mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 11:44:40 +00:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d692f60291 | |||
| a0367be686 | |||
| aa6b15aa1b | |||
| 08342c3873 | |||
| 6527a33e38 | |||
| 43f125e45d | |||
| 49cc1e26c0 | |||
| 809a82f31d | |||
| 3c8c68ce2f | |||
| 18603026a3 | |||
| 52d13cbdc2 | |||
| 26c04f1c24 | |||
| e11e402c80 | |||
| bcc7d58ddd | |||
| 685c6ad771 | |||
| 4b0372aa56 | |||
| 27aa28740e | |||
| 6291b7d0a7 | |||
| d9f0d89450 | |||
| 6ad221fcb5 | |||
| f1c55e127c | |||
| 2f6e551e22 | |||
| 6edffd0306 | |||
| 50de668d01 | |||
| 8a05d85344 | |||
| 915ce6f5f1 | |||
| 67f98b13ad | |||
| 2a147c1d3a | |||
| aae9aa6073 | |||
| 9a9885066f | |||
| 760d3349c2 | |||
| ec0dd70a4b | |||
| 4b2184955a | |||
| 8d214ff6a3 | |||
| 441df9cdf2 | |||
| e6e2789a82 | |||
| 70ceb50acd | |||
| 46d5c2e9fc | |||
| 725d2b2987 | |||
| 9581b793b5 | |||
| 3fc3f1191c | |||
| 098c79bc99 | |||
| 45b558373b | |||
| 3a2c438223 | |||
| 577287b8a8 | |||
| 1d13b054ee | |||
| dc3423df13 | |||
| 9791454d62 | |||
| 17f648f3ce | |||
| 7d89733f96 | |||
| a0e6bdfb20 | |||
| ac76ce3f03 | |||
| 4bef51a3be | |||
| 781171d60a | |||
| c01342a86c | |||
| d73a9b2acf | |||
| 1d3643a1fc | |||
| e50de00d08 | |||
| 8c87278850 | |||
| 520da46daf | |||
| 01f5e8f61d | |||
| 2faef2ae8d | |||
| 9d24d07c12 | |||
| d21ce90a5c | |||
| 889175f3de | |||
| 7a70b008c8 | |||
| 4b251e0ea4 | |||
| f242928682 | |||
| d64962147a | |||
| c2cd034556 |
+5
-7
@@ -4,12 +4,7 @@ package-lock.json
|
||||
bun.lockb
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
|
||||
.parcel-cache
|
||||
.env
|
||||
.env.submit
|
||||
|
||||
dependency-graph.svg
|
||||
bun.lock
|
||||
|
||||
# Build
|
||||
extension.zip
|
||||
@@ -19,5 +14,8 @@ betterseqtaplus-safari/
|
||||
|
||||
.million/
|
||||
.vscode/
|
||||
|
||||
**/.DS_Store
|
||||
.parcel-cache
|
||||
.env
|
||||
.env.submit
|
||||
dependency-graph.svg
|
||||
@@ -20,6 +20,7 @@
|
||||
"@types/color": "^4.2.0",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/webextension-polyfill": "^0.12.3",
|
||||
@@ -45,6 +46,7 @@
|
||||
"motion": "^12.4.12",
|
||||
"pdfjs-dist": "^5.4.530",
|
||||
"postcss": "^8.5.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "17",
|
||||
"react-best-gradient-color-picker": "3.0.11",
|
||||
"react-dom": "17",
|
||||
@@ -71,7 +73,7 @@
|
||||
"mime-types": "^3.0.1",
|
||||
"prettier": "^3.5.3",
|
||||
"process": "^0.11.10",
|
||||
"publish-browser-extension": "^4.0.3",
|
||||
"publish-browser-extension": "^4.0.4",
|
||||
"sass": "^1.85.1",
|
||||
"sass-loader": "^16.0.5",
|
||||
"semver": "^7.7.1",
|
||||
@@ -449,6 +451,8 @@
|
||||
|
||||
"@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="],
|
||||
|
||||
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "3.1.3" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.1.8", "", { "peerDependencies": { "@types/react": "19.1.12" } }, "sha512-xG7xaBMJCpcK0RpN8jDbAACQo54ycO6h4dSSmgv8+fu6ZIAdANkx/WsawASUjVXYfy+J9AbUpRMNNEsXCDfDBQ=="],
|
||||
@@ -579,6 +583,8 @@
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||
|
||||
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001737", "", {}, "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw=="],
|
||||
@@ -603,7 +609,7 @@
|
||||
|
||||
"cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "4.2.3", "strip-ansi": "6.0.1", "wrap-ansi": "7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
@@ -667,6 +673,8 @@
|
||||
|
||||
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||
|
||||
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
|
||||
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||
@@ -689,6 +697,8 @@
|
||||
|
||||
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
|
||||
|
||||
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
|
||||
|
||||
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||
|
||||
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "2.3.0", "domhandler": "5.0.3", "entities": "4.5.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||
@@ -1157,6 +1167,8 @@
|
||||
|
||||
"p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="],
|
||||
|
||||
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||
|
||||
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "3.1.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
@@ -1199,6 +1211,8 @@
|
||||
|
||||
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
|
||||
|
||||
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "3.3.11", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "4.2.0", "read-cache": "1.0.0", "resolve": "1.22.10" }, "peerDependencies": { "postcss": "8.5.6" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
|
||||
@@ -1229,12 +1243,14 @@
|
||||
|
||||
"protobufjs": ["protobufjs@6.11.4", "", { "dependencies": { "@protobufjs/aspromise": "1.1.2", "@protobufjs/base64": "1.1.2", "@protobufjs/codegen": "2.0.4", "@protobufjs/eventemitter": "1.1.0", "@protobufjs/fetch": "1.1.0", "@protobufjs/float": "1.0.2", "@protobufjs/inquire": "1.1.0", "@protobufjs/path": "1.1.2", "@protobufjs/pool": "1.1.0", "@protobufjs/utf8": "1.1.0", "@types/long": "4.0.2", "@types/node": "24.3.0", "long": "4.0.0" }, "bin": { "pbjs": "bin/pbjs", "pbts": "bin/pbts" } }, "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw=="],
|
||||
|
||||
"publish-browser-extension": ["publish-browser-extension@4.0.3", "", { "dependencies": { "cac": "^6.7.14", "consola": "^3.4.2", "dotenv": "^17.2.4", "form-data-encoder": "^4.1.0", "formdata-node": "^6.0.3", "jsonwebtoken": "^9.0.3", "listr2": "^10.1.0", "ofetch": "^1.5.1", "zod": "3.25.76 || ^4.3.6" }, "bin": { "publish-extension": "bin/publish-extension.mjs" } }, "sha512-yhzn+0z0tOYSsouEVCn6BHd3PPEc6KKplEVDEmxCOAMXC0C7NROEiJcmWm5LGGgqw3TqBvPxiink1juPrEbMqA=="],
|
||||
"publish-browser-extension": ["publish-browser-extension@4.0.4", "", { "dependencies": { "cac": "^6.7.14", "consola": "^3.4.2", "dotenv": "^17.2.4", "form-data-encoder": "^4.1.0", "formdata-node": "^6.0.3", "jsonwebtoken": "^9.0.3", "listr2": "^10.1.0", "ofetch": "^1.5.1", "zod": "3.25.76 || ^4.3.6" }, "bin": { "publish-extension": "bin/publish-extension.mjs" } }, "sha512-QMQbWL0FWgBfnkJ6w8HOJoIPaWLE7vTpewM4ae2vLs7SrD4eKdAk+SxOzqAICwbhEPuaLAOA+XkT9sZS5R0PmA=="],
|
||||
|
||||
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "1.4.5", "once": "1.4.0" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
|
||||
|
||||
"punycode": ["punycode@1.4.1", "", {}, "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="],
|
||||
|
||||
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
|
||||
|
||||
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
@@ -1265,6 +1281,8 @@
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
|
||||
|
||||
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "2.16.1", "path-parse": "1.0.7", "supports-preserve-symlinks-flag": "1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
@@ -1301,6 +1319,8 @@
|
||||
|
||||
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
|
||||
|
||||
"sharp": ["sharp@0.32.6", "", { "dependencies": { "color": "4.2.3", "detect-libc": "2.0.4", "node-addon-api": "6.1.0", "prebuild-install": "7.1.3", "semver": "7.7.2", "simple-get": "4.0.1", "tar-fs": "3.1.0", "tunnel-agent": "0.6.0" } }, "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
@@ -1489,6 +1509,8 @@
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
@@ -1503,15 +1525,15 @@
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
|
||||
|
||||
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "8.0.1", "escalade": "3.2.0", "get-caller-file": "2.0.5", "require-directory": "2.1.1", "string-width": "4.2.3", "y18n": "5.0.8", "yargs-parser": "21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@1.2.1", "", {}, "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg=="],
|
||||
|
||||
@@ -1593,10 +1615,12 @@
|
||||
|
||||
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "4.3.0", "string-width": "4.2.3", "strip-ansi": "6.0.1" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
"cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
|
||||
"concurrently/rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
|
||||
|
||||
"concurrently/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "8.0.1", "escalade": "3.2.0", "get-caller-file": "2.0.5", "require-directory": "2.1.1", "string-width": "4.2.3", "y18n": "5.0.8", "yargs-parser": "21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"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=="],
|
||||
@@ -1713,6 +1737,8 @@
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
|
||||
"z-schema/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
|
||||
|
||||
"@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
@@ -1745,6 +1771,12 @@
|
||||
|
||||
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"concurrently/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "4.2.3", "strip-ansi": "6.0.1", "wrap-ansi": "7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"concurrently/yargs/y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"concurrently/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"listr-update-renderer/chalk/ansi-styles": ["ansi-styles@2.2.1", "", {}, "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA=="],
|
||||
@@ -1845,12 +1877,18 @@
|
||||
|
||||
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="],
|
||||
|
||||
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"@microsoft/api-extractor/semver/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"@rushstack/node-core-library/semver/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="],
|
||||
|
||||
"concurrently/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"concurrently/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "4.3.0", "string-width": "4.2.3", "strip-ansi": "6.0.1" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"listr-update-renderer/cli-truncate/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@1.0.0", "", { "dependencies": { "number-is-nan": "1.0.1" } }, "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw=="],
|
||||
|
||||
"listr-update-renderer/log-update/wrap-ansi/string-width": ["string-width@2.1.1", "", { "dependencies": { "is-fullwidth-code-point": "2.0.0", "strip-ansi": "4.0.0" } }, "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw=="],
|
||||
@@ -1981,6 +2019,10 @@
|
||||
|
||||
"vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
|
||||
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"concurrently/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"listr-update-renderer/log-update/wrap-ansi/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@2.0.0", "", {}, "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w=="],
|
||||
|
||||
"listr-update-renderer/log-update/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@3.0.1", "", {}, "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw=="],
|
||||
@@ -1990,5 +2032,7 @@
|
||||
"log-update/wrap-ansi/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="],
|
||||
|
||||
"pkg-install/execa/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@1.0.0", "", {}, "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ=="],
|
||||
|
||||
"yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "betterseqtaplus",
|
||||
"version": "3.4.15",
|
||||
"version": "3.5.1",
|
||||
"type": "module",
|
||||
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development add add heaps more features!",
|
||||
"browserslist": "> 0.5%, last 2 versions, not dead",
|
||||
@@ -49,7 +49,7 @@
|
||||
"mime-types": "^3.0.1",
|
||||
"prettier": "^3.5.3",
|
||||
"process": "^0.11.10",
|
||||
"publish-browser-extension": "^4.0.3",
|
||||
"publish-browser-extension": "^4.0.4",
|
||||
"sass": "^1.85.1",
|
||||
"sass-loader": "^16.0.5",
|
||||
"semver": "^7.7.1",
|
||||
@@ -72,6 +72,7 @@
|
||||
"@types/color": "^4.2.0",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/webextension-polyfill": "^0.12.3",
|
||||
@@ -97,6 +98,7 @@
|
||||
"motion": "^12.4.12",
|
||||
"pdfjs-dist": "^5.4.530",
|
||||
"postcss": "^8.5.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "17",
|
||||
"react-best-gradient-color-picker": "3.0.11",
|
||||
"react-dom": "17",
|
||||
|
||||
@@ -11,6 +11,30 @@ import { main } from "@/seqta/main";
|
||||
import { delay } from "./seqta/utils/delay";
|
||||
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
|
||||
|
||||
function registerFetchSeqtaAppLinkListener() {
|
||||
browser.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
||||
if (request?.type !== "fetchSeqtaAppLink") return false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(`${location.origin}/seqta/student/load/profile`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = await res.json();
|
||||
const statusOk = data?.status === "200" || data?.status === 200;
|
||||
const raw = data?.payload?.app_link;
|
||||
const appLink = typeof raw === "string" && raw.length > 0 ? raw : null;
|
||||
sendResponse({ appLink: statusOk ? appLink : null });
|
||||
} catch {
|
||||
sendResponse({ appLink: null });
|
||||
}
|
||||
})();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export let MenuOptionsOpen = false;
|
||||
|
||||
var IsSEQTAPage = false;
|
||||
@@ -30,6 +54,8 @@ async function init() {
|
||||
IsSEQTAPage = true;
|
||||
console.info("[BetterSEQTA+] Verified SEQTA Page");
|
||||
|
||||
registerFetchSeqtaAppLinkListener();
|
||||
|
||||
const documentLoadStyle = document.createElement("style");
|
||||
documentLoadStyle.textContent = documentLoadCSS;
|
||||
document.head.appendChild(documentLoadStyle);
|
||||
|
||||
+244
-43
@@ -14,52 +14,250 @@ function reloadSeqtaPages() {
|
||||
result.then(open, console.error);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
/** Callback for sending a response back to the message sender */
|
||||
type MessageSender = { (response?: unknown): void };
|
||||
|
||||
function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
|
||||
const { token } = request;
|
||||
const apiUrl = `https://betterseqta.org/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
|
||||
const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`;
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
fetch(apiUrl, { cache: "no-store", headers })
|
||||
.then((r) => r.json())
|
||||
.then(sendResponse)
|
||||
.catch((err) => {
|
||||
console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message);
|
||||
fetch(githubUrl, { cache: "no-store" })
|
||||
.then((r) => r.json())
|
||||
.then((data) => sendResponse({ success: true, data: { themes: data.themes ?? [] } }))
|
||||
.catch((fallbackErr) => {
|
||||
console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr);
|
||||
sendResponse({ success: false, error: fallbackErr?.message });
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boolean {
|
||||
const { themeId, token } = request;
|
||||
if (!themeId || typeof themeId !== "string") {
|
||||
sendResponse({ success: false, error: "Missing themeId" });
|
||||
return false;
|
||||
}
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
fetch(`https://betterseqta.org/api/themes/${themeId}`, { cache: "no-store", headers })
|
||||
.then((r) => r.json())
|
||||
.then(sendResponse)
|
||||
.catch((err) => {
|
||||
console.error("[Background] fetchThemeDetails error:", err);
|
||||
sendResponse({ success: false, error: err?.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleFetchFromUrl(request: any, sendResponse: MessageSender): boolean {
|
||||
const { url } = request;
|
||||
if (!url || typeof url !== "string") {
|
||||
sendResponse({ error: "Missing url" });
|
||||
return false;
|
||||
}
|
||||
fetch(url, { cache: "no-store" })
|
||||
.then((r) => r.json())
|
||||
.then((data) => sendResponse({ data }))
|
||||
.catch((err) => {
|
||||
console.error("[Background] fetchFromUrl error:", err);
|
||||
sendResponse({ error: err?.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function parseJsonResponse(r: Response): Promise<any> {
|
||||
const text = await r.text();
|
||||
try {
|
||||
return text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function handleCloudReserveClient(request: any, sendResponse: MessageSender): boolean {
|
||||
const redirect_uri = request.redirect_uri ?? "https://accounts.betterseqta.org/auth/bsplus/callback";
|
||||
fetch("https://accounts.betterseqta.org/api/bsplus/client/reserve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ redirect_uri }),
|
||||
})
|
||||
.then(async (r) => {
|
||||
const data = await parseJsonResponse(r);
|
||||
if (!r.ok) sendResponse({ error: data?.error ?? `Reserve failed (${r.status})` });
|
||||
else sendResponse(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[Background] cloudReserveClient error:", err);
|
||||
sendResponse({ error: err?.message ?? "Network error" });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleCloudLogin(request: any, sendResponse: MessageSender): boolean {
|
||||
const { client_id, redirect_uri, login, password } = request;
|
||||
if (!client_id || !redirect_uri || !login || !password) {
|
||||
sendResponse({ error: "Missing client_id, redirect_uri, login, or password" });
|
||||
return false;
|
||||
}
|
||||
fetch("https://accounts.betterseqta.org/api/bsplus/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ client_id, redirect_uri, login, password }),
|
||||
})
|
||||
.then(async (r) => {
|
||||
const data = await parseJsonResponse(r);
|
||||
if (!r.ok) sendResponse({ error: data?.error ?? "Login failed" });
|
||||
else sendResponse(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[Background] cloudLogin error:", err);
|
||||
sendResponse({ error: err?.message ?? "Network error" });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean {
|
||||
const { refresh_token, client_id } = request;
|
||||
if (!refresh_token || !client_id) {
|
||||
sendResponse({ error: "Missing refresh_token or client_id" });
|
||||
return false;
|
||||
}
|
||||
fetch("https://accounts.betterseqta.org/api/bsplus/refresh", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token, client_id }),
|
||||
})
|
||||
.then(async (r) => {
|
||||
const data = await parseJsonResponse(r);
|
||||
if (!r.ok) sendResponse({ error: data?.error ?? "Refresh failed" });
|
||||
else sendResponse(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[Background] cloudRefresh error:", err);
|
||||
sendResponse({ error: err?.message ?? "Network error" });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean {
|
||||
const { themeId, token, action } = request;
|
||||
if (!themeId || !token) {
|
||||
sendResponse({ success: false, error: "Theme ID and token required" });
|
||||
return false;
|
||||
}
|
||||
const isFavorite = action === "favorite";
|
||||
fetch(`https://betterseqta.org/api/themes/${themeId}/favorite`, {
|
||||
method: isFavorite ? "POST" : "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then(sendResponse)
|
||||
.catch((err) => {
|
||||
console.error("[Background] cloudFavorite error:", err);
|
||||
sendResponse({ success: false, error: err?.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Handler for a message type; receives request, sendResponse, and optional sender (for tab routing) */
|
||||
type MessageHandler = {
|
||||
(request: any, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender): boolean | void;
|
||||
};
|
||||
|
||||
function isSeqtaOrigin(origin: string): boolean {
|
||||
try {
|
||||
const u = new URL(origin);
|
||||
return u.hostname.includes("seqta") || u.hostname.endsWith(".edu.au");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
|
||||
reloadTabs: () => reloadSeqtaPages(),
|
||||
extensionPages: (req) => {
|
||||
browser.tabs.query({}).then((tabs) => {
|
||||
for (const tab of tabs) {
|
||||
if (tab.url?.includes("chrome-extension://")) browser.tabs.sendMessage(tab.id!, req);
|
||||
}
|
||||
});
|
||||
},
|
||||
currentTab: (req, sendResponse) => {
|
||||
browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => {
|
||||
browser.tabs.sendMessage(tabs[0].id!, req).then(sendResponse);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
githubTab: () => {
|
||||
void browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" });
|
||||
},
|
||||
setDefaultStorage: () => SetStorageValue(getDefaultValues()),
|
||||
sendNews: (req, sendResponse) => {
|
||||
fetchNews(req.source ?? "australia", sendResponse);
|
||||
return true;
|
||||
},
|
||||
fetchThemes: handleFetchThemes,
|
||||
fetchThemeDetails: handleFetchThemeDetails,
|
||||
fetchFromUrl: handleFetchFromUrl,
|
||||
cloudReserveClient: handleCloudReserveClient,
|
||||
cloudLogin: handleCloudLogin,
|
||||
cloudRefresh: handleCloudRefresh,
|
||||
cloudFavorite: handleCloudFavorite,
|
||||
getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => {
|
||||
(async () => {
|
||||
try {
|
||||
let tabId = sender?.tab?.id;
|
||||
let originForCheck: string | undefined = req.baseUrl;
|
||||
|
||||
if (tabId == null) {
|
||||
const tabs = await browser.tabs.query({ active: true, lastFocusedWindow: true });
|
||||
const tab = tabs[0];
|
||||
if (!tab?.id || !tab.url) {
|
||||
sendResponse({ appLink: null });
|
||||
return;
|
||||
}
|
||||
tabId = tab.id;
|
||||
if (!originForCheck) originForCheck = new URL(tab.url).origin;
|
||||
} else if (!originForCheck && sender?.tab?.url) {
|
||||
originForCheck = new URL(sender.tab.url).origin;
|
||||
}
|
||||
|
||||
if (!originForCheck || !isSeqtaOrigin(originForCheck)) {
|
||||
sendResponse({ appLink: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const reply = (await browser.tabs.sendMessage(tabId, { type: "fetchSeqtaAppLink" })) as
|
||||
| { appLink?: string | null }
|
||||
| undefined;
|
||||
const appLink = typeof reply?.appLink === "string" && reply.appLink.length > 0 ? reply.appLink : null;
|
||||
sendResponse({ appLink });
|
||||
} catch (err) {
|
||||
console.error("[Background] getSeqtaSession error:", err);
|
||||
sendResponse({ appLink: null });
|
||||
}
|
||||
})();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
browser.runtime.onMessage.addListener(
|
||||
(request: any, _: any, sendResponse: (response?: any) => void) => {
|
||||
switch (request.type) {
|
||||
case "reloadTabs":
|
||||
reloadSeqtaPages();
|
||||
break;
|
||||
|
||||
case "extensionPages":
|
||||
browser.tabs.query({}).then(function (tabs) {
|
||||
for (let tab of tabs) {
|
||||
if (tab.url?.includes("chrome-extension://")) {
|
||||
browser.tabs.sendMessage(tab.id!, request);
|
||||
// @ts-ignore - OnMessageListener expects literal true for async, we return boolean
|
||||
(request: any, sender: browser.Runtime.MessageSender, sendResponse: MessageSender) => {
|
||||
const handler = MESSAGE_HANDLERS[request.type];
|
||||
if (handler) {
|
||||
const result = handler(request, sendResponse, sender);
|
||||
return result === true;
|
||||
}
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case "currentTab":
|
||||
browser.tabs
|
||||
.query({ active: true, currentWindow: true })
|
||||
.then(function (tabs) {
|
||||
browser.tabs
|
||||
.sendMessage(tabs[0].id!, request)
|
||||
.then(function (response) {
|
||||
sendResponse(response);
|
||||
});
|
||||
});
|
||||
return true;
|
||||
|
||||
case "githubTab":
|
||||
browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" });
|
||||
break;
|
||||
|
||||
case "setDefaultStorage":
|
||||
SetStorageValue(getDefaultValues());
|
||||
break;
|
||||
|
||||
case "sendNews":
|
||||
fetchNews(request.source ?? "australia", sendResponse);
|
||||
return true;
|
||||
|
||||
default:
|
||||
console.log("Unknown request type");
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
);
|
||||
@@ -127,6 +325,9 @@ function getDefaultValues(): SettingsState {
|
||||
customshortcuts: [],
|
||||
lettergrade: false,
|
||||
newsSource: "australia",
|
||||
iconOnlySidebar: false,
|
||||
adaptiveThemeColour: false,
|
||||
adaptiveThemeGradient: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+13
-12
@@ -92,8 +92,12 @@ const rssFeedsByCountry: Record<string, string[]> = {
|
||||
* used to send the fetched news data back to the caller.
|
||||
* It's called with an object like `{ news: { articles: [...] } }`.
|
||||
*/
|
||||
export async function fetchNews(source: string, sendResponse: any) {
|
||||
if (source === "australia") {
|
||||
export async function fetchNews(source: string | undefined, sendResponse: any) {
|
||||
const normalizedSource = typeof source === "string" && source.trim()
|
||||
? source.trim()
|
||||
: "australia";
|
||||
|
||||
if (normalizedSource === "australia") {
|
||||
const date = new Date();
|
||||
|
||||
const from =
|
||||
@@ -111,18 +115,15 @@ export async function fetchNews(source: string, sendResponse: any) {
|
||||
|
||||
const parser = new Parser();
|
||||
let feeds: string[];
|
||||
console.log("fetchNews", source);
|
||||
console.log("fetchNews", normalizedSource);
|
||||
|
||||
if (rssFeedsByCountry[source.toLowerCase()]) {
|
||||
// If the source is a country, fetch from predefined feeds
|
||||
feeds = rssFeedsByCountry[source.toLowerCase()];
|
||||
} else if (source.startsWith("http")) {
|
||||
// If the source is a URL, use it directly
|
||||
feeds = [source];
|
||||
if (rssFeedsByCountry[normalizedSource.toLowerCase()]) {
|
||||
feeds = rssFeedsByCountry[normalizedSource.toLowerCase()];
|
||||
} else if (normalizedSource.startsWith("http")) {
|
||||
feeds = [normalizedSource];
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid source. Provide a country code or a valid RSS feed URL.",
|
||||
);
|
||||
console.warn("[BetterSEQTA+] Invalid news source, falling back to Australia", normalizedSource);
|
||||
return fetchNews("australia", sendResponse);
|
||||
}
|
||||
|
||||
const articlesPromises = feeds.map(async (feedUrl) => {
|
||||
|
||||
@@ -26,6 +26,29 @@
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "IconFamily";
|
||||
src: url("@/resources/fonts/IconFamily.woff") format("woff");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@layer base, override;
|
||||
|
||||
@layer override {
|
||||
* {
|
||||
font-family: Rubik, sans-serif !important;
|
||||
}
|
||||
|
||||
.iconFamily,
|
||||
.iconFamily *,
|
||||
[class~="iconFamily"],
|
||||
[class~="iconFamily"] * {
|
||||
font-family: "IconFamily" !important;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
background: #161616 !important;
|
||||
background-color: #161616;
|
||||
|
||||
+190
-29
@@ -22,10 +22,6 @@
|
||||
font-family: Rubik, sans-serif !important;
|
||||
}
|
||||
|
||||
*:not([class^="Canvas__canvas___"]):not([class^="Canvas__canvas___"] *):not([class^="ThemeCard__"]):not([class^="ThemeCard__"] *):not([class^="ThemePreview__"]):not([class^="ThemePreview__"] *):not([class^="academicReportsWrapper"]):not([class^="academicReportsWrapper"] *):not(textarea):not(.doesntexist) {
|
||||
font-family: Rubik, sans-serif !important;
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
@@ -64,20 +60,62 @@ body {
|
||||
font-family: Rubik, sans-serif !important;
|
||||
}
|
||||
|
||||
select option {
|
||||
background-color: #ffffff !important;
|
||||
color: #111827 !important;
|
||||
}
|
||||
.dark select option {
|
||||
background-color: #1f2937 !important;
|
||||
color: #ffffff !important;
|
||||
select {
|
||||
border-radius: 16px !important;
|
||||
border: 1px solid color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 78%, transparent) !important;
|
||||
background: color-mix(in srgb, var(--background-primary) 90%, transparent) !important;
|
||||
color: var(--text-primary) !important;
|
||||
transition:
|
||||
background-color 180ms ease,
|
||||
border-color 180ms ease,
|
||||
box-shadow 180ms ease !important;
|
||||
}
|
||||
|
||||
select {
|
||||
border-radius: 8px !important;
|
||||
select:hover {
|
||||
background: color-mix(in srgb, var(--background-primary) 94%, var(--background-secondary) 6%) !important;
|
||||
border-color: color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 92%, transparent) !important;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
outline: none !important;
|
||||
background: color-mix(in srgb, var(--background-primary) 96%, var(--background-secondary) 4%) !important;
|
||||
border-color: color-mix(in srgb, var(--text-primary) 18%, var(--theme-offset-bg, var(--background-secondary)) 82%) !important;
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary) 12%, transparent) !important;
|
||||
}
|
||||
|
||||
select:not([multiple]):not([size]),
|
||||
select[size="1"] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23999'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important;
|
||||
background-position: right 0.9rem center !important;
|
||||
background-repeat: no-repeat !important;
|
||||
background-size: 1rem !important;
|
||||
padding-right: 2.6rem !important;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
select::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
select option {
|
||||
background: var(--background-primary) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.dark select:not([multiple]):not([size]),
|
||||
.dark select[size="1"] {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23c9c9c9'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.dark select option {
|
||||
background: var(--background-primary) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
#container {
|
||||
transition: 200ms;
|
||||
background: var(--auto-background) !important;
|
||||
}
|
||||
:root * {
|
||||
@@ -290,8 +328,13 @@ select {
|
||||
}
|
||||
}
|
||||
|
||||
.timetable-zoom {
|
||||
.timetable-zoom,
|
||||
.timetable-hide {
|
||||
font-size: 14px !important;
|
||||
line-height: 1 !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#main > .dashboard {
|
||||
@@ -448,6 +491,7 @@ ul.magicDelete > li.deleting {
|
||||
background: var(--better-main) !important;
|
||||
color: var(--text-color);
|
||||
border-right: none;
|
||||
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
#menu li > label > svg,
|
||||
#menu section > label > svg {
|
||||
@@ -455,6 +499,63 @@ ul.magicDelete > li.deleting {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
}
|
||||
|
||||
/* Icon Only Sidebar - compact mode. When submenu is open, all styles are disabled so it behaves as normal sidebar. */
|
||||
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) {
|
||||
#menu {
|
||||
width: 70px !important;
|
||||
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
#menu > ul {
|
||||
min-width: 0 !important;
|
||||
overflow-x: visible !important;
|
||||
}
|
||||
|
||||
#content {
|
||||
left: 70px !important;
|
||||
transition: left 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
#menu .sub {
|
||||
left: 70px !important;
|
||||
transition: left 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
#menu li > label span,
|
||||
#menu section > label span {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#menu ul > li > svg {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#menu ul li,
|
||||
#menu ul section {
|
||||
min-width: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 8px !important;
|
||||
justify-content: center !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
#menu li > label,
|
||||
#menu section > label {
|
||||
flex: 0 0 auto !important;
|
||||
min-width: 0 !important;
|
||||
justify-content: center !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
#menu li > label > svg,
|
||||
#menu section > label > svg {
|
||||
margin: 0 auto !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
}
|
||||
[class*="notifications__items___"] {
|
||||
-ms-overflow-style: none !important;
|
||||
scrollbar-width: none !important;
|
||||
@@ -873,12 +974,20 @@ html.transparencyEffects
|
||||
#menu li:hover {
|
||||
background: rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
#menu li:focus-visible,
|
||||
#menu section:focus-visible {
|
||||
outline: 1px solid rgb(from currentColor r g b / 0.55);
|
||||
outline-offset: -1px;
|
||||
background: rgba(0, 0, 0, 0.2) !important;
|
||||
}
|
||||
|
||||
#main > .timetablepage > .container {
|
||||
background: var(--background-primary);
|
||||
}
|
||||
|
||||
#content {
|
||||
transition: 0.4s;
|
||||
transition: left 0.4s cubic-bezier(0.4, 0, 0.2, 1), transform 0.4s ease;
|
||||
left: 270px;
|
||||
background: unset;
|
||||
}
|
||||
@@ -921,6 +1030,17 @@ html.transparencyEffects
|
||||
-webkit-transform: translatex(270px);
|
||||
transform: translatex(270px);
|
||||
}
|
||||
|
||||
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) {
|
||||
#menu {
|
||||
-webkit-transform: translatex(-70px);
|
||||
transform: translatex(-70px);
|
||||
}
|
||||
.menuShown #content {
|
||||
-webkit-transform: translatex(70px);
|
||||
transform: translatex(70px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1145px) {
|
||||
@@ -1186,7 +1306,7 @@ html.transparencyEffects
|
||||
height: 15em;
|
||||
font-family: Rubik, sans-serif !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 700 !important;
|
||||
font-weight: 500 !important;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(100px, 1fr);
|
||||
@@ -1677,6 +1797,22 @@ iframe.userHTML {
|
||||
background-image: unset;
|
||||
background-color: var(--auto-background);
|
||||
}
|
||||
[class*="OverallResult__OverallResult___"] {
|
||||
--fill-colour: var(--assessment-accent-colour, var(--item-colour, var(--container-accent, rgb(var(--theme-sel-bg-parts)))));
|
||||
}
|
||||
[class*="OverallResult__OverallResult___"] [class*="Thermoscore__Thermoscore___"],
|
||||
[class*="OverallResult__OverallResult___"] [class*="Thermoscore__fill___"],
|
||||
[class*="OverallResult__OverallResult___"] > div > div {
|
||||
--fill-colour: var(--assessment-accent-colour, var(--item-colour, var(--container-accent, rgb(var(--theme-sel-bg-parts)))));
|
||||
}
|
||||
[class*="AssessableCriterion__header___"] {
|
||||
--fill-colour: var(--assessment-accent-colour, var(--item-colour, var(--container-accent, rgb(var(--theme-sel-bg-parts)))));
|
||||
}
|
||||
[class*="AssessableCriterion__header___"] [class*="Thermoscore__Thermoscore___"],
|
||||
[class*="AssessableCriterion__header___"] [class*="Thermoscore__fill___"],
|
||||
[class*="AssessableCriterion__header___"] > div > div > div {
|
||||
--fill-colour: var(--assessment-accent-colour, var(--item-colour, var(--container-accent, rgb(var(--theme-sel-bg-parts)))));
|
||||
}
|
||||
.dark [class*="Thermoscore__Thermoscore___"] {
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
@@ -2290,7 +2426,7 @@ div.bar.flat {
|
||||
padding: 0 !important;
|
||||
padding-left: 8px !important;
|
||||
gap: 0 8px;
|
||||
background: var(--better-main);
|
||||
background: transparent !important;
|
||||
}
|
||||
.cke_toolbar:has(.cke_toolgroup) {
|
||||
.cke_combo {
|
||||
@@ -2689,20 +2825,25 @@ body {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
will-change: transform;
|
||||
}
|
||||
.logo {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.big-circle {
|
||||
margin: -88px;
|
||||
animation: spin 3s ease infinite;
|
||||
-moz-animation: spin 3s ease infinite;
|
||||
will-change: transform;
|
||||
animation-timing-function: linear;
|
||||
animation: spin 3s linear infinite;
|
||||
-moz-animation: spin 3s linear infinite;
|
||||
}
|
||||
|
||||
.small-circle {
|
||||
margin: -66px;
|
||||
animation: spin 3s ease infinite;
|
||||
-moz-animation: spin 3s ease infinite;
|
||||
will-change: transform;
|
||||
animation-timing-function: linear;
|
||||
animation: spin 3s linear infinite;
|
||||
-moz-animation: spin 3s linear infinite;
|
||||
}
|
||||
|
||||
.dark [class*="LabelList__name___"] {
|
||||
@@ -2746,8 +2887,10 @@ body {
|
||||
}
|
||||
.outer-circle {
|
||||
margin: -108px;
|
||||
animation: spinback 1s linear infinite alternate-reverse;
|
||||
-moz-animation: spinback 1s linear infinite alternate-reverse;
|
||||
will-change: transform;
|
||||
animation-direction: alternate-reverse;
|
||||
animation: spinback 1s linear infinite;
|
||||
-moz-animation: spinback 1s linear infinite;
|
||||
}
|
||||
@-moz-keyframes spin {
|
||||
100% {
|
||||
@@ -2855,8 +2998,14 @@ div.day-empty {
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
#notice-container.day-empty,
|
||||
#notice-container .day-empty {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
.upcoming-submittedtext {
|
||||
align-self: center;
|
||||
padding: 8px 25px;
|
||||
@@ -3036,7 +3185,7 @@ div.day-empty {
|
||||
border-radius: 16px;
|
||||
font-family: Rubik, sans-serif !important;
|
||||
font-size: 20px !important;
|
||||
font-weight: 700 !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
.dark .upcoming-items {
|
||||
box-shadow: inset 0px 40px 80px -40px rgba(0, 0, 0, 0.6);
|
||||
@@ -3288,7 +3437,7 @@ div.day-empty {
|
||||
.whatsnewImg {
|
||||
margin: 0 auto;
|
||||
width: 90%;
|
||||
aspect-ratio: 16 / 10;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
@@ -3550,6 +3699,17 @@ div.day-empty {
|
||||
scrollbar-width: none !important;
|
||||
}
|
||||
|
||||
/* Fix SEQTA update: ul.logo-link covers sidebar and causes page reload on empty space clicks */
|
||||
#menu ul.logo-link {
|
||||
pointer-events: none;
|
||||
|
||||
/* Re-enable clicks on menu items while sidebar editing swaps built-in items to <section> */
|
||||
li,
|
||||
section {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-unified-content.notice-modal-state {
|
||||
border: none !important;
|
||||
}
|
||||
@@ -3619,7 +3779,7 @@ div.day-empty {
|
||||
background: var(--background-primary);
|
||||
border-radius: 16px;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
max-height: 90vh;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||
@@ -3685,7 +3845,7 @@ div.day-empty {
|
||||
margin: 0 !important;
|
||||
flex: 1;
|
||||
display: block;
|
||||
min-width: 600px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -3702,7 +3862,8 @@ div.day-empty {
|
||||
}
|
||||
|
||||
.notice-content-body {
|
||||
overflow-y: hidden;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
||||
@@ -35,9 +35,19 @@
|
||||
}
|
||||
|
||||
#menu .sub {
|
||||
transition: transform 0.3s ease;
|
||||
transition: transform 0.3s ease, left 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
#menu > ul:has(li.hasChildren.active) > li.active {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Icon-only collapsed: submenu slides over narrow icons */
|
||||
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul:has(li.hasChildren.active) > li::before,
|
||||
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li::before,
|
||||
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul:has(li.hasChildren.active) > li > label,
|
||||
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul:has(li.hasChildren.active) > li > svg,
|
||||
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li > label,
|
||||
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li > svg {
|
||||
transform: translateX(-70px);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import browser from "webextension-polyfill";
|
||||
import QRCode from "qrcode";
|
||||
import { portal } from "../utils/portal";
|
||||
|
||||
let showQrModal = $state(false);
|
||||
let qrDataUrl = $state<string | null>(null);
|
||||
let appLink = $state<string | null>(null);
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let isLoading = $state(false);
|
||||
let isStandalone = $state(false);
|
||||
|
||||
function isExtensionPage(): boolean {
|
||||
return (
|
||||
window.location.protocol === "chrome-extension:" ||
|
||||
window.location.protocol === "moz-extension:"
|
||||
);
|
||||
}
|
||||
|
||||
function isSeqtaUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.hostname.includes("seqta") || u.hostname.endsWith(".edu.au");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.origin;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
async function getAppLink(): Promise<string | null> {
|
||||
let baseUrl: string | undefined;
|
||||
|
||||
if (isExtensionPage()) {
|
||||
baseUrl = undefined;
|
||||
} else {
|
||||
baseUrl = normalizeBaseUrl(window.location.href);
|
||||
if (!isSeqtaUrl(baseUrl)) return null;
|
||||
}
|
||||
|
||||
const { appLink: link } = (await browser.runtime.sendMessage({
|
||||
type: "getSeqtaSession",
|
||||
baseUrl,
|
||||
})) as { appLink: string | null };
|
||||
return link ?? null;
|
||||
}
|
||||
|
||||
async function generateQrCode() {
|
||||
errorMessage = null;
|
||||
qrDataUrl = null;
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
isStandalone = isExtensionPage();
|
||||
const link = await getAppLink();
|
||||
|
||||
if (!link) {
|
||||
if (isStandalone) {
|
||||
errorMessage =
|
||||
"Open SEQTA Learn in a tab and log in, then open settings from that tab to generate a QR code.";
|
||||
} else {
|
||||
errorMessage = "Please log in to SEQTA Learn first.";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const dataUrl = await QRCode.toDataURL(link, { width: 256, margin: 2 });
|
||||
appLink = link;
|
||||
qrDataUrl = dataUrl;
|
||||
showQrModal = true;
|
||||
} catch (err) {
|
||||
console.error("[ConnectMobileApp] Failed to generate QR:", err);
|
||||
errorMessage = "Failed to generate QR code. Please try again.";
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showQrModal = false;
|
||||
qrDataUrl = null;
|
||||
appLink = null;
|
||||
errorMessage = null;
|
||||
}
|
||||
|
||||
function openAppLink() {
|
||||
if (appLink) window.location.href = appLink;
|
||||
}
|
||||
|
||||
function downloadQrImage() {
|
||||
if (!qrDataUrl) return;
|
||||
const link = document.createElement("a");
|
||||
link.href = qrDataUrl;
|
||||
link.download = "desqta-login-qr.png";
|
||||
link.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1 items-end">
|
||||
<button
|
||||
type="button"
|
||||
onclick={generateQrCode}
|
||||
disabled={isLoading}
|
||||
class="px-5 py-1.5 text-[0.75rem] text-nowrap shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-opacity">
|
||||
{isLoading ? "Generating..." : "Generate QR"}
|
||||
</button>
|
||||
{#if errorMessage}
|
||||
<p class="text-xs text-right text-amber-600 dark:text-amber-400">{errorMessage}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showQrModal && qrDataUrl}
|
||||
<div
|
||||
use:portal
|
||||
class="fixed cursor-auto inset-0 z-[10000] flex justify-center items-center bg-black/50 {isStandalone ? 'backdrop-blur-sm' : ''}"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) closeModal();
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Escape") closeModal();
|
||||
}}
|
||||
transition:fade={{ duration: 150 }}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="p-6 mx-4 w-full max-w-sm bg-white rounded-2xl shadow-2xl dark:bg-zinc-800"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-lg font-bold text-zinc-900 dark:text-white">Scan with DesQTA</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeModal}
|
||||
class="p-2 rounded-lg transition-colors text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 dark:hover:text-zinc-400 dark:hover:bg-zinc-700"
|
||||
aria-label="Close">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex justify-center p-4 bg-white rounded-xl dark:bg-zinc-900">
|
||||
<img src={qrDataUrl} alt="SEQTA Learn app link QR code" class="w-64 h-64" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={openAppLink}
|
||||
class="px-4 py-2.5 w-full text-sm font-medium text-white bg-indigo-600 rounded-lg transition-colors dark:bg-indigo-500 hover:bg-indigo-700 dark:hover:bg-indigo-600">
|
||||
Sign into DesQTA Desktop
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={downloadQrImage}
|
||||
class="px-4 py-2 w-full text-xs font-medium rounded-lg border transition-colors text-zinc-500 dark:text-zinc-400 border-zinc-200 dark:border-zinc-600 hover:bg-zinc-50 dark:hover:bg-zinc-800/50">
|
||||
Download QR as image
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-center text-zinc-600 dark:text-zinc-400">
|
||||
Or scan this QR code with DesQTA on your phone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -8,12 +8,12 @@
|
||||
let select: HTMLSelectElement;
|
||||
</script>
|
||||
|
||||
<div class="border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 shadow-2xl rounded-xl w-full overflow-clip">
|
||||
<div class="select-wrapper relative w-full overflow-hidden rounded-2xl border shadow-2xl">
|
||||
<select
|
||||
bind:this={select}
|
||||
value={state}
|
||||
onchange={() => onChange(select.value)}
|
||||
class="px-4 py-2 pr-9 text-[0.875rem] font-medium text-black dark:text-white w-full border-none bg-white/80 dark:bg-zinc-800/70 hover:bg-white/90 dark:hover:bg-zinc-800/80 focus:bg-white/90 dark:focus:bg-zinc-800/80 focus:ring-0 rounded-md appearance-none transition-colors"
|
||||
class="select-input w-full appearance-none border-none bg-transparent px-4 py-2.5 pr-10 text-[0.875rem] font-medium transition-colors"
|
||||
>
|
||||
{#each options as option}
|
||||
<option value={option.value}>
|
||||
@@ -21,20 +21,62 @@
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="select-icon pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3" aria-hidden="true">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
|
||||
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Make native dropdown list readable on Windows */
|
||||
select option {
|
||||
background-color: #ffffff;
|
||||
color: #111827; /* zinc-900 */
|
||||
}
|
||||
:global(.dark) select option {
|
||||
background-color: #1f2937; /* zinc-800 */
|
||||
color: #ffffff;
|
||||
.select-wrapper {
|
||||
background: color-mix(in srgb, var(--background-primary) 88%, transparent);
|
||||
border-color: color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 72%, transparent);
|
||||
border-radius: 18px;
|
||||
color: var(--text-primary);
|
||||
transition:
|
||||
background-color 180ms ease,
|
||||
border-color 180ms ease,
|
||||
box-shadow 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
:global(.dark) div::after {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
.select-wrapper:hover {
|
||||
background: color-mix(in srgb, var(--background-primary) 94%, var(--background-secondary) 6%);
|
||||
border-color: color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 88%, transparent);
|
||||
}
|
||||
|
||||
.select-wrapper:focus-within {
|
||||
background: color-mix(in srgb, var(--background-primary) 96%, var(--background-secondary) 4%);
|
||||
border-color: color-mix(in srgb, var(--text-primary) 22%, var(--theme-offset-bg, var(--background-secondary)) 78%);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
.select-input {
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.select-input:hover,
|
||||
.select-input:focus {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.select-input option {
|
||||
background: var(--background-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.select-icon {
|
||||
color: color-mix(in srgb, var(--text-primary) 60%, transparent);
|
||||
}
|
||||
|
||||
.select-input {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:global(.dark) .select-input {
|
||||
color-scheme: dark;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import { animate } from "motion";
|
||||
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup";
|
||||
|
||||
let { onClose } = $props<{ onClose: () => void }>();
|
||||
let modalElement: HTMLElement;
|
||||
|
||||
$effect(() => {
|
||||
if (modalElement) {
|
||||
animate(modalElement, { scale: [0.9, 1], opacity: [0, 1] }, { type: "spring", stiffness: 300, damping: 25 });
|
||||
}
|
||||
});
|
||||
|
||||
function handleSignIn() {
|
||||
onClose();
|
||||
if (document.getElementById("ExtensionPopup")) {
|
||||
closeExtensionPopup();
|
||||
} else {
|
||||
window.close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex fixed inset-0 z-[10000] justify-center items-center bg-black/50"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
}}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
transition:fade={{ duration: 150 }}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={modalElement}
|
||||
class="p-4 mx-4 w-full max-w-md bg-white rounded-2xl shadow-2xl dark:bg-zinc-800 dark:text-white"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 class="mb-3 text-xl font-bold text-zinc-900 dark:text-white">
|
||||
Sign in to favorite themes
|
||||
</h2>
|
||||
|
||||
<p class="mb-6 text-zinc-600 dark:text-zinc-400">
|
||||
Sign in in the Theme Store to save favorites across devices, or create an account to get started.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-700 dark:text-zinc-200 hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
<a
|
||||
href="https://accounts.betterseqta.org/register"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
|
||||
>
|
||||
Create account
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSignIn}
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -9,7 +9,7 @@
|
||||
let percentage = $derived(((state - min) / (max - min)) * 100);
|
||||
</script>
|
||||
|
||||
<div class="relative mx-auto w-full max-w-lg">
|
||||
<div class="relative w-full min-w-0">
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
import './TabbedContainer.css';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { tabs } = $props<{ tabs: { title: string, Content: any, props?: any }[] }>();
|
||||
let activeTab = $state(0);
|
||||
let { tabs, activeTab = $bindable(0) } = $props<{ tabs: { title: string, Content: any, props?: any }[]; activeTab?: number }>();
|
||||
let containerRef: HTMLElement | null = null;
|
||||
let tabWidth = $state(0);
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
|
||||
let username = $state("");
|
||||
let password = $state("");
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let cloudState = $state(cloudAuth.state);
|
||||
let open = $state(false);
|
||||
let dropdownEl: HTMLElement;
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribe = cloudAuth.subscribe((state) => {
|
||||
cloudState = state;
|
||||
});
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (dropdownEl && !dropdownEl.contains(e.target as Node)) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
const timer = setTimeout(() => {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
}, 0);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
async function handleLogin() {
|
||||
if (loading) return;
|
||||
error = null;
|
||||
if (!username.trim() || !password) {
|
||||
error = "Please enter username and password";
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const result = await cloudAuth.login(username.trim(), password);
|
||||
if (result.success) {
|
||||
password = "";
|
||||
open = false;
|
||||
} else {
|
||||
error = result.error ?? "Login failed";
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await cloudAuth.logout();
|
||||
open = false;
|
||||
}
|
||||
|
||||
function getInitials(): string {
|
||||
const u = cloudState.user;
|
||||
if (!u) return "?";
|
||||
if (u.displayName) return u.displayName.slice(0, 2).toUpperCase();
|
||||
if (u.username) return u.username.slice(0, 2).toUpperCase();
|
||||
if (u.email) return u.email.slice(0, 2).toUpperCase();
|
||||
return "?";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative flex items-center" bind:this={dropdownEl}>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (open = !open)}
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-zinc-100/80 dark:bg-zinc-700/80 hover:bg-zinc-200/80 dark:hover:bg-zinc-600/80 transition-colors duration-200 text-base font-medium text-zinc-900 dark:text-white"
|
||||
>
|
||||
{#if cloudState.isLoggedIn}
|
||||
{#if cloudState.user?.pfpUrl}
|
||||
<img
|
||||
src={cloudState.user.pfpUrl}
|
||||
alt=""
|
||||
class="w-8 h-8 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-sm">
|
||||
{getInitials()}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="hidden max-w-24 truncate sm:inline text-base">
|
||||
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xl font-IconFamily" aria-hidden="true">{'\ued53'}</span>
|
||||
<span class="text-base font-medium">Sign in</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute right-0 top-full mt-2 w-80 rounded-xl border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-xl z-[100] overflow-hidden"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="p-4 border-b border-zinc-200 dark:border-zinc-600">
|
||||
<h3 class="text-xl font-bold text-zinc-900 dark:text-white">BetterSEQTA Cloud</h3>
|
||||
<p class="text-base text-zinc-500 dark:text-zinc-400">Sync favorites across devices</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
{#if cloudState.isLoggedIn}
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if cloudState.user?.pfpUrl}
|
||||
<img
|
||||
src={cloudState.user.pfpUrl}
|
||||
alt=""
|
||||
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
|
||||
{getInitials()}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-base font-medium text-zinc-900 dark:text-white truncate">
|
||||
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
|
||||
</p>
|
||||
{#if cloudState.user?.email && cloudState.user?.email !== (cloudState.user?.displayName || cloudState.user?.username)}
|
||||
<p class="text-base text-zinc-500 dark:text-zinc-400 truncate">{cloudState.user.email}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleLogout}
|
||||
class="w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-900 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mb-4 text-base text-zinc-600 dark:text-zinc-400">
|
||||
Sign in to favorite themes. Your favorites sync across devices when logged in.
|
||||
</p>
|
||||
<form
|
||||
class="flex flex-col gap-3"
|
||||
autocomplete="off"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleLogin();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="betterseqta-cloud-username"
|
||||
autocomplete="off"
|
||||
placeholder="Email or username"
|
||||
bind:value={username}
|
||||
disabled={loading}
|
||||
readonly
|
||||
onfocus={(e) => e.currentTarget.removeAttribute('readonly')}
|
||||
class="w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
name="betterseqta-cloud-password"
|
||||
autocomplete="new-password"
|
||||
placeholder="Password"
|
||||
bind:value={password}
|
||||
disabled={loading}
|
||||
readonly
|
||||
onfocus={(e) => e.currentTarget.removeAttribute('readonly')}
|
||||
class="w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200"
|
||||
/>
|
||||
{#if error}
|
||||
<p class="text-base text-red-600 dark:text-red-400">{error}</p>
|
||||
{/if}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 disabled:opacity-50 transition-colors duration-200"
|
||||
>
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
<a
|
||||
href="https://accounts.betterseqta.org/register"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center justify-center gap-2 px-4 py-3 text-base font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
|
||||
>
|
||||
Create account
|
||||
</a>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -29,7 +29,7 @@
|
||||
{#if coverThemes.length > 0}
|
||||
<div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade>
|
||||
<div
|
||||
class="w-full aspect-8/3"
|
||||
class="w-full aspect-[5/1] max-h-[500px]"
|
||||
use:emblaCarouselSvelte={{ options, plugins }}
|
||||
onemblaInit={onInit}
|
||||
>
|
||||
@@ -42,7 +42,7 @@
|
||||
onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }}
|
||||
onclick={() => setDisplayTheme(theme)}
|
||||
>
|
||||
<img src={theme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-full" />
|
||||
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-full" />
|
||||
<div class='absolute bottom-0 left-0 p-8 z-[1]'>
|
||||
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2>
|
||||
<p class='text-lg text-white'>{theme.description}</p>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import logoDark from '@/resources/icons/betterseqta-light-full.png';
|
||||
import { closeStore } from '@/seqta/ui/renderStore'
|
||||
import browser from 'webextension-polyfill';
|
||||
import CloudHeader from './CloudHeader.svelte';
|
||||
|
||||
// Props
|
||||
let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{
|
||||
@@ -39,6 +40,8 @@
|
||||
>
|
||||
Backgrounds
|
||||
</button>
|
||||
|
||||
<CloudHeader />
|
||||
</div>
|
||||
|
||||
<div class="flex relative gap-2">
|
||||
|
||||
@@ -1,19 +1,110 @@
|
||||
<script lang="ts">
|
||||
import type { Theme } from '@/interface/types/Theme'
|
||||
|
||||
let { theme, onClick } = $props<{ theme: Theme; onClick: () => void }>();
|
||||
|
||||
import { fade } from 'svelte/transition';
|
||||
import { onMount } from 'svelte';
|
||||
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte';
|
||||
|
||||
let { theme, onClick, toggleFavorite, isLoggedIn } = $props<{
|
||||
theme: Theme;
|
||||
onClick: () => void;
|
||||
toggleFavorite: (theme: Theme) => void;
|
||||
isLoggedIn: boolean;
|
||||
}>();
|
||||
let menuOpen = $state(false);
|
||||
let showSignInModal = $state(false);
|
||||
let menuRef: HTMLDivElement;
|
||||
|
||||
onMount(() => {
|
||||
const closeMenu = (e: MouseEvent) => {
|
||||
if (menuOpen && menuRef && !menuRef.contains(e.target as Node)) {
|
||||
menuOpen = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', closeMenu);
|
||||
return () => document.removeEventListener('click', closeMenu);
|
||||
});
|
||||
|
||||
function handleCardClick(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).closest('[data-theme-menu]')) return;
|
||||
onClick();
|
||||
}
|
||||
|
||||
function handleFavoriteClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (isLoggedIn) {
|
||||
toggleFavorite(theme);
|
||||
} else {
|
||||
showSignInModal = true;
|
||||
}
|
||||
menuOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full cursor-pointer" role="button" tabindex="-1" onkeydown={onClick} onclick={onClick}>
|
||||
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border" transition:fade>
|
||||
<div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white">
|
||||
{theme.name}
|
||||
<div class="w-full cursor-pointer" role="button" tabindex="-1" onkeydown={onClick} onclick={handleCardClick}>
|
||||
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] dark:hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border" transition:fade>
|
||||
<!-- Menu dropdown -->
|
||||
<div class="absolute top-2 right-2 z-20" data-theme-menu bind:this={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex justify-center items-center w-8 h-8 rounded-lg bg-black/40 hover:bg-black/60 text-white transition-all"
|
||||
onclick={(e) => { e.stopPropagation(); menuOpen = !menuOpen; }}
|
||||
aria-label="Theme options"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" class="w-5 h-5">
|
||||
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="absolute right-0 top-full mt-1 py-1 min-w-[140px] rounded-lg bg-white dark:bg-zinc-800 shadow-lg border border-zinc-200 dark:border-zinc-700"
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex gap-2 items-center w-full px-3 py-2 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-700"
|
||||
role="menuitem"
|
||||
onclick={handleFavoriteClick}
|
||||
title={isLoggedIn ? (theme.is_favorited ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill={theme.is_favorited ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="w-5 h-5 {theme.is_favorited ? 'text-red-500' : ''}"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
{theme.is_favorited ? 'Favorited' : 'Favorite'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="absolute bottom-1 left-3 right-3 z-10 mb-1 flex flex-col gap-0.5">
|
||||
<span class="text-xl font-bold text-white drop-shadow-md">{theme.name}</span>
|
||||
<div class="flex gap-3 text-xs font-medium text-white/90 drop-shadow-sm">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3.5 h-3.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
{(theme.download_count ?? 0).toLocaleString()}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
{(theme.favorite_count ?? 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div>
|
||||
<div class='w-full'>
|
||||
<img src={theme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
|
||||
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showSignInModal}
|
||||
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
|
||||
{/if}
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
import type { Theme } from '@/interface/types/Theme'
|
||||
import ThemeCard from './ThemeCard.svelte';
|
||||
|
||||
let { themes, searchTerm, setDisplayTheme } = $props<{ themes: Theme[]; searchTerm: string, setDisplayTheme: (theme: Theme) => void }>();
|
||||
let { themes, searchTerm, setDisplayTheme, toggleFavorite, isLoggedIn } = $props<{
|
||||
themes: Theme[];
|
||||
searchTerm: string;
|
||||
setDisplayTheme: (theme: Theme) => void;
|
||||
toggleFavorite: (theme: Theme) => void;
|
||||
isLoggedIn: boolean;
|
||||
}>();
|
||||
|
||||
let filteredThemes = $derived(themes.filter((theme: Theme) =>
|
||||
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
@@ -12,7 +18,12 @@
|
||||
<div class="relative" >
|
||||
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each filteredThemes as theme (theme.id)}
|
||||
<ThemeCard theme={theme} onClick={() => setDisplayTheme(theme)} />
|
||||
<ThemeCard
|
||||
{theme}
|
||||
onClick={() => setDisplayTheme(theme)}
|
||||
{toggleFavorite}
|
||||
{isLoggedIn}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if filteredThemes.length !== 0}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import type { Theme } from '@/interface/types/Theme'
|
||||
import { fade } from 'svelte/transition';
|
||||
import { animate } from 'motion';
|
||||
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte';
|
||||
|
||||
let { theme, currentThemes, setDisplayTheme, onInstall, onRemove, allThemes, displayTheme } = $props<{
|
||||
let { theme, currentThemes, setDisplayTheme, onInstall, onRemove, allThemes, displayTheme, toggleFavorite, isLoggedIn } = $props<{
|
||||
theme: Theme | null;
|
||||
currentThemes: string[];
|
||||
setDisplayTheme: (theme: Theme | null) => void;
|
||||
@@ -11,15 +12,30 @@
|
||||
onRemove: (themeId: string) => void;
|
||||
allThemes: Theme[];
|
||||
displayTheme: Theme | null;
|
||||
toggleFavorite?: (theme: Theme) => void;
|
||||
isLoggedIn?: boolean;
|
||||
}>();
|
||||
let installing = $state(false);
|
||||
let showSignInModal = $state(false);
|
||||
let modalElement: HTMLElement;
|
||||
|
||||
function handleFavoriteClick() {
|
||||
if (isLoggedIn && toggleFavorite && theme) {
|
||||
toggleFavorite(theme);
|
||||
} else {
|
||||
showSignInModal = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to get related themes
|
||||
function getRelatedThemes() {
|
||||
if (!theme) return [];
|
||||
return allThemes
|
||||
.filter((t: Theme) => t.id !== theme.id)
|
||||
.sort((a: Theme, b: Theme) => a.name.localeCompare(theme.name) - b.name.localeCompare(theme.name))
|
||||
.filter((t: Theme) => !!t && t.id !== theme.id)
|
||||
.sort(
|
||||
(a: Theme, b: Theme) =>
|
||||
a.name.localeCompare(theme.name) - b.name.localeCompare(theme.name),
|
||||
)
|
||||
.slice(0, 4);
|
||||
}
|
||||
|
||||
@@ -72,19 +88,51 @@
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{#if theme}
|
||||
<div class="relative h-auto">
|
||||
<button class="absolute top-0 right-0 p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200" onclick={() => hideModal()}>
|
||||
<div class="absolute top-0 right-0 flex gap-1 items-center">
|
||||
<button class="p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200" onclick={() => hideModal()}>
|
||||
{'\ued8a'}
|
||||
</button>
|
||||
<h2 class="mb-4 text-2xl font-bold">
|
||||
</div>
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
{theme.name}
|
||||
</h2>
|
||||
<img src={theme.marqueeImage} alt="Theme Cover" class="object-cover mb-4 w-full rounded-md" />
|
||||
<div class="flex gap-4 mb-4 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
{(theme.download_count ?? 0).toLocaleString()} downloads
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
{(theme.favorite_count ?? 0).toLocaleString()} favorites
|
||||
</span>
|
||||
</div>
|
||||
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Cover" class="object-cover mb-4 w-full rounded-md" />
|
||||
<p class="mb-4 text-gray-700 dark:text-gray-300">
|
||||
{theme.description}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2 mt-4 justify-end items-center">
|
||||
{#if toggleFavorite && theme}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-full transition-all duration-200 hover:scale-105 active:scale-95 {theme.is_favorited ? 'text-red-500 bg-red-500/10 dark:bg-red-500/20' : 'bg-zinc-200 dark:bg-zinc-700 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600'}"
|
||||
onclick={handleFavoriteClick}
|
||||
title={isLoggedIn ? (theme.is_favorited ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
|
||||
aria-label={theme.is_favorited ? 'Unfavorite' : 'Favorite'}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
{theme.is_favorited ? 'Favorited' : 'Favorite'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if currentThemes.includes(theme.id)}
|
||||
<button onclick={async () => {installing = true; await onRemove(theme.id); installing = false}} class="flex relative justify-center items-center px-4 py-2 mt-4 ml-auto w-32 text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200">
|
||||
<button onclick={async () => {installing = true; await onRemove(theme.id); installing = false}} class="flex relative justify-center items-center px-4 py-2 w-32 text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200 transition-all duration-200 hover:scale-105 active:scale-95">
|
||||
{#if installing}
|
||||
<svg class="absolute w-4 h-4 { installing ? 'opacity-100' : 'opacity-0' }" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
|
||||
@@ -93,7 +141,7 @@
|
||||
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Remove</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button onclick={async () => {installing = true; await onInstall(theme.id); installing = false}} class="flex relative justify-center items-center px-4 py-2 mt-4 ml-auto w-32 text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200">
|
||||
<button onclick={async () => {installing = true; await onInstall(theme.id); installing = false}} class="flex relative justify-center items-center px-4 py-2 w-32 text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200 transition-all duration-200 hover:scale-105 active:scale-95">
|
||||
{#if installing}
|
||||
<svg class="absolute w-4 h-4 { installing ? 'opacity-100' : 'opacity-0' }" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
|
||||
@@ -102,6 +150,7 @@
|
||||
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Install</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="my-8 border-b border-zinc-200 dark:border-zinc-700"></div>
|
||||
|
||||
@@ -116,11 +165,22 @@
|
||||
{relatedTheme.name}
|
||||
</div>
|
||||
<div class="absolute bottom-0 z-0 w-full h-3/4 to-transparent from-black/80 bg-linear-to-t"></div>
|
||||
<img src={relatedTheme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-48" />
|
||||
<img src={relatedTheme.marqueeImage || relatedTheme.coverImage} alt="Theme Preview" class="object-cover w-full h-48" />
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex justify-center items-center h-full text-zinc-600 dark:text-zinc-300">
|
||||
<button class="px-4 py-2 rounded-lg bg-zinc-200 dark:bg-zinc-700 transition-all duration-200 hover:scale-105 active:scale-95" onclick={() => hideModal()}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showSignInModal}
|
||||
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
|
||||
{/if}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import browser from 'webextension-polyfill'
|
||||
import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
|
||||
import { OpenStorePage } from '@/seqta/ui/renderStore'
|
||||
import { themeUpdates } from '@/interface/hooks/ThemeUpdates'
|
||||
import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup'
|
||||
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
|
||||
import { cloudAuth } from '@/seqta/utils/CloudAuth'
|
||||
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte'
|
||||
|
||||
const themeManager = ThemeManager.getInstance();
|
||||
|
||||
@@ -13,6 +16,17 @@
|
||||
let { isEditMode } = $props<{ isEditMode: boolean }>();
|
||||
let isDragging = $state(false);
|
||||
let tempTheme = $state(null);
|
||||
let favoriteStatus = $state<Record<string, boolean>>({});
|
||||
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
|
||||
let prevLoggedIn = $state(false);
|
||||
let showSignInModal = $state(false);
|
||||
|
||||
cloudAuth.subscribe((s) => {
|
||||
const now = s.isLoggedIn;
|
||||
if (now && !prevLoggedIn && themes) void fetchThemes();
|
||||
prevLoggedIn = now;
|
||||
cloudLoggedIn = now;
|
||||
});
|
||||
|
||||
const handleThemeClick = async (theme: CustomTheme, e: MouseEvent) => {
|
||||
if (isEditMode) return;
|
||||
@@ -87,11 +101,55 @@
|
||||
themes: await themeManager.getAvailableThemes(),
|
||||
selectedTheme: themeManager.getSelectedThemeId() || '',
|
||||
}
|
||||
if (themes && cloudLoggedIn) {
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
if (token) {
|
||||
const status: Record<string, boolean> = {};
|
||||
await Promise.all(
|
||||
themes.themes.map(async (t) => {
|
||||
try {
|
||||
const res = (await browser.runtime.sendMessage({
|
||||
type: 'fetchThemeDetails',
|
||||
themeId: t.id,
|
||||
token,
|
||||
})) as { success?: boolean; data?: { theme?: { is_favorited?: boolean } } };
|
||||
if (res?.success && res?.data?.theme) {
|
||||
status[t.id] = !!res.data.theme.is_favorited;
|
||||
}
|
||||
} catch {
|
||||
// Theme may not exist on store (e.g. locally created)
|
||||
}
|
||||
})
|
||||
);
|
||||
favoriteStatus = status;
|
||||
}
|
||||
} else {
|
||||
favoriteStatus = {};
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleFavorite = async (theme: CustomTheme, e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!cloudLoggedIn) {
|
||||
showSignInModal = true;
|
||||
return;
|
||||
}
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
if (!token) return;
|
||||
const isFavorite = !favoriteStatus[theme.id];
|
||||
const result = (await browser.runtime.sendMessage({
|
||||
type: 'cloudFavorite',
|
||||
themeId: theme.id,
|
||||
token,
|
||||
action: isFavorite ? 'favorite' : 'unfavorite',
|
||||
})) as { success?: boolean };
|
||||
if (result?.success) {
|
||||
favoriteStatus = { ...favoriteStatus, [theme.id]: isFavorite };
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await fetchThemes();
|
||||
|
||||
themeUpdates.addListener(fetchThemes);
|
||||
})
|
||||
|
||||
@@ -144,6 +202,18 @@
|
||||
{/if}
|
||||
|
||||
{#if !isEditMode}
|
||||
<div
|
||||
class="flex absolute right-24 top-1/4 z-20 place-items-center p-2 w-8 h-8 text-center rounded-full opacity-0 transition-all -translate-y-1/2 group-hover:opacity-100 group-hover:top-1/2 {(favoriteStatus[theme.id] ?? false) ? 'text-red-400' : 'text-white/80'} bg-black/50"
|
||||
onclick={(event) => handleToggleFavorite(theme, event)}
|
||||
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') handleToggleFavorite(theme, event as any) }}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
title={cloudLoggedIn ? ((favoriteStatus[theme.id] ?? false) ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={(favoriteStatus[theme.id] ?? false) ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full delay-[20ms] opacity-0 top-1/4 right-2 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-1/2 -translate-y-1/2"
|
||||
onclick={(event) => { event.stopPropagation(); OpenThemeCreator(theme.id); closeExtensionPopup() }}
|
||||
@@ -211,3 +281,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showSignInModal}
|
||||
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
|
||||
{/if}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { settingsPopup } from "../hooks/SettingsPopup";
|
||||
|
||||
let devModeSequence = "";
|
||||
let settingsActiveTab = $state(0);
|
||||
let showDisclaimerModal = $state(false);
|
||||
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
|
||||
|
||||
@@ -71,13 +72,14 @@
|
||||
showDisclaimerModal = true;
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
onMount(() => {
|
||||
settingsPopup.addListener(() => {
|
||||
showColourPicker = false;
|
||||
});
|
||||
|
||||
if (!standalone) return;
|
||||
if (standalone) {
|
||||
StandaloneStore.setStandalone(true);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -275,6 +277,7 @@
|
||||
</div>
|
||||
|
||||
<TabbedContainer
|
||||
bind:activeTab={settingsActiveTab}
|
||||
tabs={[
|
||||
{
|
||||
title: "Settings",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import type { SettingsList } from "@/interface/types/SettingsProps"
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
|
||||
import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
|
||||
import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte"
|
||||
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
|
||||
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
|
||||
|
||||
@@ -113,27 +114,15 @@
|
||||
<div class="flex flex-col divide-y divide-zinc-100 dark:divide-zinc-700">
|
||||
{#each [
|
||||
{
|
||||
title: "Transparency Effects",
|
||||
description: "Enables transparency effects on certain elements such as blur. (May impact battery life)",
|
||||
id: 1,
|
||||
Component: Switch,
|
||||
props: {
|
||||
state: $settingsState.transparencyEffects,
|
||||
onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Custom Theme Colour",
|
||||
description: "Customise the overall theme colour of SEQTA Learn.",
|
||||
id: 4,
|
||||
Component: PickerSwatch,
|
||||
props: {
|
||||
onClick: showColourPicker
|
||||
}
|
||||
title: "Connect Mobile App",
|
||||
description: "Link your SEQTA session to DesQTA — the modern desktop and mobile app for SEQTA Learn",
|
||||
id: 0,
|
||||
Component: ConnectMobileApp,
|
||||
props: {}
|
||||
},
|
||||
{
|
||||
title: "Edit Sidebar Layout",
|
||||
description: "Customise the sidebar layout.",
|
||||
description: "Reorder pages on the sidebar",
|
||||
id: 5,
|
||||
Component: Button,
|
||||
props: {
|
||||
@@ -141,9 +130,28 @@
|
||||
text: "Edit"
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Custom Theme Colour",
|
||||
description: "Customise the overall theme colour of SEQTA Learn",
|
||||
id: 4,
|
||||
Component: PickerSwatch,
|
||||
props: {
|
||||
onClick: showColourPicker
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Icon Only Sidebar",
|
||||
description: "Show only icons in the sidebar for a compact layout",
|
||||
id: 14,
|
||||
Component: Switch,
|
||||
props: {
|
||||
state: $settingsState.iconOnlySidebar ?? false,
|
||||
onChange: (isOn: boolean) => settingsState.iconOnlySidebar = isOn
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Animations",
|
||||
description: "Enables animations on certain pages.",
|
||||
description: "Enable animations on certain pages",
|
||||
id: 6,
|
||||
Component: Switch,
|
||||
props: {
|
||||
@@ -161,9 +169,19 @@
|
||||
onChange: (isOn: boolean) => settingsState.timeFormat = isOn ? "12" : "24"
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Transparency Effects",
|
||||
description: "Enable transparency effects on certain elements, such as blur (May impact battery life)",
|
||||
id: 1,
|
||||
Component: Switch,
|
||||
props: {
|
||||
state: $settingsState.transparencyEffects,
|
||||
onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Default Page",
|
||||
description: "The page to load when SEQTA Learn is opened.",
|
||||
description: "The page to load when SEQTA Learn is opened",
|
||||
id: 10,
|
||||
Component: Select,
|
||||
props: {
|
||||
@@ -182,7 +200,7 @@
|
||||
},
|
||||
{
|
||||
title: "News Feed Source",
|
||||
description: "Choose sources of your news feed.",
|
||||
description: "Choose the sources for your news feed",
|
||||
id: 11,
|
||||
Component: Select,
|
||||
props: {
|
||||
@@ -207,6 +225,37 @@
|
||||
{@render Setting(option)}
|
||||
{/each}
|
||||
|
||||
<div class="border-none">
|
||||
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
|
||||
<div class="flex justify-between items-center px-4 py-3">
|
||||
<div class="pr-4">
|
||||
<h2 class="text-sm font-bold">Adaptive Theme Colour</h2>
|
||||
<p class="text-xs">Change the theme colour based on the current class (e.g. when viewing a course or assessments page)</p>
|
||||
</div>
|
||||
<div>
|
||||
<Switch
|
||||
state={$settingsState.adaptiveThemeColour ?? false}
|
||||
onChange={(isOn: boolean) => settingsState.adaptiveThemeColour = isOn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if $settingsState.adaptiveThemeColour}
|
||||
<div class="flex justify-between items-center px-4 py-3 pl-6 border-t border-zinc-100 dark:border-zinc-700/50">
|
||||
<div class="pr-4">
|
||||
<h2 class="text-sm font-bold">Soft Gradient</h2>
|
||||
<p class="text-xs">Use a soft gradient instead of a solid colour when viewing a class</p>
|
||||
</div>
|
||||
<div>
|
||||
<Switch
|
||||
state={$settingsState.adaptiveThemeGradient ?? false}
|
||||
onChange={(isOn: boolean) => settingsState.adaptiveThemeGradient = isOn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each pluginSettings as plugin}
|
||||
<div class="border-none">
|
||||
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40 {!(plugin as any).disableToggle && Object.keys(plugin.settings).length === 0 ? 'hidden' : ''}">
|
||||
@@ -234,7 +283,6 @@
|
||||
await updatePluginSetting(plugin.pluginId, 'enabled', true);
|
||||
},
|
||||
() => {
|
||||
// Do nothing on cancel
|
||||
}
|
||||
);
|
||||
return;
|
||||
@@ -262,6 +310,7 @@
|
||||
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
|
||||
/>
|
||||
{:else if setting.type === 'number'}
|
||||
<div class="w-28 shrink-0">
|
||||
<Slider
|
||||
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
|
||||
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
|
||||
@@ -269,6 +318,7 @@
|
||||
max={setting.max}
|
||||
step={setting.step}
|
||||
/>
|
||||
</div>
|
||||
{:else if setting.type === 'string'}
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -15,8 +15,12 @@
|
||||
|
||||
import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
|
||||
import Backgrounds from '../components/store/Backgrounds.svelte'
|
||||
import { cloudAuth } from '@/seqta/utils/CloudAuth'
|
||||
|
||||
const themeManager = ThemeManager.getInstance();
|
||||
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
|
||||
|
||||
cloudAuth.subscribe((s) => { cloudLoggedIn = s.isLoggedIn; });
|
||||
|
||||
// State variables
|
||||
let searchTerm = $state('');
|
||||
@@ -48,20 +52,57 @@
|
||||
activeTab = tab;
|
||||
};
|
||||
|
||||
// Fetch themes and initialize app
|
||||
const toggleFavorite = async (theme: Theme) => {
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
if (!token) return;
|
||||
const isFavorite = !theme.is_favorited;
|
||||
const result = (await browser.runtime.sendMessage({
|
||||
type: 'cloudFavorite',
|
||||
themeId: theme.id,
|
||||
token,
|
||||
action: isFavorite ? 'favorite' : 'unfavorite',
|
||||
})) as { success?: boolean };
|
||||
if (result?.success) {
|
||||
const delta = isFavorite ? 1 : -1;
|
||||
themes = themes.map((t) =>
|
||||
t.id === theme.id
|
||||
? { ...t, is_favorited: isFavorite, favorite_count: Math.max(0, (t.favorite_count ?? 0) + delta) }
|
||||
: t
|
||||
);
|
||||
if (displayTheme?.id === theme.id) {
|
||||
displayTheme = {
|
||||
...displayTheme,
|
||||
is_favorited: isFavorite,
|
||||
favorite_count: Math.max(0, (displayTheme.favorite_count ?? 0) + delta),
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch themes via background script (avoids CORS when store runs inside SEQTA page)
|
||||
const fetchThemes = async () => {
|
||||
try {
|
||||
const response = await fetch(`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${(new Date()).getTime()}`, { cache: 'no-store' });
|
||||
const data = await response.json();
|
||||
themes = data.themes;
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
const data = (await browser.runtime.sendMessage({
|
||||
type: 'fetchThemes',
|
||||
token: token ?? undefined,
|
||||
})) as {
|
||||
success?: boolean;
|
||||
data?: { themes: Theme[] };
|
||||
error?: string;
|
||||
};
|
||||
if (!data?.success || !data?.data?.themes) {
|
||||
throw new Error(data?.error || 'Failed to fetch themes');
|
||||
}
|
||||
themes = data.data.themes;
|
||||
|
||||
// Shuffle for cover themes
|
||||
const shuffled = [...themes].sort(() => 0.5 - Math.random());
|
||||
coverThemes = shuffled.slice(0, 3);
|
||||
|
||||
loading = false;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch themes', error);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch themes', err);
|
||||
setTimeout(fetchThemes, 5000); // Retry after 5 seconds if failure occurs
|
||||
}
|
||||
};
|
||||
@@ -91,6 +132,17 @@
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Refetch themes when user logs in (from another tab) to get is_favorited
|
||||
let lastLoggedIn = $state(false);
|
||||
$effect(() => {
|
||||
if (cloudLoggedIn && !lastLoggedIn) {
|
||||
lastLoggedIn = true;
|
||||
fetchThemes();
|
||||
} else if (!cloudLoggedIn) {
|
||||
lastLoggedIn = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="w-screen h-screen bg-white {darkMode ? 'dark' : ''}">
|
||||
@@ -111,7 +163,13 @@
|
||||
{/if}
|
||||
|
||||
<!-- ThemeGrid to display filtered themes -->
|
||||
<ThemeGrid themes={filteredThemes} {searchTerm} {setDisplayTheme} />
|
||||
<ThemeGrid
|
||||
themes={filteredThemes}
|
||||
{searchTerm}
|
||||
{setDisplayTheme}
|
||||
{toggleFavorite}
|
||||
isLoggedIn={cloudLoggedIn}
|
||||
/>
|
||||
|
||||
{#if displayTheme}
|
||||
<ThemeModal
|
||||
@@ -120,6 +178,8 @@
|
||||
theme={displayTheme}
|
||||
{displayTheme}
|
||||
{setDisplayTheme}
|
||||
{toggleFavorite}
|
||||
isLoggedIn={cloudLoggedIn}
|
||||
onInstall={async () => {
|
||||
if (displayTheme) {
|
||||
await themeManager.downloadTheme(displayTheme);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
export type Theme = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
coverImage: string;
|
||||
marqueeImage: string;
|
||||
id: string;
|
||||
marqueeImage?: string;
|
||||
theme_json_url?: string;
|
||||
is_favorited?: boolean;
|
||||
favorite_count?: number;
|
||||
download_count?: number;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Action } from "svelte/action";
|
||||
|
||||
/**
|
||||
* Svelte action that moves the element to a different DOM target.
|
||||
* Defaults to the nearest ShadowRoot so styles remain intact when the app
|
||||
* is rendered inside a shadow DOM. Falls back to document.body otherwise.
|
||||
* Keeps all Svelte reactivity/events intact while escaping ancestor stacking contexts.
|
||||
*/
|
||||
export const portal: Action<HTMLElement, HTMLElement | ShadowRoot | undefined> = (node, target) => {
|
||||
const root = node.getRootNode();
|
||||
const dest = target ?? (root instanceof ShadowRoot ? root : document.body);
|
||||
dest.appendChild(node);
|
||||
|
||||
return {
|
||||
update(newTarget) {
|
||||
(newTarget ?? dest).appendChild(node);
|
||||
},
|
||||
destroy() {
|
||||
node.remove();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -16,12 +16,12 @@
|
||||
}
|
||||
},
|
||||
"permissions": ["tabs", "notifications", "storage"],
|
||||
"host_permissions": ["https://newsapi.org/", "*://*/*"],
|
||||
"host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"],
|
||||
"background": {
|
||||
"service_worker": "background.ts"
|
||||
},
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||
"extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' http: https: https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com https://newsapi.org"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Setting,
|
||||
} from "@/plugins/core/settingsHelpers";
|
||||
import styles from "./styles.css?inline";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
|
||||
const settings = defineSettings({
|
||||
speed: numberSetting({
|
||||
@@ -35,13 +36,10 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
|
||||
settings: instance.settings,
|
||||
|
||||
run: async (api) => {
|
||||
// Create the background elements
|
||||
const container = document.getElementById("container");
|
||||
const menu = document.getElementById("menu");
|
||||
|
||||
if (!container || !menu) {
|
||||
return () => {};
|
||||
}
|
||||
const [container, menu] = await Promise.all([
|
||||
waitForElm("#container", true),
|
||||
waitForElm("#menu", true),
|
||||
]);
|
||||
|
||||
const backgrounds = [
|
||||
{ classes: ["bg"] },
|
||||
|
||||
@@ -180,8 +180,52 @@ 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);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function applySubjectColourToOverallResult() {
|
||||
const selectedAssessmentItem = document.querySelector(
|
||||
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
|
||||
) || document.querySelector(
|
||||
"[class*='Collapsible__content___'] [class*='AssessmentItem__AssessmentItem___']",
|
||||
);
|
||||
const assessmentThermoscore = selectedAssessmentItem?.querySelector(
|
||||
"[class*='Thermoscore__Thermoscore___']",
|
||||
) as HTMLElement | null;
|
||||
const overallResult = document.querySelector(
|
||||
"[class*='OverallResult__OverallResult___']",
|
||||
) as HTMLElement | null;
|
||||
const assessableCriterionHeaders = document.querySelectorAll(
|
||||
"[class*='AssessableCriterion__header___']",
|
||||
);
|
||||
|
||||
if (assessmentThermoscore && (overallResult || assessableCriterionHeaders.length > 0)) {
|
||||
const accentColour =
|
||||
getComputedStyle(assessmentThermoscore).getPropertyValue("--assessment-accent-colour").trim() ||
|
||||
getComputedStyle(assessmentThermoscore).getPropertyValue("--fill-colour").trim() ||
|
||||
getComputedStyle(assessmentThermoscore.closest("[class*='Collapsible__Collapsible___']") || assessmentThermoscore).getPropertyValue("--assessment-accent-colour").trim() ||
|
||||
getComputedStyle(assessmentThermoscore.closest("[class*='Collapsible__Collapsible___']") || assessmentThermoscore).getPropertyValue("--item-colour").trim();
|
||||
if (accentColour) {
|
||||
overallResult?.style.setProperty("--assessment-accent-colour", accentColour);
|
||||
overallResult?.style.setProperty("--fill-colour", accentColour);
|
||||
assessableCriterionHeaders.forEach((el) => {
|
||||
(el as HTMLElement).style.setProperty("--assessment-accent-colour", accentColour);
|
||||
(el as HTMLElement).style.setProperty("--fill-colour", accentColour);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default assessmentsAveragePlugin;
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
|
||||
interface FilterOptions {
|
||||
subject: string;
|
||||
sortBy: "due" | "grade" | "subject" | "title";
|
||||
sortBy: "due" | "grade" | "subject" | "title" | "year";
|
||||
}
|
||||
|
||||
const HIDDEN_ASSESSMENTS_KEY = "betterseqta-hidden-assessments";
|
||||
|
||||
function percentageToLetter(percentage: number): string {
|
||||
const letterMap: Record<number, string> = {
|
||||
100: "A+",
|
||||
@@ -41,48 +43,108 @@
|
||||
|
||||
let filteredAssessments: any[] = [];
|
||||
let statusGroups: Record<string, any[]> = {};
|
||||
let columns: { key: string; title: string; className: string; icon: string }[] = [];
|
||||
|
||||
function updateAssessments() {
|
||||
filteredAssessments = data.assessments.filter((a: any) => {
|
||||
const subjectMatch =
|
||||
currentFilters.subject === "all" || a.code === currentFilters.subject;
|
||||
return subjectMatch;
|
||||
});
|
||||
function getAssessmentYear(a: any): number {
|
||||
const dateStr = a.due || a.date || a.dueDate || a.created;
|
||||
return dateStr ? new Date(dateStr).getFullYear() : 0;
|
||||
}
|
||||
|
||||
filteredAssessments.sort((a: any, b: any) => {
|
||||
function getAssessmentType(a: any): string {
|
||||
return (a.type || a.assessmentType || a.taskType || "Other").toString();
|
||||
}
|
||||
|
||||
function getAssessmentGrade(a: any): string {
|
||||
const val = getGradeValue(a);
|
||||
if (val === null) return "No grade";
|
||||
return percentageToLetter(val);
|
||||
}
|
||||
|
||||
function getGroupKey(assessment: any): string {
|
||||
switch (currentFilters.sortBy) {
|
||||
case "due":
|
||||
return new Date(a.due).getTime() - new Date(b.due).getTime();
|
||||
case "grade":
|
||||
const gradeA = getGradeValue(a);
|
||||
const gradeB = getGradeValue(b);
|
||||
if (gradeA === null && gradeB === null) return 0;
|
||||
if (gradeA === null) return 1;
|
||||
if (gradeB === null) return -1;
|
||||
return gradeB - gradeA;
|
||||
return determineStatus(assessment);
|
||||
case "year":
|
||||
return String(getAssessmentYear(assessment) || "Unknown");
|
||||
case "subject":
|
||||
return a.code.localeCompare(b.code);
|
||||
return assessment.code || "Unknown";
|
||||
case "grade":
|
||||
return getAssessmentGrade(assessment);
|
||||
case "title":
|
||||
return a.title.localeCompare(b.title);
|
||||
const first = (assessment.title || "?")[0].toUpperCase();
|
||||
return /[A-Z0-9]/.test(first) ? first : "#";
|
||||
default:
|
||||
return 0;
|
||||
return determineStatus(assessment);
|
||||
}
|
||||
}
|
||||
|
||||
function sortCompare(a: any, b: any): number {
|
||||
return new Date(a.due || a.date || 0).getTime() - new Date(b.due || b.date || 0).getTime();
|
||||
}
|
||||
|
||||
const STATUS_COLUMNS = [
|
||||
{ key: "UPCOMING", title: "Upcoming", className: "column-upcoming", icon: "📅" },
|
||||
{ key: "DUE_SOON", title: "Due Soon", className: "column-due-soon", icon: "⏰" },
|
||||
{ key: "OVERDUE", title: "Overdue", className: "column-overdue", icon: "🚨" },
|
||||
{ key: "SUBMITTED", title: "Submitted", className: "column-submitted", icon: "📝" },
|
||||
{ key: "MARKS_RELEASED", title: "Marked", className: "column-marked", icon: "✅" },
|
||||
];
|
||||
|
||||
function buildGroupsAndColumns() {
|
||||
if (!data?.assessments) return { filteredAssessments: [], statusGroups: {}, columns: [] };
|
||||
const subjectFilters = settingsState.subjectfilters || {};
|
||||
const hiddenAssessmentIds = new Set(
|
||||
(JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]")).map(String)
|
||||
);
|
||||
|
||||
const filtered = data.assessments.filter((a: any) => {
|
||||
if (hiddenAssessmentIds.has(String(a.id))) return false;
|
||||
if (subjectFilters[a.code] === false) return false;
|
||||
return currentFilters.subject === "all" || a.code === currentFilters.subject;
|
||||
});
|
||||
|
||||
statusGroups = {
|
||||
UPCOMING: [],
|
||||
DUE_SOON: [],
|
||||
OVERDUE: [],
|
||||
SUBMITTED: [],
|
||||
MARKS_RELEASED: [],
|
||||
};
|
||||
|
||||
filteredAssessments.forEach((assessment) => {
|
||||
const status = determineStatus(assessment);
|
||||
if (statusGroups[status]) {
|
||||
statusGroups[status].push(assessment);
|
||||
}
|
||||
const groups: Record<string, any[]> = {};
|
||||
filtered.forEach((assessment) => {
|
||||
const key = getGroupKey(assessment);
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(assessment);
|
||||
});
|
||||
|
||||
Object.keys(groups).forEach((key) => {
|
||||
groups[key].sort(sortCompare);
|
||||
});
|
||||
|
||||
let cols: { key: string; title: string; className: string; icon: string }[];
|
||||
if (currentFilters.sortBy === "due") {
|
||||
cols = STATUS_COLUMNS;
|
||||
} else {
|
||||
const keys = Object.keys(groups).filter((k) => groups[k]?.length > 0);
|
||||
if (currentFilters.sortBy === "year") {
|
||||
cols = keys.sort((a, b) => Number(b) - Number(a)).map((k) => ({ key: k, title: k, className: "column-custom", icon: "📆" }));
|
||||
} else if (currentFilters.sortBy === "subject") {
|
||||
const subjectTitles = new Map(data?.subjects?.map((s: any) => [s.code, `${s.code} - ${s.title}`]) || []);
|
||||
cols = keys.sort().map((k) => ({ key: k, title: subjectTitles.get(k) || k, className: "column-custom", icon: "📚" }));
|
||||
} else {
|
||||
cols = keys.sort().map((k) => ({ key: k, title: k, className: "column-custom", icon: "📋" }));
|
||||
}
|
||||
}
|
||||
|
||||
return { filteredAssessments: filtered, statusGroups: groups, columns: cols };
|
||||
}
|
||||
|
||||
$: if (data) {
|
||||
const _ = currentFilters.sortBy && currentFilters.subject;
|
||||
const result = buildGroupsAndColumns();
|
||||
filteredAssessments = result.filteredAssessments;
|
||||
statusGroups = result.statusGroups;
|
||||
columns = result.columns;
|
||||
}
|
||||
|
||||
function updateAssessments() {
|
||||
const result = buildGroupsAndColumns();
|
||||
filteredAssessments = result.filteredAssessments;
|
||||
statusGroups = result.statusGroups;
|
||||
columns = result.columns;
|
||||
}
|
||||
|
||||
function getDueDateClass(assessment: any): string {
|
||||
@@ -123,6 +185,56 @@
|
||||
}
|
||||
}
|
||||
|
||||
function hideAssessment(assessment: any) {
|
||||
const hidden = JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]");
|
||||
const id = String(assessment.id);
|
||||
if (!hidden.includes(id)) {
|
||||
hidden.push(id);
|
||||
localStorage.setItem(HIDDEN_ASSESSMENTS_KEY, JSON.stringify(hidden));
|
||||
visibilityRefresh++;
|
||||
closeAllMenus();
|
||||
updateAssessments();
|
||||
}
|
||||
}
|
||||
|
||||
function hideSubject(subjectCode: string) {
|
||||
const filters = { ...(settingsState.subjectfilters || {}) };
|
||||
filters[subjectCode] = false;
|
||||
settingsState.subjectfilters = filters;
|
||||
closeAllMenus();
|
||||
updateAssessments();
|
||||
}
|
||||
|
||||
function unhideSubject(subjectCode: string) {
|
||||
const filters = { ...(settingsState.subjectfilters || {}) };
|
||||
filters[subjectCode] = true;
|
||||
settingsState.subjectfilters = filters;
|
||||
updateAssessments();
|
||||
}
|
||||
|
||||
function unhideAssessment(assessmentId: string) {
|
||||
const hidden = JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]");
|
||||
const idStr = String(assessmentId);
|
||||
const filtered = hidden.filter((id: string) => id !== idStr);
|
||||
localStorage.setItem(HIDDEN_ASSESSMENTS_KEY, JSON.stringify(filtered));
|
||||
visibilityRefresh++;
|
||||
updateAssessments();
|
||||
}
|
||||
|
||||
function initSubjectFilters() {
|
||||
const filters = settingsState.subjectfilters || {};
|
||||
let updated = false;
|
||||
data.subjects.forEach((s: any) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(filters, s.code)) {
|
||||
filters[s.code] = true;
|
||||
updated = true;
|
||||
}
|
||||
});
|
||||
if (updated) {
|
||||
settingsState.subjectfilters = filters;
|
||||
}
|
||||
}
|
||||
|
||||
function checkForCelebration() {
|
||||
const overdueCount = statusGroups.OVERDUE?.length || 0;
|
||||
const dueSoonCount = statusGroups.DUE_SOON?.length || 0;
|
||||
@@ -201,6 +313,20 @@
|
||||
}
|
||||
|
||||
let openMenuId: string | null = null;
|
||||
let showVisibilityPanel = false;
|
||||
let visibilityRefresh = 0;
|
||||
|
||||
$: hiddenSubjects = data?.subjects?.filter(
|
||||
(s: any) => (settingsState.subjectfilters || {})[s.code] === false
|
||||
) || [];
|
||||
$: hiddenAssessmentIds = (() => {
|
||||
visibilityRefresh; // Dependency for reactivity
|
||||
return new Set((JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]")).map(String));
|
||||
})();
|
||||
$: hiddenAssessmentsWithInfo = data?.assessments?.filter(
|
||||
(a: any) => hiddenAssessmentIds.has(String(a.id))
|
||||
) || [];
|
||||
$: hasHiddenItems = hiddenSubjects.length > 0 || hiddenAssessmentsWithInfo.length > 0;
|
||||
|
||||
function toggleMenu(assessmentId: string, event: Event) {
|
||||
event.stopPropagation();
|
||||
@@ -211,44 +337,13 @@
|
||||
openMenuId = null;
|
||||
}
|
||||
|
||||
$: {
|
||||
if (data) {
|
||||
$: if (data) {
|
||||
initSubjectFilters();
|
||||
updateAssessments();
|
||||
}
|
||||
void currentFilters.sortBy;
|
||||
void currentFilters.subject;
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "UPCOMING",
|
||||
title: "Upcoming",
|
||||
className: "column-upcoming",
|
||||
icon: "📅",
|
||||
},
|
||||
{
|
||||
key: "DUE_SOON",
|
||||
title: "Due Soon",
|
||||
className: "column-due-soon",
|
||||
icon: "⏰",
|
||||
},
|
||||
{
|
||||
key: "OVERDUE",
|
||||
title: "Overdue",
|
||||
className: "column-overdue",
|
||||
icon: "🚨",
|
||||
},
|
||||
{
|
||||
key: "SUBMITTED",
|
||||
title: "Submitted",
|
||||
className: "column-submitted",
|
||||
icon: "📝",
|
||||
},
|
||||
{
|
||||
key: "MARKS_RELEASED",
|
||||
title: "Marked",
|
||||
className: "column-marked",
|
||||
icon: "✅",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={closeAllMenus} />
|
||||
@@ -263,15 +358,58 @@
|
||||
<option value={subject.code}>{subject.code} - {subject.title}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select class="filter-select" bind:value={currentFilters.sortBy}>
|
||||
<option value="due">Sort by Due Date</option>
|
||||
<option value="grade">Sort by Grade</option>
|
||||
<option value="subject">Sort by Subject</option>
|
||||
<option value="title">Sort by Title</option>
|
||||
<select class="filter-select" bind:value={currentFilters.sortBy} title="Group by - columns change based on this">
|
||||
<option value="due">Group: Status</option>
|
||||
<option value="year">Group: Year</option>
|
||||
<option value="subject">Group: Subject</option>
|
||||
<option value="grade">Group: Grade</option>
|
||||
<option value="title">Group: Title (A-Z)</option>
|
||||
</select>
|
||||
{#if hasHiddenItems}
|
||||
<button
|
||||
class="visibility-toggle"
|
||||
class:active={showVisibilityPanel}
|
||||
on:click={() => (showVisibilityPanel = !showVisibilityPanel)}
|
||||
title="Manage hidden subjects and assessments"
|
||||
>
|
||||
👁 Visibility ({hiddenSubjects.length + hiddenAssessmentsWithInfo.length})
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showVisibilityPanel && hasHiddenItems}
|
||||
<div class="visibility-panel">
|
||||
<h4 class="visibility-panel-title">Hidden items</h4>
|
||||
{#if hiddenSubjects.length > 0}
|
||||
<div class="visibility-section">
|
||||
<span class="visibility-label">Subjects:</span>
|
||||
<div class="visibility-chips">
|
||||
{#each hiddenSubjects as subject}
|
||||
<span class="visibility-chip">
|
||||
{subject.code}
|
||||
<button class="visibility-unhide" on:click={() => unhideSubject(subject.code)}>Show</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if hiddenAssessmentsWithInfo.length > 0}
|
||||
<div class="visibility-section">
|
||||
<span class="visibility-label">Assessments:</span>
|
||||
<div class="visibility-chips">
|
||||
{#each hiddenAssessmentsWithInfo as assessment}
|
||||
<span class="visibility-chip">
|
||||
{assessment.title}
|
||||
<button class="visibility-unhide" on:click={() => unhideAssessment(assessment.id)}>Show</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div id="main-grid-content">
|
||||
{#if filteredAssessments.length === 0}
|
||||
<div class="empty-state">
|
||||
@@ -340,6 +478,12 @@
|
||||
Mark as Not Complete
|
||||
</button>
|
||||
{/if}
|
||||
<button class="menu-item menu-item-hide" on:click={() => hideAssessment(assessment)}>
|
||||
Hide assessment
|
||||
</button>
|
||||
<button class="menu-item menu-item-hide" on:click={() => hideSubject(assessment.code)}>
|
||||
Hide subject ({assessment.code})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -349,7 +493,7 @@
|
||||
{#if !assessment.results && !isCompleted}
|
||||
<div class="assessment-meta">
|
||||
<div class="due-date {dueDateClass}">
|
||||
📅 {formatDate(assessment.due, assessment.submitted)}
|
||||
📅 {formatDate(assessment.due || assessment.date || assessment.dueDate || "", assessment.submitted)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -56,6 +56,18 @@ async function loadUpcoming(student: number) {
|
||||
return res.payload;
|
||||
}
|
||||
|
||||
function normalizeAssessmentDates(t: any, subject: Subject): any {
|
||||
const normalized = { ...t };
|
||||
// Past API may use different date fields - ensure we have 'due' for year filter & display
|
||||
if (!normalized.due && (t.date || t.dueDate || t.created || t.submittedDate)) {
|
||||
normalized.due = t.date || t.dueDate || t.created || t.submittedDate;
|
||||
}
|
||||
if (!normalized.programmeID) normalized.programmeID = subject.programme;
|
||||
if (!normalized.metaclassID) normalized.metaclassID = subject.metaclass;
|
||||
if (!normalized.code && t.subject) normalized.code = t.subject;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function loadPast(student: number, subjects: Subject[]) {
|
||||
const map: Record<number, any> = {};
|
||||
await Promise.all(
|
||||
@@ -65,10 +77,22 @@ async function loadPast(student: number, subjects: Subject[]) {
|
||||
metaclass: s.metaclass,
|
||||
student,
|
||||
});
|
||||
if (res.payload.tasks) {
|
||||
res.payload.tasks.forEach((t: any) => {
|
||||
map[t.id] = t;
|
||||
});
|
||||
const processAssessment = (t: any) => {
|
||||
if (t && t.id) {
|
||||
const merged = {
|
||||
...t,
|
||||
programmeID: t.programmeID || t.programme || s.programme,
|
||||
metaclassID: t.metaclassID || t.metaclass || s.metaclass,
|
||||
code: t.code || t.subject || s.code,
|
||||
};
|
||||
map[t.id] = normalizeAssessmentDates(merged, s);
|
||||
}
|
||||
};
|
||||
if (res.payload?.pending && Array.isArray(res.payload.pending)) {
|
||||
res.payload.pending.forEach(processAssessment);
|
||||
}
|
||||
if (res.payload?.tasks && Array.isArray(res.payload.tasks)) {
|
||||
res.payload.tasks.forEach(processAssessment);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -34,19 +34,38 @@
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background: #ffffff !important;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%2364748b'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important;
|
||||
background-position: right 0.9rem center !important;
|
||||
background-repeat: no-repeat !important;
|
||||
background-size: 1rem !important;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
color: #1a1a1a;
|
||||
padding: 0.75rem 1rem;
|
||||
color-scheme: light;
|
||||
padding: 0.75rem 2.5rem 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
font-family: Rubik, sans-serif;
|
||||
line-height: 1.2;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.filter-select::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filter-select option {
|
||||
background: #ffffff;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: #d41e3a;
|
||||
@@ -61,8 +80,10 @@
|
||||
/* Dark mode dropdowns */
|
||||
.dark .filter-select {
|
||||
background: var(--background-primary) !important;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='rgba(255,255,255,0.72)'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important;
|
||||
border-color: var(--background-secondary);
|
||||
color: var(--text-primary);
|
||||
color-scheme: dark;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
@@ -73,7 +94,8 @@
|
||||
|
||||
.dark .filter-select:hover {
|
||||
border-color: var(--background-secondary);
|
||||
background: var(--background-secondary);
|
||||
background: var(--background-secondary) !important;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='rgba(255,255,255,0.72)'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important;
|
||||
}
|
||||
|
||||
.dark .filter-select option {
|
||||
@@ -335,6 +357,141 @@
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.menu-item.menu-item-hide {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.dark .menu-item.menu-item-hide {
|
||||
color: var(--text-primary);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.visibility-toggle {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #e2e8f0;
|
||||
background: #ffffff;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.visibility-toggle:hover {
|
||||
border-color: #cbd5e1;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.visibility-toggle.active {
|
||||
border-color: #d41e3a;
|
||||
background: rgba(212, 30, 58, 0.08);
|
||||
color: #d41e3a;
|
||||
}
|
||||
|
||||
.dark .visibility-toggle {
|
||||
background: var(--background-primary);
|
||||
border-color: var(--background-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dark .visibility-toggle:hover {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.dark .visibility-toggle.active {
|
||||
border-color: #d41e3a;
|
||||
background: rgba(212, 30, 58, 0.15);
|
||||
color: #d41e3a;
|
||||
}
|
||||
|
||||
.visibility-panel {
|
||||
padding: 1rem 1.25rem;
|
||||
margin: 0 1rem 1rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.dark .visibility-panel {
|
||||
background: var(--background-secondary);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.visibility-panel-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.dark .visibility-panel-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.visibility-section {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.visibility-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.visibility-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.dark .visibility-label {
|
||||
color: var(--text-primary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.visibility-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.visibility-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8125rem;
|
||||
color: #1a1a1a;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dark .visibility-chip {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.visibility-unhide {
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background: #d41e3a;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.visibility-unhide:hover {
|
||||
background: #b91c33;
|
||||
}
|
||||
|
||||
.assessment-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
@@ -455,6 +612,10 @@
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%);
|
||||
}
|
||||
|
||||
.column-custom .column-header {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f1f5f9 100%);
|
||||
}
|
||||
|
||||
/* Dark mode column headers */
|
||||
.dark .column-upcoming .column-header {
|
||||
background: linear-gradient(135deg, var(--background-secondary) 0%, #1e3a8a 100%);
|
||||
@@ -476,6 +637,10 @@
|
||||
background: linear-gradient(135deg, var(--background-secondary) 0%, #065f46 100%);
|
||||
}
|
||||
|
||||
.dark .column-custom .column-header {
|
||||
background: linear-gradient(135deg, var(--background-secondary) 0%, #1e3a5f 100%);
|
||||
}
|
||||
|
||||
/* Subject filter view */
|
||||
.subject-section {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
@@ -7,7 +7,7 @@ import localforage from "localforage";
|
||||
const settings = defineSettings({
|
||||
uploader: componentSetting({
|
||||
title: "Background Music",
|
||||
description: "Upload a .wav or .mp3 audio file to play in the background.",
|
||||
description: "Upload a .wav or .mp3 audio file to play in the background",
|
||||
component: BackgroundMusicSetting,
|
||||
}),
|
||||
volume: numberSetting({
|
||||
@@ -99,7 +99,7 @@ async function startPlayback(volume: number): Promise<void> {
|
||||
const backgroundMusicPlugin: Plugin<typeof settings> = {
|
||||
id: "background-music",
|
||||
name: "Background Music",
|
||||
description: "Play your own music in the background while SEQTA is open.",
|
||||
description: "Play your own music in the background while SEQTA is open",
|
||||
version: "1.0.0",
|
||||
settings,
|
||||
styles,
|
||||
@@ -142,7 +142,6 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
|
||||
if (!currentAudio) return;
|
||||
const pauseOnHidden = (api.settings as any).pauseOnHidden ?? true;
|
||||
if (!pauseOnHidden) return;
|
||||
|
||||
if (document.visibilityState === "hidden") {
|
||||
if (visibilityResumeTimeout !== null) {
|
||||
clearTimeout(visibilityResumeTimeout);
|
||||
@@ -183,5 +182,3 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
|
||||
};
|
||||
|
||||
export default backgroundMusicPlugin;
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ const settings = defineSettings({
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
const profilePicturePlugin: Plugin<typeof settings> = {
|
||||
id: "profile-picture",
|
||||
name: "Custom Profile Picture",
|
||||
@@ -74,7 +73,7 @@ const profilePicturePlugin: Plugin<typeof settings> = {
|
||||
window.addEventListener('profile-picture-updated', handler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('profile-picture-updated', handler);
|
||||
window.removeEventListener("profile-picture-updated", handler);
|
||||
if (img) img.remove();
|
||||
if (svg) svg.style.display = "";
|
||||
if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl);
|
||||
@@ -83,4 +82,3 @@ const profilePicturePlugin: Plugin<typeof settings> = {
|
||||
};
|
||||
|
||||
export default profilePicturePlugin;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import localforage from "localforage";
|
||||
import browser from "webextension-polyfill";
|
||||
import type { CustomTheme, LoadedCustomTheme } from "@/types/CustomThemes";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import debounce from "@/seqta/utils/debounce";
|
||||
@@ -470,23 +471,53 @@ export class ThemeManager {
|
||||
}
|
||||
}
|
||||
|
||||
private readonly THEME_API_BASE = 'https://betterseqta.org/api';
|
||||
private readonly GITHUB_THEMES_BASE = 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes';
|
||||
|
||||
/**
|
||||
* Download and install a theme from the store
|
||||
* Fetch JSON from a URL via background script (avoids CORS when running inside SEQTA page)
|
||||
*/
|
||||
private async fetchFromUrl(url: string): Promise<any> {
|
||||
const result = (await browser.runtime.sendMessage({
|
||||
type: 'fetchFromUrl',
|
||||
url,
|
||||
})) as { data?: unknown; error?: string };
|
||||
if (result?.error) throw new Error(result.error);
|
||||
return result?.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and install a theme from the store.
|
||||
* Uses API first (increments download_count), falls back to GitHub if unreachable.
|
||||
*/
|
||||
public async downloadTheme(themeContent: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
coverImage: string;
|
||||
description?: string;
|
||||
coverImage?: string;
|
||||
theme_json_url?: string;
|
||||
}): Promise<void> {
|
||||
console.debug("[ThemeManager] Downloading theme:", themeContent.name);
|
||||
try {
|
||||
if (!themeContent.id) return;
|
||||
|
||||
const response = await fetch(
|
||||
`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes/${themeContent.id}/theme.json`,
|
||||
);
|
||||
const themeData = (await response.json()) as ThemeContent;
|
||||
let themeData: ThemeContent;
|
||||
|
||||
try {
|
||||
// Try API first (increments download_count)
|
||||
const downloadData = (await this.fetchFromUrl(
|
||||
`${this.THEME_API_BASE}/themes/${themeContent.id}/download`
|
||||
)) as { success?: boolean; data?: { theme_json_url: string } };
|
||||
if (!downloadData?.success || !downloadData?.data?.theme_json_url) {
|
||||
throw new Error("Failed to get theme download URL");
|
||||
}
|
||||
themeData = (await this.fetchFromUrl(downloadData.data.theme_json_url)) as ThemeContent;
|
||||
} catch (apiError) {
|
||||
// Fallback to GitHub if API is unreachable
|
||||
console.warn("[ThemeManager] API failed, trying GitHub fallback:", apiError);
|
||||
const githubUrl = `${this.GITHUB_THEMES_BASE}/${themeContent.id}/theme.json`;
|
||||
themeData = (await this.fetchFromUrl(githubUrl)) as ThemeContent;
|
||||
}
|
||||
|
||||
await this.installTheme(themeData);
|
||||
} catch (error) {
|
||||
|
||||
@@ -130,7 +130,7 @@ function handleTimetableAssessmentHide(): void {
|
||||
|
||||
const hideOn = document.createElement("button");
|
||||
hideOn.className = "uiButton timetable-hide iconFamily";
|
||||
hideOn.innerHTML = "👁";
|
||||
hideOn.innerHTML = "";
|
||||
|
||||
hideControls.appendChild(hideOn);
|
||||
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import type { Plugin } from "../../core/types";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import styles from "./styles.css?inline";
|
||||
|
||||
interface TimetableEntryData {
|
||||
ci: number;
|
||||
description: string;
|
||||
room: string;
|
||||
staff: string;
|
||||
}
|
||||
|
||||
interface TimetableOverrides {
|
||||
[ci: string]: { room?: string; staff?: string };
|
||||
}
|
||||
|
||||
interface TimetableOverridesBySubject {
|
||||
[description: string]: { room?: string; staff?: string };
|
||||
}
|
||||
|
||||
interface TimetableStorage {
|
||||
timetableOverrides?: TimetableOverrides;
|
||||
timetableOverridesBySubject?: TimetableOverridesBySubject;
|
||||
}
|
||||
|
||||
/** SEQTA timetable entries use .teacher and .room as direct children, and data-instance for ci */
|
||||
function getRoomAndTeacherElements(entry: HTMLElement): {
|
||||
roomEl: HTMLElement | null;
|
||||
teacherEl: HTMLElement | null;
|
||||
} {
|
||||
const roomEl = entry.querySelector(".room") as HTMLElement | null;
|
||||
const teacherEl = entry.querySelector(".teacher") as HTMLElement | null;
|
||||
return { roomEl, teacherEl };
|
||||
}
|
||||
|
||||
const EDIT_ICON_SVG =
|
||||
'<svg width="24" height="24" viewBox="0 0 24 24"><g style="fill: currentcolor;"><path d="M20.71,7.04C21.1,6.65 21.1,6 20.71,5.63L18.37,3.29C18,2.9 17.35,2.9 16.96,3.29L15.12,5.12L18.87,8.87M3,17.25V21H6.75L17.81,9.93L14.06,6.18L3,17.25Z"/></g></svg>';
|
||||
|
||||
function showEditModal(
|
||||
item: TimetableEntryData,
|
||||
overrides: TimetableOverrides | undefined,
|
||||
overridesBySubject: TimetableOverridesBySubject | undefined,
|
||||
onSave: (
|
||||
ci: number,
|
||||
room: string,
|
||||
staff: string,
|
||||
applyToFuture: boolean,
|
||||
) => void,
|
||||
onClear: (ci: number) => void,
|
||||
): void {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "timetable-edit-modal-overlay";
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "timetable-edit-modal";
|
||||
|
||||
const override = overrides?.[String(item.ci)] ?? overridesBySubject?.[item.description];
|
||||
|
||||
const roomValue = override?.room ?? item.room ?? "";
|
||||
const staffValue = override?.staff ?? item.staff ?? "";
|
||||
|
||||
const escapeHtml = (s: string) =>
|
||||
s.replace(/&/g, "&").replace(/</g, "<").replace(/"/g, """);
|
||||
const title = escapeHtml(item.description);
|
||||
|
||||
modal.innerHTML = `
|
||||
<h3>Edit ${title}</h3>
|
||||
<label for="timetable-edit-room">Room</label>
|
||||
<input type="text" id="timetable-edit-room" value="${roomValue.replace(/"/g, """)}" placeholder="Room" />
|
||||
<label for="timetable-edit-staff">Teacher</label>
|
||||
<input type="text" id="timetable-edit-staff" value="${staffValue.replace(/"/g, """)}" placeholder="Teacher" />
|
||||
<div class="timetable-edit-modal-checkbox">
|
||||
<input type="checkbox" id="timetable-edit-apply-future" />
|
||||
<label for="timetable-edit-apply-future">Apply to future weeks</label>
|
||||
</div>
|
||||
<div class="timetable-edit-modal-actions">
|
||||
${override ? '<button type="button" class="timetable-edit-btn-clear">Clear</button>' : ""}
|
||||
<button type="button" class="timetable-edit-btn-cancel">Cancel</button>
|
||||
<button type="button" class="timetable-edit-btn-save">Save</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
overlay.appendChild(modal);
|
||||
|
||||
const removeModal = () => {
|
||||
overlay.remove();
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
};
|
||||
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") removeModal();
|
||||
};
|
||||
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) removeModal();
|
||||
});
|
||||
|
||||
modal.addEventListener("click", (e) => e.stopPropagation());
|
||||
modal.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
modal.addEventListener("mouseup", (e) => e.stopPropagation());
|
||||
|
||||
const roomInput = modal.querySelector("#timetable-edit-room") as HTMLInputElement;
|
||||
const staffInput = modal.querySelector("#timetable-edit-staff") as HTMLInputElement;
|
||||
const applyFutureCheckbox = modal.querySelector("#timetable-edit-apply-future") as HTMLInputElement;
|
||||
|
||||
modal.querySelector(".timetable-edit-btn-save")?.addEventListener("click", () => {
|
||||
onSave(
|
||||
item.ci,
|
||||
roomInput.value.trim(),
|
||||
staffInput.value.trim(),
|
||||
applyFutureCheckbox?.checked ?? false,
|
||||
);
|
||||
removeModal();
|
||||
});
|
||||
|
||||
modal.querySelector(".timetable-edit-btn-cancel")?.addEventListener("click", removeModal);
|
||||
|
||||
const clearBtn = modal.querySelector(".timetable-edit-btn-clear");
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener("click", () => {
|
||||
onClear(item.ci);
|
||||
removeModal();
|
||||
});
|
||||
}
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
roomInput?.focus();
|
||||
}
|
||||
|
||||
const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
||||
id: "timetableEdit",
|
||||
name: "Edit Rooms & Teachers",
|
||||
description: "Edit room and teacher names in timetable classes",
|
||||
version: "1.0.0",
|
||||
settings: {},
|
||||
disableToggle: true,
|
||||
defaultEnabled: true,
|
||||
|
||||
run: async (api) => {
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.textContent = styles;
|
||||
document.head.appendChild(styleEl);
|
||||
|
||||
await api.storage.loaded;
|
||||
|
||||
let observer: MutationObserver | null = null;
|
||||
let quickbarObserver: MutationObserver | null = null;
|
||||
let lastClickedCi: number | null = null;
|
||||
let lastClickedEntry: { roomEl: HTMLElement; teacherEl: HTMLElement; item: TimetableEntryData } | null = null;
|
||||
|
||||
const getOverrides = (): TimetableOverrides =>
|
||||
api.storage.timetableOverrides ?? {};
|
||||
const getOverridesBySubject = (): TimetableOverridesBySubject =>
|
||||
api.storage.timetableOverridesBySubject ?? {};
|
||||
|
||||
const getEffectiveOverride = (
|
||||
ci: number,
|
||||
description: string,
|
||||
): { room?: string; staff?: string } | undefined =>
|
||||
getOverrides()[String(ci)] ?? getOverridesBySubject()[description];
|
||||
|
||||
const processEntry = (entry: HTMLElement): void => {
|
||||
if (entry.classList.contains("assessment") || entry.hasAttribute("data-timetable-edit-processed")) return;
|
||||
|
||||
const ciStr = entry.getAttribute("data-instance");
|
||||
if (!ciStr) return;
|
||||
|
||||
const ci = parseInt(ciStr, 10);
|
||||
if (isNaN(ci)) return;
|
||||
|
||||
const { roomEl, teacherEl } = getRoomAndTeacherElements(entry);
|
||||
if (!roomEl && !teacherEl) return;
|
||||
|
||||
const titleEl = entry.querySelector(".title");
|
||||
const description = titleEl?.textContent?.trim() ?? "";
|
||||
const room = roomEl?.textContent?.trim() ?? "";
|
||||
const staff = teacherEl?.textContent?.trim() ?? "";
|
||||
|
||||
const item: TimetableEntryData = { ci, description, room, staff };
|
||||
|
||||
entry.setAttribute("data-timetable-edit-processed", "true");
|
||||
|
||||
const override = getEffectiveOverride(ci, description);
|
||||
if (override) {
|
||||
if (override.room !== undefined && roomEl) roomEl.textContent = override.room;
|
||||
if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff;
|
||||
}
|
||||
|
||||
const captureClick = (e: MouseEvent) => {
|
||||
lastClickedCi = ci;
|
||||
lastClickedEntry = { roomEl, teacherEl, item };
|
||||
};
|
||||
entry.addEventListener("click", captureClick, true);
|
||||
};
|
||||
|
||||
const processAllEntries = () => {
|
||||
document.querySelectorAll(".timetablepage .entry.class").forEach((entry) => {
|
||||
processEntry(entry as HTMLElement);
|
||||
});
|
||||
};
|
||||
|
||||
const addEditButtonToQuickbar = (quickbar: HTMLElement) => {
|
||||
if (quickbar.querySelector(".timetable-edit-quickbar-btn")) return;
|
||||
|
||||
const actions = quickbar.querySelector(".actions");
|
||||
if (!actions) return;
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "uiButton timetable-edit-quickbar-btn";
|
||||
btn.title = "Edit room and teacher";
|
||||
btn.innerHTML = EDIT_ICON_SVG;
|
||||
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const ci = lastClickedCi;
|
||||
const entryData = lastClickedEntry;
|
||||
if (!ci || !entryData) return;
|
||||
|
||||
const qb = (e.currentTarget as HTMLElement).closest(".quickbar");
|
||||
if (!qb) return;
|
||||
const quickbarRoom = qb.querySelector(".meta .room")?.textContent?.trim() ?? "";
|
||||
const quickbarTeacher = qb.querySelector(".meta .teacher")?.textContent?.trim() ?? "";
|
||||
const quickbarTitle = qb.querySelector(".title")?.textContent?.trim() ?? "";
|
||||
const item: TimetableEntryData = {
|
||||
ci,
|
||||
description: quickbarTitle || entryData.item.description,
|
||||
room: quickbarRoom || entryData.item.room,
|
||||
staff: quickbarTeacher || entryData.item.staff,
|
||||
};
|
||||
|
||||
showEditModal(
|
||||
item,
|
||||
getOverrides(),
|
||||
getOverridesBySubject(),
|
||||
(ci, room, staff, applyToFuture) => {
|
||||
if (applyToFuture) {
|
||||
const bySubject = { ...getOverridesBySubject() };
|
||||
bySubject[item.description] = {
|
||||
room: room || undefined,
|
||||
staff: staff || undefined,
|
||||
};
|
||||
api.storage.timetableOverridesBySubject = bySubject;
|
||||
} else {
|
||||
const current = getOverrides();
|
||||
api.storage.timetableOverrides = {
|
||||
...current,
|
||||
[String(ci)]: { room: room || undefined, staff: staff || undefined },
|
||||
};
|
||||
}
|
||||
if (entryData.roomEl) entryData.roomEl.textContent = room;
|
||||
if (entryData.teacherEl) entryData.teacherEl.textContent = staff;
|
||||
processAllEntries();
|
||||
},
|
||||
(ci) => {
|
||||
const current = getOverrides();
|
||||
delete current[String(ci)];
|
||||
api.storage.timetableOverrides = current;
|
||||
const bySubject = getOverridesBySubject();
|
||||
delete bySubject[item.description];
|
||||
api.storage.timetableOverridesBySubject = bySubject;
|
||||
if (entryData.roomEl) entryData.roomEl.textContent = item.room;
|
||||
if (entryData.teacherEl) entryData.teacherEl.textContent = item.staff;
|
||||
processAllEntries();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
actions.insertBefore(btn, actions.firstChild);
|
||||
};
|
||||
|
||||
const syncQuickbarFromDOM = () => {
|
||||
const quickbar = document.querySelector(".timetablepage .quickbar.visible");
|
||||
if (quickbar && quickbar.getAttribute("data-type") === "class") {
|
||||
const titleEl = quickbar.querySelector(".title");
|
||||
const roomEl = quickbar.querySelector(".meta .room");
|
||||
const teacherEl = quickbar.querySelector(".meta .teacher");
|
||||
if (titleEl && roomEl && teacherEl && lastClickedCi !== null && lastClickedEntry) {
|
||||
addEditButtonToQuickbar(quickbar as HTMLElement);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setupQuickbarObserver = () => {
|
||||
const timetablePage = document.querySelector(".timetablepage");
|
||||
if (!timetablePage || quickbarObserver) return;
|
||||
|
||||
quickbarObserver = new MutationObserver(() => {
|
||||
const quickbar = document.querySelector(".timetablepage .quickbar.visible");
|
||||
if (quickbar?.getAttribute("data-type") === "class") {
|
||||
addEditButtonToQuickbar(quickbar as HTMLElement);
|
||||
}
|
||||
});
|
||||
|
||||
quickbarObserver.observe(timetablePage, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimetable = async () => {
|
||||
await waitForElm(".timetablepage .entry", true, 10, 100);
|
||||
processAllEntries();
|
||||
setupQuickbarObserver();
|
||||
syncQuickbarFromDOM();
|
||||
|
||||
const timetablePage = document.querySelector(".timetablepage");
|
||||
if (timetablePage && !observer) {
|
||||
observer = new MutationObserver(() => {
|
||||
document.querySelectorAll(".timetablepage .entry.class").forEach((entry) => {
|
||||
if (!entry.hasAttribute("data-timetable-edit-processed")) {
|
||||
processEntry(entry as HTMLElement);
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(timetablePage, { childList: true, subtree: true });
|
||||
}
|
||||
};
|
||||
|
||||
const { unregister } = api.seqta.onMount(".timetablepage", handleTimetable);
|
||||
|
||||
return () => {
|
||||
unregister();
|
||||
observer?.disconnect();
|
||||
quickbarObserver?.disconnect();
|
||||
styleEl.remove();
|
||||
document.querySelectorAll("[data-timetable-edit-processed]").forEach((el) => {
|
||||
el.removeAttribute("data-timetable-edit-processed");
|
||||
});
|
||||
document.querySelectorAll(".timetable-edit-quickbar-btn").forEach((el) => el.remove());
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default timetableEditPlugin;
|
||||
@@ -0,0 +1,188 @@
|
||||
/* Timetable Edit Plugin - BetterSEQTA Plus style */
|
||||
|
||||
/* Edit button in quickbar */
|
||||
.timetable-edit-quickbar-btn {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.timetable-edit-quickbar-btn:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.timetable-edit-quickbar-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.timetable-edit-quickbar-btn svg {
|
||||
fill: currentColor;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* Edit modal animations */
|
||||
@keyframes timetable-edit-overlay-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes timetable-edit-modal-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Edit modal overlay - fix click-through with proper stacking */
|
||||
.timetable-edit-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483647;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
pointer-events: auto;
|
||||
animation: timetable-edit-overlay-in 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
.timetable-edit-modal {
|
||||
padding: 1rem 1.5rem;
|
||||
margin: 0 1rem;
|
||||
min-width: 18rem;
|
||||
max-width: 24rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--background-primary);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
pointer-events: auto;
|
||||
border: 1px solid var(--background-secondary);
|
||||
animation: timetable-edit-modal-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
.timetable-edit-modal h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.timetable-edit-modal label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.timetable-edit-modal input[type="text"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 1rem 0.5rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid var(--background-secondary);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--background-secondary);
|
||||
color: var(--text-primary);
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
.timetable-edit-modal input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--better-main, #007bff);
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
.timetable-edit-modal-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.timetable-edit-modal-checkbox input {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.timetable-edit-modal-checkbox label {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timetable-edit-modal-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.timetable-edit-modal-actions button {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timetable-edit-modal-actions .timetable-edit-btn-clear {
|
||||
background: transparent;
|
||||
border: 1px solid var(--background-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.timetable-edit-modal-actions .timetable-edit-btn-clear:hover {
|
||||
background: var(--background-secondary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.timetable-edit-modal-actions .timetable-edit-btn-cancel {
|
||||
background: transparent;
|
||||
border: 1px solid var(--background-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.timetable-edit-modal-actions .timetable-edit-btn-cancel:hover {
|
||||
background: var(--background-secondary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.timetable-edit-modal-actions .timetable-edit-btn-save {
|
||||
background: var(--better-main, #007bff);
|
||||
border: none;
|
||||
color: var(--text-color, white);
|
||||
}
|
||||
|
||||
.timetable-edit-modal-actions .timetable-edit-btn-save:hover {
|
||||
transform: scale(1.03) translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.35);
|
||||
}
|
||||
|
||||
.timetable-edit-modal-actions .timetable-edit-btn-save:active {
|
||||
transform: scale(0.98) translateY(0);
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { PluginManager } from "./core/manager";
|
||||
|
||||
// Lightweight plugins (load immediately)
|
||||
import timetablePlugin from "./built-in/timetable";
|
||||
import timetableEditPlugin from "./built-in/timetableEdit";
|
||||
import notificationCollectorPlugin from "./built-in/notificationCollector";
|
||||
import themesPlugin from "./built-in/themes";
|
||||
import animatedBackgroundPlugin from "./built-in/animatedBackground";
|
||||
@@ -23,6 +24,7 @@ pluginManager.registerPlugin(animatedBackgroundPlugin);
|
||||
pluginManager.registerPlugin(assessmentsAveragePlugin);
|
||||
pluginManager.registerPlugin(notificationCollectorPlugin);
|
||||
pluginManager.registerPlugin(timetablePlugin);
|
||||
pluginManager.registerPlugin(timetableEditPlugin);
|
||||
pluginManager.registerPlugin(profilePicturePlugin);
|
||||
pluginManager.registerPlugin(assessmentsOverviewPlugin);
|
||||
pluginManager.registerPlugin(backgroundMusicPlugin);
|
||||
|
||||
+81
-1
@@ -612,6 +612,15 @@ export function init() {
|
||||
if (settingsState.onoff) {
|
||||
console.info("[BetterSEQTA+] Enabled");
|
||||
if (settingsState.DarkMode) document.documentElement.classList.add("dark");
|
||||
if (settingsState.iconOnlySidebar) {
|
||||
if (document.body) {
|
||||
document.body.classList.add("icon-only-sidebar");
|
||||
} else {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.body?.classList.add("icon-only-sidebar");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelector(".legacy-root")?.classList.add("hidden");
|
||||
ObserveMenuItemPosition();
|
||||
@@ -619,12 +628,83 @@ export function init() {
|
||||
new StorageChangeHandler();
|
||||
new MessageHandler();
|
||||
|
||||
updateAllColors();
|
||||
void updateAllColors();
|
||||
|
||||
window.addEventListener("hashchange", () => {
|
||||
if (settingsState.adaptiveThemeColour) void updateAllColors();
|
||||
});
|
||||
loading();
|
||||
InjectCustomIcons();
|
||||
HideMenuItems();
|
||||
tryLoad();
|
||||
|
||||
// Auto-focus WISP direct online submission editor when pane opens
|
||||
eventManager.register(
|
||||
"wispassessmentAdded",
|
||||
{
|
||||
customCheck: (el) =>
|
||||
el.classList.contains("wispassessment") ||
|
||||
el.querySelector(".wispassessment") !== null,
|
||||
},
|
||||
(element) => {
|
||||
const wispassessment = element.classList.contains("wispassessment")
|
||||
? (element as Element)
|
||||
: element.querySelector(".wispassessment");
|
||||
if (!wispassessment) return;
|
||||
|
||||
const focusEditableBody = (iframe: HTMLIFrameElement) => {
|
||||
try {
|
||||
const doc = iframe.contentDocument;
|
||||
const win = iframe.contentWindow;
|
||||
if (doc?.body && win) {
|
||||
const editable =
|
||||
doc.body.querySelector(".cke_editable") || doc.body;
|
||||
const el = editable as HTMLElement;
|
||||
el.focus();
|
||||
const range = doc.createRange();
|
||||
range.selectNodeContents(el);
|
||||
range.collapse(true);
|
||||
const sel = win.getSelection();
|
||||
if (sel) {
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
return false;
|
||||
};
|
||||
|
||||
const focusEditor = () => {
|
||||
const iframe = wispassessment.querySelector(".cke_wysiwyg_frame");
|
||||
if (iframe instanceof HTMLIFrameElement) {
|
||||
if (focusEditableBody(iframe)) return;
|
||||
iframe.focus();
|
||||
return;
|
||||
}
|
||||
const ckeditor = (window as any).CKEDITOR;
|
||||
if (ckeditor?.instances?.editor1) {
|
||||
try {
|
||||
ckeditor.instances.editor1.focus();
|
||||
} catch (_) {}
|
||||
}
|
||||
};
|
||||
|
||||
const iframe = wispassessment.querySelector(".cke_wysiwyg_frame");
|
||||
if (iframe instanceof HTMLIFrameElement) {
|
||||
iframe.addEventListener(
|
||||
"load",
|
||||
() => focusEditableBody(iframe),
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
|
||||
[1000, 1200, 1500].forEach((delay) =>
|
||||
setTimeout(focusEditor, delay),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
const legacyElement = document.querySelector(
|
||||
".outside-container .bottom-container",
|
||||
|
||||
Binary file not shown.
@@ -13,6 +13,9 @@ import { delay } from "@/seqta/utils/delay";
|
||||
let cachedUserInfo: any = null;
|
||||
|
||||
let LightDarkModeSnakeEggButton = 0;
|
||||
let sidebarAccessibilityObserver: MutationObserver | null = null;
|
||||
let sidebarTabOrderAnimationFrame: number | null = null;
|
||||
let sidebarAccessibilityListenersAttached = false;
|
||||
|
||||
export async function getUserInfo() {
|
||||
if (cachedUserInfo) return cachedUserInfo;
|
||||
@@ -66,6 +69,7 @@ export async function AddBetterSEQTAElements() {
|
||||
setupEventListeners();
|
||||
await addDarkLightToggle();
|
||||
customizeMenuToggle();
|
||||
setupSidebarAccessibility();
|
||||
}
|
||||
|
||||
addExtensionSettings();
|
||||
@@ -113,6 +117,10 @@ function updateUserInfo(info: {
|
||||
userName: string | null;
|
||||
}) {
|
||||
const titlebar = document.getElementsByClassName("titlebar")[0];
|
||||
const metadata = [info.meta.code, info.meta.governmentID]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(" // ");
|
||||
const displayName = info.userDesc || info.userName || "";
|
||||
|
||||
titlebar.append(
|
||||
stringToHTML(/* html */ `
|
||||
@@ -128,10 +136,10 @@ function updateUserInfo(info: {
|
||||
<div class="userInfo">
|
||||
<div class="userInfoText">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<p class="userInfohouse userInfoCode"></p>
|
||||
<p class="userInfoName">${info.userDesc}</p>
|
||||
<p class="userInfohouse userInfoCode" style="display: none;"></p>
|
||||
${displayName ? `<p class="userInfoName">${displayName}</p>` : ""}
|
||||
</div>
|
||||
<p class="userInfoCode">${info.meta.code} // ${info.meta.governmentID}</p>
|
||||
${metadata ? `<p class="userInfoCode">${metadata}</p>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`).firstChild!,
|
||||
@@ -171,9 +179,12 @@ async function updateStudentInfo(students: any) {
|
||||
|
||||
const houseelement = document.getElementsByClassName(
|
||||
"userInfohouse",
|
||||
)[0] as HTMLElement;
|
||||
)[0] as HTMLElement | undefined;
|
||||
|
||||
if (!houseelement) return;
|
||||
|
||||
const student = students[index] ?? {};
|
||||
let text = "N/A";
|
||||
let text = "";
|
||||
|
||||
if (student.house) {
|
||||
text = `${student.year ?? ""}${student.house}`;
|
||||
@@ -193,6 +204,7 @@ async function updateStudentInfo(students: any) {
|
||||
}
|
||||
|
||||
houseelement.innerText = text;
|
||||
houseelement.style.display = text ? "block" : "none";
|
||||
}
|
||||
|
||||
function createNewsButton(fragment: DocumentFragment, menu: HTMLElement) {
|
||||
@@ -213,22 +225,27 @@ function setupEventListeners() {
|
||||
const homebutton = document.getElementById("homebutton");
|
||||
const newsbutton = document.getElementById("newsbutton");
|
||||
|
||||
homebutton?.addEventListener("click", function () {
|
||||
const activateMenuAction = (button: HTMLElement, action: () => void) => {
|
||||
if (
|
||||
!homebutton.classList.contains("draggable") &&
|
||||
!homebutton.classList.contains("active")
|
||||
button.classList.contains("draggable") ||
|
||||
button.classList.contains("active")
|
||||
) {
|
||||
loadHomePage();
|
||||
return;
|
||||
}
|
||||
|
||||
action();
|
||||
};
|
||||
|
||||
homebutton?.addEventListener("click", function () {
|
||||
activateMenuAction(homebutton, () => {
|
||||
loadHomePage();
|
||||
});
|
||||
});
|
||||
|
||||
newsbutton?.addEventListener("click", function () {
|
||||
if (
|
||||
!newsbutton.classList.contains("draggable") &&
|
||||
!newsbutton.classList.contains("active")
|
||||
) {
|
||||
activateMenuAction(newsbutton, () => {
|
||||
SendNewsPage();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
menuCover?.addEventListener("click", function () {
|
||||
@@ -319,3 +336,216 @@ function customizeMenuToggle() {
|
||||
menuToggle.appendChild(line);
|
||||
}
|
||||
}
|
||||
|
||||
function setupSidebarAccessibility() {
|
||||
updateSidebarAccessibility();
|
||||
|
||||
const menu = document.getElementById("menu");
|
||||
if (!menu) return;
|
||||
|
||||
sidebarAccessibilityObserver?.disconnect();
|
||||
sidebarAccessibilityObserver = new MutationObserver(() => {
|
||||
scheduleSidebarAccessibilityUpdate();
|
||||
});
|
||||
sidebarAccessibilityObserver.observe(menu, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style"],
|
||||
});
|
||||
|
||||
if (!sidebarAccessibilityListenersAttached) {
|
||||
document.addEventListener("keydown", handleSidebarKeyboardActivation);
|
||||
sidebarAccessibilityListenersAttached = true;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSidebarAccessibilityUpdate() {
|
||||
if (sidebarTabOrderAnimationFrame !== null) {
|
||||
cancelAnimationFrame(sidebarTabOrderAnimationFrame);
|
||||
}
|
||||
|
||||
sidebarTabOrderAnimationFrame = requestAnimationFrame(() => {
|
||||
sidebarTabOrderAnimationFrame = null;
|
||||
updateSidebarAccessibility();
|
||||
});
|
||||
}
|
||||
|
||||
function handleSidebarKeyboardActivation(event: KeyboardEvent) {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
|
||||
const menuItem = target.closest("#menu li, #menu section") as
|
||||
| HTMLElement
|
||||
| null;
|
||||
if (!menuItem || target !== menuItem) return;
|
||||
|
||||
if (event.key === "Tab") {
|
||||
const menu = document.getElementById("menu");
|
||||
if (!menu) return;
|
||||
|
||||
const visibleList = getVisibleSidebarList(menu);
|
||||
if (!visibleList) return;
|
||||
|
||||
const visibleEntries = getDirectSidebarEntries(visibleList);
|
||||
if (visibleEntries.length === 0) return;
|
||||
|
||||
const boundaryEntry = event.shiftKey
|
||||
? visibleEntries[0]
|
||||
: visibleEntries[visibleEntries.length - 1];
|
||||
|
||||
if (boundaryEntry !== menuItem) return;
|
||||
|
||||
const parentEntry = getSidebarListParentEntry(visibleList);
|
||||
if (!parentEntry) return;
|
||||
|
||||
event.preventDefault();
|
||||
parentEntry.classList.remove("active");
|
||||
scheduleSidebarAccessibilityUpdate();
|
||||
requestAnimationFrame(() => {
|
||||
parentEntry.focus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const childSubmenu = menuItem.querySelector(":scope > .sub > ul") as
|
||||
| HTMLElement
|
||||
| null;
|
||||
|
||||
menuItem.click();
|
||||
scheduleSidebarAccessibilityUpdate();
|
||||
|
||||
if (childSubmenu) {
|
||||
focusFirstSidebarSubmenuEntry(menuItem);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSidebarAccessibility() {
|
||||
const menu = document.getElementById("menu");
|
||||
if (!menu) return;
|
||||
|
||||
const visibleEntries = new Set(getVisibleSidebarEntries(menu));
|
||||
const menuEntries = menu.querySelectorAll("li.item, section.item, li, section");
|
||||
|
||||
for (const entry of menuEntries) {
|
||||
if (!(entry instanceof HTMLElement)) continue;
|
||||
|
||||
const label = entry.querySelector(":scope > label") as HTMLLabelElement | null;
|
||||
if (!label) continue;
|
||||
|
||||
const childSubmenu = entry.querySelector(":scope > .sub") as HTMLElement | null;
|
||||
const isHidden =
|
||||
entry.offsetParent === null ||
|
||||
window.getComputedStyle(entry).display === "none" ||
|
||||
window.getComputedStyle(label).display === "none" ||
|
||||
!visibleEntries.has(entry);
|
||||
|
||||
if (isHidden) {
|
||||
entry.tabIndex = -1;
|
||||
label.tabIndex = -1;
|
||||
entry.setAttribute("aria-hidden", "true");
|
||||
label.setAttribute("aria-hidden", "true");
|
||||
if (childSubmenu) {
|
||||
childSubmenu.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.tabIndex = 0;
|
||||
label.tabIndex = -1;
|
||||
entry.removeAttribute("aria-hidden");
|
||||
label.removeAttribute("aria-hidden");
|
||||
|
||||
if (!entry.hasAttribute("role")) {
|
||||
entry.setAttribute("role", "button");
|
||||
}
|
||||
|
||||
const accessibleLabel = label.textContent?.trim();
|
||||
if (accessibleLabel) {
|
||||
entry.setAttribute("aria-label", accessibleLabel);
|
||||
}
|
||||
|
||||
if (childSubmenu) {
|
||||
const isExpanded = entry.classList.contains("active");
|
||||
entry.setAttribute("aria-expanded", String(isExpanded));
|
||||
childSubmenu.setAttribute("aria-hidden", String(!isExpanded));
|
||||
} else {
|
||||
entry.removeAttribute("aria-expanded");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getVisibleSidebarEntries(menu = document.getElementById("menu")) {
|
||||
if (!menu) return [] as HTMLElement[];
|
||||
|
||||
const visibleList = getVisibleSidebarList(menu);
|
||||
if (!visibleList) return [] as HTMLElement[];
|
||||
|
||||
return getDirectSidebarEntries(visibleList);
|
||||
}
|
||||
|
||||
function getDirectSidebarEntries(list: HTMLElement) {
|
||||
return Array.from(list.querySelectorAll(":scope > li, :scope > section")).filter(
|
||||
(entry): entry is HTMLElement => entry instanceof HTMLElement,
|
||||
);
|
||||
}
|
||||
|
||||
function getVisibleSidebarList(menu: HTMLElement) {
|
||||
let currentList = menu.querySelector(":scope > ul") as HTMLElement | null;
|
||||
|
||||
while (currentList) {
|
||||
const activeSubmenuParent = currentList.querySelector(
|
||||
":scope > li.hasChildren.active, :scope > section.hasChildren.active",
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (!activeSubmenuParent) {
|
||||
return currentList;
|
||||
}
|
||||
|
||||
const nextList = activeSubmenuParent.querySelector(
|
||||
":scope > .sub > ul",
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (!nextList) {
|
||||
return currentList;
|
||||
}
|
||||
|
||||
currentList = nextList;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSidebarListParentEntry(list: HTMLElement) {
|
||||
return list.closest(".sub")?.parentElement instanceof HTMLElement
|
||||
? (list.closest(".sub")!.parentElement as HTMLElement)
|
||||
: null;
|
||||
}
|
||||
|
||||
function focusFirstSidebarSubmenuEntry(parentEntry: HTMLElement) {
|
||||
const menu = document.getElementById("menu");
|
||||
if (!menu) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!parentEntry.classList.contains("active")) return;
|
||||
|
||||
const visibleList = getVisibleSidebarList(menu);
|
||||
if (!visibleList || getSidebarListParentEntry(visibleList) !== parentEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstEntry = getDirectSidebarEntries(visibleList).find(
|
||||
(entry) =>
|
||||
entry.offsetParent !== null &&
|
||||
window.getComputedStyle(entry).display !== "none",
|
||||
);
|
||||
|
||||
firstEntry?.focus({ preventScroll: true });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import Color from "color";
|
||||
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
||||
import { lightenAndPaleColor } from "./lightenAndPaleColor";
|
||||
import ColorLuminance from "./ColorLuminance";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour";
|
||||
|
||||
import darkLogo from "@/resources/icons/betterseqta-light-full.png";
|
||||
import lightLogo from "@/resources/icons/betterseqta-dark-full.png";
|
||||
@@ -13,13 +15,7 @@ const setCSSVar = (varName: any, value: any) =>
|
||||
const applyProperties = (props: any) =>
|
||||
Object.entries(props).forEach(([key, value]) => setCSSVar(key, value));
|
||||
|
||||
export function updateAllColors() {
|
||||
// Determine the color to use
|
||||
const selectedColor =
|
||||
settingsState.selectedColor !== ""
|
||||
? settingsState.selectedColor
|
||||
: "#007bff";
|
||||
|
||||
function applyColorsWith(selectedColor: string) {
|
||||
if (settingsState.transparencyEffects) {
|
||||
document.documentElement.classList.add("transparencyEffects");
|
||||
}
|
||||
@@ -28,7 +24,7 @@ export function updateAllColors() {
|
||||
const commonProps = {
|
||||
"--better-sub": "#161616",
|
||||
"--better-alert-highlight": "#c61851",
|
||||
"--better-main": settingsState.selectedColor,
|
||||
"--better-main": selectedColor,
|
||||
};
|
||||
|
||||
// Mode-based properties, applied if storedSetting is provided
|
||||
@@ -79,3 +75,29 @@ export function updateAllColors() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toSoftGradient(hex: string): string {
|
||||
const base = Color(hex);
|
||||
const analogous = base.rotate(30).lighten(0.25).saturate(0.15);
|
||||
const mid = base.mix(analogous, 0.5).hex();
|
||||
return `linear-gradient(135deg, ${hex} 0%, ${mid} 50%, ${analogous.hex()} 100%)`;
|
||||
}
|
||||
|
||||
export async function updateAllColors() {
|
||||
let effectiveColor =
|
||||
settingsState.selectedColor !== ""
|
||||
? settingsState.selectedColor
|
||||
: "#007bff";
|
||||
|
||||
if (settingsState.adaptiveThemeColour) {
|
||||
const adaptiveColor = await getAdaptiveColour();
|
||||
if (adaptiveColor) {
|
||||
effectiveColor =
|
||||
settingsState.adaptiveThemeGradient
|
||||
? toSoftGradient(adaptiveColor)
|
||||
: adaptiveColor;
|
||||
}
|
||||
}
|
||||
|
||||
applyColorsWith(effectiveColor);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
interface ElementConfig {
|
||||
selector: string;
|
||||
action: (element: Element) => void;
|
||||
/** When true, element is not added to processedElements so the action runs every time (e.g. overwriting container content) */
|
||||
alwaysRun?: boolean;
|
||||
}
|
||||
|
||||
interface ContentConfig {
|
||||
@@ -77,6 +79,18 @@ const contentConfig: ContentConfig = {
|
||||
element.textContent = getRandomElement(mockData.assessmentTitles);
|
||||
},
|
||||
},
|
||||
assessmentTitleInTooltip: {
|
||||
selector: ".assessmenttooltip .tooltiptext p",
|
||||
action: (element) => {
|
||||
element.textContent = getRandomElement(mockData.assessmentTitles);
|
||||
},
|
||||
},
|
||||
assessmentTitleInDetail: {
|
||||
selector: "[class*='AssessmentItem__title___'], .assessment-title",
|
||||
action: (element) => {
|
||||
element.textContent = getRandomElement(mockData.assessmentTitles);
|
||||
},
|
||||
},
|
||||
assessmentSubject: {
|
||||
selector: ".upcoming-assessment .upcoming-details h5",
|
||||
action: (element) => {
|
||||
@@ -92,7 +106,8 @@ const contentConfig: ContentConfig = {
|
||||
noticeContent: {
|
||||
selector: ".notice .contents",
|
||||
action: (element) => {
|
||||
element.textContent = "Content has been redacted for privacy.";
|
||||
element.textContent =
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
|
||||
},
|
||||
},
|
||||
upcomingCheckboxes: {
|
||||
@@ -135,7 +150,7 @@ const contentConfig: ContentConfig = {
|
||||
selector:
|
||||
'[class*="MessageList__recipients___"] [class*="MessageList__value___"]',
|
||||
action: (element) => {
|
||||
element.textContent = "Recipient(s) Redacted";
|
||||
element.textContent = getRandomElement(mockData.messages.recipients);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -175,16 +190,15 @@ const contentConfig: ContentConfig = {
|
||||
documentNames: {
|
||||
selector: ".document td.title",
|
||||
action: (element) => {
|
||||
element.textContent = "Document Name Redacted";
|
||||
element.textContent = getRandomElement(mockData.documentTitles);
|
||||
},
|
||||
},
|
||||
forumTopics: {
|
||||
selector: "#menu .sub ul li:not([data-colour]):not(.hasChildren) label",
|
||||
action: (element) => {
|
||||
// Only redact if not in assessments section
|
||||
const assessmentsSection = element.closest('[data-key="assessments"]');
|
||||
if (!assessmentsSection) {
|
||||
element.textContent = "Forum Topic Redacted";
|
||||
element.textContent = getRandomElement(mockData.forumTopics);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -210,25 +224,27 @@ const contentConfig: ContentConfig = {
|
||||
courseNames: {
|
||||
selector: "#menu .sub ul li[data-colour] label",
|
||||
action: (element) => {
|
||||
element.textContent = "Course Name Redacted";
|
||||
element.textContent = getRandomElement(mockData.subjects);
|
||||
},
|
||||
},
|
||||
yearGroups: {
|
||||
selector: "#menu .sub > ul > li > label",
|
||||
action: (element) => {
|
||||
element.textContent = "Year Group Redacted";
|
||||
const yearGroup = Math.floor(Math.random() * 5) + 8;
|
||||
element.textContent = `Year ${yearGroup}`;
|
||||
},
|
||||
},
|
||||
newsArticleTitle: {
|
||||
selector: ".ArticleText a",
|
||||
action: (element) => {
|
||||
element.textContent = "News Article Title Redacted";
|
||||
element.textContent = getRandomElement(mockData.notices);
|
||||
},
|
||||
},
|
||||
newsArticleContent: {
|
||||
selector: ".ArticleText p",
|
||||
action: (element) => {
|
||||
element.textContent = "News Article Content Redacted";
|
||||
element.textContent =
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
|
||||
},
|
||||
},
|
||||
userHouse: {
|
||||
@@ -237,6 +253,45 @@ const contentConfig: ContentConfig = {
|
||||
element.textContent = "House";
|
||||
},
|
||||
},
|
||||
|
||||
// Timetable page: replace class names, teachers, rooms with fake data
|
||||
timetableEntryTitle: {
|
||||
selector: ".timetablepage .entry .title",
|
||||
action: (element) => {
|
||||
element.textContent = getRandomElement(mockData.subjects);
|
||||
},
|
||||
},
|
||||
timetableEntryTeacher: {
|
||||
selector: ".timetablepage .entry .teacher, .timetablepage .quickbar .meta .teacher",
|
||||
action: (element) => {
|
||||
element.textContent = getRandomElement(mockData.teachers);
|
||||
},
|
||||
},
|
||||
timetableEntryRoom: {
|
||||
selector: ".timetablepage .entry .room, .timetablepage .quickbar .meta .room",
|
||||
action: (element) => {
|
||||
element.textContent = getRandomElement(mockData.classrooms);
|
||||
},
|
||||
},
|
||||
quickbarTitle: {
|
||||
selector: ".timetablepage .quickbar .title",
|
||||
action: (element) => {
|
||||
element.textContent = getRandomElement(mockData.subjects);
|
||||
},
|
||||
},
|
||||
|
||||
// Home page: replace entire day with mock schedule (care + 7 lessons 8:55–3:15)
|
||||
homeDayContainer: {
|
||||
selector: "#day-container",
|
||||
alwaysRun: true,
|
||||
action: (element) => {
|
||||
const container = element as HTMLElement;
|
||||
if (!container.closest(".timetable-container")) return; // only on home
|
||||
const schedule = getMockDaySchedule();
|
||||
container.innerHTML = schedule;
|
||||
container.classList.remove("loading");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockData = {
|
||||
@@ -367,7 +422,26 @@ const mockData = {
|
||||
"Field Trip",
|
||||
"Cultural Festival",
|
||||
],
|
||||
documentTitles: [
|
||||
"Course Outline",
|
||||
"Assignment Brief",
|
||||
"Study Guide",
|
||||
"Reference Material",
|
||||
"Worksheet",
|
||||
"Reading List",
|
||||
"Project Guidelines",
|
||||
],
|
||||
forumTopics: [
|
||||
"General Discussion",
|
||||
"Homework Help",
|
||||
"Resource Share",
|
||||
"Class Updates",
|
||||
"Study Group",
|
||||
"Q&A",
|
||||
"Announcements",
|
||||
],
|
||||
messages: {
|
||||
recipients: ["Students", "Class", "Year Group", "Parents", "Guardians"],
|
||||
subjects: [
|
||||
"Mid-year Exams",
|
||||
"Science project due soon",
|
||||
@@ -573,6 +647,35 @@ Register through the PE department or see your house captains for more informati
|
||||
]
|
||||
};
|
||||
|
||||
/** Mock day schedule for home timetable: care 8:30–8:55, then 7 lessons 8:55–3:15 (45m each), 20m recess, lunch. */
|
||||
function getMockDaySchedule(): string {
|
||||
const blocks: { title: string; teacher: string; room: string; from: string; until: string }[] = [
|
||||
{ title: "Care Group", teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "8:30am", until: "8:55am" },
|
||||
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "8:55am", until: "9:40am" },
|
||||
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "9:40am", until: "10:25am" },
|
||||
{ title: "Recess", teacher: "—", room: "—", from: "10:25am", until: "10:45am" },
|
||||
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "10:45am", until: "11:30am" },
|
||||
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "11:30am", until: "12:15pm" },
|
||||
{ title: "Lunch", teacher: "—", room: "—", from: "12:15pm", until: "1:00pm" },
|
||||
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "1:00pm", until: "1:45pm" },
|
||||
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "1:45pm", until: "2:30pm" },
|
||||
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "2:30pm", until: "3:15pm" },
|
||||
];
|
||||
const colours = ["#8e8e8e", "#4FBBFE", "#59F675", "#fa915d", "#9c27b0", "#2196f3", "#4caf50", "#ff9800", "#e91e63", "#673ab7"];
|
||||
return blocks
|
||||
.map(
|
||||
(b, i) =>
|
||||
`<div class="day" style="--item-colour: ${colours[i % colours.length]};">
|
||||
<h2>${b.title}</h2>
|
||||
<h3>${b.teacher}</h3>
|
||||
<h3>${b.room}</h3>
|
||||
<h4>${b.from} – ${b.until}</h4>
|
||||
<h5> </h5>
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function getMockNotices() {
|
||||
return {
|
||||
payload: mockData.noticesData
|
||||
@@ -617,12 +720,15 @@ export function getMockAssessmentsData() {
|
||||
{ submitted: false, score: null, dayOffset: () => Math.floor(Math.random() * -3) - 1 }, // Recently overdue
|
||||
];
|
||||
|
||||
const assessments = Array.from({ length: 12 }, (_, i) => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const assessments = Array.from({ length: 14 }, (_, i) => {
|
||||
const subj = subjects[i % subjects.length];
|
||||
const template = statusTemplates[i % statusTemplates.length];
|
||||
const due = new Date();
|
||||
due.setDate(due.getDate() + template.dayOffset());
|
||||
if (i >= 10) due.setFullYear(currentYear - 1);
|
||||
|
||||
const types = ["Assignment", "Test", "Exam", "Project", "Presentation", "Report"];
|
||||
const assessment: any = {
|
||||
id: i + 1,
|
||||
title: mockData.assessmentTitles[i % mockData.assessmentTitles.length],
|
||||
@@ -631,6 +737,7 @@ export function getMockAssessmentsData() {
|
||||
metaclassID: subj.metaclass,
|
||||
due: due.toISOString(),
|
||||
submitted: template.submitted,
|
||||
type: types[i % types.length],
|
||||
};
|
||||
|
||||
if (template.score && typeof template.score === 'function') {
|
||||
@@ -650,14 +757,16 @@ export function getMockAssessmentsData() {
|
||||
const debouncedProcessElements = debounce(processNewElements, 1);
|
||||
|
||||
function processNewElements() {
|
||||
Object.entries(contentConfig).forEach(([_, { selector, action }]) => {
|
||||
Object.entries(contentConfig).forEach(([_, config]) => {
|
||||
const { selector, action, alwaysRun } = config;
|
||||
const elements = document.querySelectorAll(selector);
|
||||
elements.forEach((element: Element) => {
|
||||
// Only process elements that haven't been processed before
|
||||
if (!processedElements.has(element)) {
|
||||
if (alwaysRun || !processedElements.has(element)) {
|
||||
action(element);
|
||||
if (!alwaysRun) {
|
||||
processedElements.add(element);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
|
||||
const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback";
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
clientId: "bsplus_client_id",
|
||||
accessToken: "bsplus_token",
|
||||
refreshToken: "bsplus_refresh_token",
|
||||
user: "bsplus_user",
|
||||
} as const;
|
||||
|
||||
export type CloudUser = {
|
||||
id: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
pfpUrl?: string;
|
||||
admin_level?: number;
|
||||
};
|
||||
|
||||
export type CloudAuthState = {
|
||||
isLoggedIn: boolean;
|
||||
user: CloudUser | null;
|
||||
};
|
||||
|
||||
/** Callback invoked when auth state changes */
|
||||
type Listener = { (state: CloudAuthState): void };
|
||||
|
||||
class CloudAuthService {
|
||||
private static instance: CloudAuthService;
|
||||
private listeners = new Set<Listener>();
|
||||
private _state: CloudAuthState = { isLoggedIn: false, user: null };
|
||||
|
||||
private constructor() {
|
||||
void this.loadFromStorage();
|
||||
browser.storage.onChanged.addListener((changes, areaName) => {
|
||||
if (
|
||||
areaName === "local" &&
|
||||
(changes[STORAGE_KEYS.accessToken] ||
|
||||
changes[STORAGE_KEYS.user] ||
|
||||
changes[STORAGE_KEYS.clientId])
|
||||
) {
|
||||
void this.loadFromStorage();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static getInstance(): CloudAuthService {
|
||||
if (!CloudAuthService.instance) {
|
||||
CloudAuthService.instance = new CloudAuthService();
|
||||
}
|
||||
return CloudAuthService.instance;
|
||||
}
|
||||
|
||||
public get state(): CloudAuthState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
public subscribe(listener: Listener): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this._state);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private async loadFromStorage(): Promise<void> {
|
||||
const result = await browser.storage.local.get([
|
||||
STORAGE_KEYS.accessToken,
|
||||
STORAGE_KEYS.user,
|
||||
]);
|
||||
const token = result[STORAGE_KEYS.accessToken] as string | undefined;
|
||||
const user = result[STORAGE_KEYS.user] as CloudUser | undefined;
|
||||
this._state = {
|
||||
isLoggedIn: !!token,
|
||||
user: user ?? null,
|
||||
};
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener(this._state);
|
||||
}
|
||||
}
|
||||
|
||||
public async getStoredToken(): Promise<string | null> {
|
||||
const result = await browser.storage.local.get(STORAGE_KEYS.accessToken);
|
||||
return (result[STORAGE_KEYS.accessToken] as string) ?? null;
|
||||
}
|
||||
|
||||
private async getClientId(): Promise<string> {
|
||||
let clientId = (settingsState as any)[STORAGE_KEYS.clientId] as string | undefined;
|
||||
if (!clientId) {
|
||||
const stored = await browser.storage.local.get(STORAGE_KEYS.clientId);
|
||||
clientId = stored[STORAGE_KEYS.clientId] as string | undefined;
|
||||
}
|
||||
if (!clientId) {
|
||||
const reserveResult = (await browser.runtime.sendMessage({
|
||||
type: "cloudReserveClient",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
})) as { client_id?: string; error?: string };
|
||||
if (!reserveResult?.client_id) {
|
||||
throw new Error(reserveResult?.error ?? "Failed to reserve client");
|
||||
}
|
||||
clientId = reserveResult.client_id;
|
||||
(settingsState as any).setKey(STORAGE_KEYS.clientId, clientId);
|
||||
}
|
||||
return clientId;
|
||||
}
|
||||
|
||||
public async login(
|
||||
login: string,
|
||||
password: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const clientId = await this.getClientId();
|
||||
const result = (await browser.runtime.sendMessage({
|
||||
type: "cloudLogin",
|
||||
client_id: clientId,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
login: login.trim(),
|
||||
password,
|
||||
})) as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
user?: CloudUser;
|
||||
error?: string;
|
||||
};
|
||||
if (result?.access_token && result?.refresh_token) {
|
||||
(settingsState as any).setKey(STORAGE_KEYS.accessToken, result.access_token);
|
||||
(settingsState as any).setKey(STORAGE_KEYS.refreshToken, result.refresh_token);
|
||||
(settingsState as any).setKey(STORAGE_KEYS.user, result.user ?? null);
|
||||
this._state = {
|
||||
isLoggedIn: true,
|
||||
user: result.user ?? null,
|
||||
};
|
||||
this.notify();
|
||||
return { success: true };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: result?.error ?? "Login failed",
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Login failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async logout(): Promise<void> {
|
||||
await browser.storage.local.remove([
|
||||
STORAGE_KEYS.accessToken,
|
||||
STORAGE_KEYS.refreshToken,
|
||||
STORAGE_KEYS.user,
|
||||
"cloudAccessToken",
|
||||
"cloudUsername",
|
||||
]);
|
||||
this._state = { isLoggedIn: false, user: null };
|
||||
this.notify();
|
||||
}
|
||||
|
||||
public async refreshToken(): Promise<boolean> {
|
||||
const result = await browser.storage.local.get([
|
||||
STORAGE_KEYS.refreshToken,
|
||||
STORAGE_KEYS.clientId,
|
||||
]);
|
||||
const refreshToken = result[STORAGE_KEYS.refreshToken] as string | undefined;
|
||||
const clientId = result[STORAGE_KEYS.clientId] as string | undefined;
|
||||
if (!refreshToken || !clientId) return false;
|
||||
|
||||
const refreshResult = (await browser.runtime.sendMessage({
|
||||
type: "cloudRefresh",
|
||||
refresh_token: refreshToken,
|
||||
client_id: clientId,
|
||||
})) as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
user?: CloudUser;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (refreshResult?.access_token && refreshResult?.refresh_token) {
|
||||
(settingsState as any).setKey(STORAGE_KEYS.accessToken, refreshResult.access_token);
|
||||
(settingsState as any).setKey(STORAGE_KEYS.refreshToken, refreshResult.refresh_token);
|
||||
(settingsState as any).setKey(STORAGE_KEYS.user, refreshResult.user ?? null);
|
||||
this._state = {
|
||||
isLoggedIn: true,
|
||||
user: refreshResult.user ?? null,
|
||||
};
|
||||
this.notify();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const cloudAuth = CloudAuthService.getInstance();
|
||||
@@ -129,9 +129,9 @@ export async function loadHomePage() {
|
||||
.filter((item: any) => item.name === "notices.filters")
|
||||
.map((item: any) => item.value);
|
||||
|
||||
if (labelArray.length > 0) {
|
||||
const noticeContainer = document.getElementById("notice-container");
|
||||
if (noticeContainer) {
|
||||
if (labelArray.length > 0) {
|
||||
const dateControl = document.querySelector(
|
||||
'input[type="date"]',
|
||||
) as HTMLInputElement;
|
||||
@@ -140,6 +140,17 @@ export async function loadHomePage() {
|
||||
setupNotices(labelArray[0].split(" "), TodayFormatted);
|
||||
}
|
||||
noticeContainer.classList.remove("loading");
|
||||
} else {
|
||||
noticeContainer.classList.remove("loading");
|
||||
noticeContainer.innerHTML = "";
|
||||
const emptyState = document.createElement("div");
|
||||
emptyState.classList.add("day-empty");
|
||||
const img = document.createElement("img");
|
||||
img.src = browser.runtime.getURL(LogoLight);
|
||||
const text = document.createElement("p");
|
||||
text.innerText = "No notices available.";
|
||||
emptyState.append(img, text);
|
||||
noticeContainer.append(emptyState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,8 +254,9 @@ function setupNotices(labelArray: string[], date: string) {
|
||||
).json();
|
||||
|
||||
processNotices(data, labelArray);
|
||||
} catch (error) {
|
||||
console.error("[BetterSEQTA+] Failed to fetch notices:", error);
|
||||
} catch {
|
||||
// Notices failed to load; processNotices will show empty state if container exists
|
||||
processNotices({ payload: [] }, labelArray);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -278,12 +290,28 @@ function processNotices(response: any, labelArray: string[]) {
|
||||
|
||||
NoticeContainer.innerHTML = "";
|
||||
|
||||
const notices = response.payload;
|
||||
const notices = response?.payload;
|
||||
if (!Array.isArray(notices)) {
|
||||
const emptyState = document.createElement("div");
|
||||
emptyState.classList.add("day-empty");
|
||||
const img = document.createElement("img");
|
||||
img.src = browser.runtime.getURL(LogoLight);
|
||||
const text = document.createElement("p");
|
||||
text.innerText = "No notices for today.";
|
||||
emptyState.append(img, text);
|
||||
NoticeContainer.append(emptyState);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!notices.length) {
|
||||
const dummyNotice = document.createElement("div");
|
||||
dummyNotice.textContent = "No notices for today.";
|
||||
dummyNotice.classList.add("dummynotice");
|
||||
NoticeContainer.append(dummyNotice);
|
||||
const emptyState = document.createElement("div");
|
||||
emptyState.classList.add("day-empty");
|
||||
const img = document.createElement("img");
|
||||
img.src = browser.runtime.getURL(LogoLight);
|
||||
const text = document.createElement("p");
|
||||
text.innerText = "No notices for today.";
|
||||
emptyState.append(img, text);
|
||||
NoticeContainer.append(emptyState);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -440,7 +468,7 @@ function openNoticeModal(
|
||||
document.body.removeChild(tempMeasureDiv);
|
||||
|
||||
let targetHeight = Math.round(
|
||||
Math.min(Math.max(measuredHeight, 200), viewportHeight * 0.85),
|
||||
Math.min(Math.max(measuredHeight + 32, 200), viewportHeight * 0.9),
|
||||
);
|
||||
let targetLeft = Math.round((viewportWidth - targetWidth) / 2);
|
||||
let targetTop = Math.round((viewportHeight - targetHeight) / 2) + scrollY;
|
||||
@@ -552,7 +580,7 @@ function openNoticeModal(
|
||||
);
|
||||
const currentHeight = unifiedContent.getBoundingClientRect().height;
|
||||
const newTargetHeight = Math.round(
|
||||
Math.min(Math.max(currentHeight, 200), newViewportHeight * 0.85),
|
||||
Math.min(Math.max(currentHeight + 32, 200), newViewportHeight * 0.9),
|
||||
);
|
||||
const newTargetLeft = Math.round((newViewportWidth - newTargetWidth) / 2);
|
||||
const newTargetTop =
|
||||
|
||||
@@ -32,6 +32,29 @@ export function OpenWhatsNewPopup() {
|
||||
const text = stringToHTML(/* html */ `
|
||||
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
|
||||
|
||||
<h1>3.5.1 - QR & session link fix</h1>
|
||||
<li>Fixed DesQTA Connect Mobile App QR generation on Chrome</li>
|
||||
|
||||
<h1>3.5.0 - Adaptive Theme, Timetable Editor & More</h1>
|
||||
<li>Added adaptive theme colour</li>
|
||||
<li>Added optional soft gradient for adaptive theme when viewing a class</li>
|
||||
<li>Added timetable editor</li>
|
||||
<li>Added icon-only sidebar option for a compact layout</li>
|
||||
<li>Added empty states for notices and homepage cards</li>
|
||||
<li>Added BetterSEQTA Cloud sign-in and improved store browsing details</li>
|
||||
<li>Improved popup rendering to better handle floating UI elements</li>
|
||||
<li>Fixed assessment colouring issues</li>
|
||||
<li>Fixed icon loading on SEQTA pages and improved Windows dropdown styling</li>
|
||||
<li>Fixed sidebar issues with compact mode, keyboard focus, and tab navigation</li>
|
||||
<li>Fixed unnecessary notice modal scrolling and other popup styling issues</li>
|
||||
<li>Added new kanban categories to the assessments overview</li>
|
||||
<li>Added DesQTA QR code generation in settings for linking to the DesQTA (BetterSEQTA) mobile app</li>
|
||||
<li>New store with improved theme browsing and backgrounds</li>
|
||||
<li>Other minor bug fixes and improvements</li>
|
||||
|
||||
<h1>3.4.16 - Subject Averages Fixes</h1>
|
||||
<li>Fixed subject averages not showing correctly with letter grades and weighted marks</li>
|
||||
|
||||
<h1>3.4.15 - SEQTA New UI Patch</h1>
|
||||
<li>Fixed compatibility issues caused by the new SEQTA UI update</li>
|
||||
<li>Adjusted styling to match updated SEQTA layout changes</li>
|
||||
|
||||
@@ -53,11 +53,22 @@ export async function SendNewsPage() {
|
||||
const newscontainer = document.querySelector("#news-container");
|
||||
document.getElementById("newsloading")?.remove();
|
||||
|
||||
// Create a document fragment to batch DOM operations
|
||||
const articles = response?.news?.articles;
|
||||
if (!Array.isArray(articles) || articles.length === 0) {
|
||||
const emptyState = document.createElement("div");
|
||||
emptyState.classList.add("day-empty");
|
||||
const img = document.createElement("img");
|
||||
img.src = browser.runtime.getURL(LogoLightOutline);
|
||||
const text = document.createElement("p");
|
||||
text.innerText = "No news articles available right now.";
|
||||
emptyState.append(img, text);
|
||||
newscontainer?.append(emptyState);
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
// Map over articles to create elements
|
||||
response.news.articles.forEach((article: any) => {
|
||||
articles.forEach((article: any) => {
|
||||
const newsarticle = document.createElement("a");
|
||||
newsarticle.classList.add("NewsArticle");
|
||||
newsarticle.href = article.url;
|
||||
@@ -85,12 +96,14 @@ export async function SendNewsPage() {
|
||||
title.target = "_blank";
|
||||
|
||||
const description = document.createElement("p");
|
||||
const articleDescription = typeof article.description === "string"
|
||||
? article.description
|
||||
: "No description available.";
|
||||
|
||||
article.description =
|
||||
article.description.length > 400
|
||||
? article.description.substring(0, 400) + "..."
|
||||
: article.description;
|
||||
description.innerHTML = article.description;
|
||||
description.innerHTML =
|
||||
articleDescription.length > 400
|
||||
? articleDescription.substring(0, 400) + "..."
|
||||
: articleDescription;
|
||||
|
||||
articletext.append(title, description);
|
||||
newsarticle.append(articleimage, articletext);
|
||||
@@ -102,10 +115,10 @@ export async function SendNewsPage() {
|
||||
|
||||
if (!settingsState.animations) return;
|
||||
|
||||
const articles = Array.from(document.querySelectorAll(".NewsArticle"));
|
||||
const animatedArticles = Array.from(document.querySelectorAll(".NewsArticle"));
|
||||
|
||||
animate(
|
||||
articles.slice(0, 20),
|
||||
animatedArticles.slice(0, 20),
|
||||
{ opacity: [0, 1], y: [10, 0], scale: [0.99, 1] },
|
||||
{
|
||||
delay: stagger(0.1),
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
|
||||
|
||||
/**
|
||||
* Parses the current page from window.location.hash.
|
||||
* Returns { programme, metaclass } for /courses/SEMESTER/X:Y or /assessments/SEMESTER/X:Y, or null.
|
||||
* e.g. #?page=/courses/2023S/4804:11066 or #?page=/assessments/2023S/4621:10772
|
||||
*/
|
||||
function parsePageContext(): { programme: number; metaclass: number } | null {
|
||||
const hash = window.location.hash || "";
|
||||
const match = hash.match(/[?&]page=\/(courses|assessments)\/[^/]+\/(\d+):(\d+)/);
|
||||
if (!match) return null;
|
||||
const programme = parseInt(match[2], 10);
|
||||
const metaclass = parseInt(match[3], 10);
|
||||
if (isNaN(programme) || isNaN(metaclass)) return null;
|
||||
return { programme, metaclass };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches subjects and finds the subject matching programme:metaclass.
|
||||
*/
|
||||
async function getSubjectCode(
|
||||
programme: number,
|
||||
metaclass: number
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`${location.origin}/seqta/student/load/subjects?`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = await res.json();
|
||||
const payload = data?.payload;
|
||||
if (!Array.isArray(payload)) return null;
|
||||
|
||||
for (const semester of payload) {
|
||||
const subjects = semester?.subjects;
|
||||
if (!Array.isArray(subjects)) continue;
|
||||
const subject = subjects.find(
|
||||
(s: any) =>
|
||||
s &&
|
||||
Number(s.programme) === programme &&
|
||||
Number(s.metaclass) === metaclass
|
||||
);
|
||||
if (subject?.code) return subject.code;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.warn("[BetterSEQTA+] Adaptive theme: failed to load subjects:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches user prefs and returns the colour for the given subject code.
|
||||
*/
|
||||
async function getSubjectColour(
|
||||
subjectCode: string,
|
||||
userId: number
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`${location.origin}/seqta/student/load/prefs?`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify({
|
||||
request: "userPrefs",
|
||||
asArray: true,
|
||||
user: userId,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
const payload = data?.payload;
|
||||
if (!Array.isArray(payload)) return null;
|
||||
|
||||
const pref = payload.find(
|
||||
(p: { name: string; value: string }) =>
|
||||
p.name === `timetable.subject.colour.${subjectCode}`
|
||||
);
|
||||
return pref?.value ?? null;
|
||||
} catch (error) {
|
||||
console.warn("[BetterSEQTA+] Adaptive theme: failed to load prefs:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the adaptive theme colour for the current page context, or null.
|
||||
* When viewing a course or assessments page, returns the subject's assigned colour.
|
||||
*/
|
||||
export async function getAdaptiveColour(): Promise<string | null> {
|
||||
const context = parsePageContext();
|
||||
if (!context) return null;
|
||||
|
||||
const subjectCode = await getSubjectCode(context.programme, context.metaclass);
|
||||
if (!subjectCode) return null;
|
||||
|
||||
let userId: number;
|
||||
try {
|
||||
const userInfo = await getUserInfo();
|
||||
userId = userInfo?.id;
|
||||
if (typeof userId !== "number") return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const colour = await getSubjectColour(subjectCode, userId);
|
||||
if (!colour || typeof colour !== "string") return null;
|
||||
|
||||
// Basic hex validation
|
||||
if (/^#([0-9A-Fa-f]{3}){1,2}$/.test(colour)) return colour;
|
||||
if (/^[0-9A-Fa-f]{6}$/.test(colour)) return `#${colour}`;
|
||||
return null;
|
||||
}
|
||||
@@ -14,7 +14,9 @@ export class StorageChangeHandler {
|
||||
}
|
||||
|
||||
private registerHandlers() {
|
||||
settingsState.register("selectedColor", updateAllColors.bind(this));
|
||||
settingsState.register("selectedColor", () => void updateAllColors());
|
||||
settingsState.register("adaptiveThemeColour", () => void updateAllColors());
|
||||
settingsState.register("adaptiveThemeGradient", () => void updateAllColors());
|
||||
settingsState.register("DarkMode", this.handleDarkModeChange.bind(this));
|
||||
settingsState.register("onoff", this.handleOnOffChange.bind(this));
|
||||
settingsState.register("shortcuts", this.handleShortcutsChange.bind(this));
|
||||
@@ -30,10 +32,23 @@ export class StorageChangeHandler {
|
||||
"subjectfilters",
|
||||
FilterUpcomingAssessments.bind(this),
|
||||
);
|
||||
settingsState.register(
|
||||
"iconOnlySidebar",
|
||||
this.handleIconOnlySidebarChange.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
private handleIconOnlySidebarChange(newValue: boolean | undefined) {
|
||||
if (!document.body) return;
|
||||
if (newValue) {
|
||||
document.body.classList.add("icon-only-sidebar");
|
||||
} else {
|
||||
document.body.classList.remove("icon-only-sidebar");
|
||||
}
|
||||
}
|
||||
|
||||
private handleDarkModeChange() {
|
||||
updateAllColors();
|
||||
void updateAllColors();
|
||||
}
|
||||
|
||||
private handleOnOffChange(newValue: boolean) {
|
||||
|
||||
@@ -40,6 +40,9 @@ export interface SettingsState {
|
||||
newsSource?: string;
|
||||
mockNotices?: boolean;
|
||||
hideSensitiveContent?: boolean;
|
||||
iconOnlySidebar?: boolean;
|
||||
adaptiveThemeColour?: boolean;
|
||||
adaptiveThemeGradient?: boolean;
|
||||
|
||||
// depreciated keys
|
||||
animatedbk: boolean;
|
||||
@@ -47,6 +50,12 @@ export interface SettingsState {
|
||||
lettergrade: boolean;
|
||||
assessmentsAverage?: boolean;
|
||||
notificationCollector?: boolean;
|
||||
|
||||
// BetterSEQTA Cloud (accounts.betterseqta.org)
|
||||
bsplus_client_id?: string;
|
||||
bsplus_token?: string;
|
||||
bsplus_refresh_token?: string;
|
||||
bsplus_user?: { id: string; email?: string; username?: string; displayName?: string; pfpUrl?: string; admin_level?: number };
|
||||
}
|
||||
|
||||
interface ToggleItem {
|
||||
|
||||
Reference in New Issue
Block a user