Compare commits

...

238 Commits

Author SHA1 Message Date
SethBurkart123 b503363d64 fix: assessment tooltips on homepage 2025-06-25 09:46:35 +10:00
SethBurkart123 b69d5f47fc feat: update changelog 2025-06-25 09:17:23 +10:00
SethBurkart123 404d3c02f3 feat: new update video 2025-06-25 09:13:19 +10:00
SethBurkart123 964a026e7a feat: improved sensitive content hider (dev) 2025-06-23 12:33:37 +10:00
SethBurkart123 c7d9e1d955 feat: remove rounded corners on custom shortcut icons 2025-06-23 11:58:18 +10:00
SethBurkart123 e305b70035 chore: update changelog 2025-06-20 10:01:56 +10:00
SethBurkart123 c4140a2a9d feat: homepage styling improvements 2025-06-18 14:14:48 +10:00
SethBurkart123 566f326dce feat: modern and animated notices on homepage 2025-06-18 12:49:35 +10:00
SethBurkart123 c9fe4e0e1c feat: add loading animations to lessons on home page 2025-06-18 10:08:01 +10:00
SethBurkart123 fbd8d9e9e8 fix: Home page todays lessons not properly changing days #310 2025-06-18 09:41:21 +10:00
SethBurkart123 3f0d3f87fe fix: search bar searches being delayed 2025-06-13 10:59:15 +10:00
SethBurkart123 2292585e60 feat: add confetti and render overview with svelte 2025-06-13 09:06:56 +10:00
SethBurkart123 bb7c27dfea feat: hide empty overview columns + transparency effects support 2025-06-12 17:12:26 +10:00
SethBurkart123 8c1df8f829 fix (perf): background flickering on page load #305 2025-06-12 17:02:26 +10:00
SethBurkart123 7462e6ab5d fix: builds failing 2025-06-12 16:59:21 +10:00
SethBurkart123 66ff6e3468 fix: standalone settingsState not loading 2025-06-12 16:42:01 +10:00
SethBurkart123 8bd9b1dae7 perf: reduce timetable resource consuption and update notification collector 2025-06-12 16:24:28 +10:00
SethBurkart123 d377329bf9 chore: remove unused code 2025-06-12 15:15:34 +10:00
SethBurkart123 ec38502747 perf: only load vectorWorker when required 2025-06-12 15:15:07 +10:00
SethBurkart123 57b4daa9b7 feat: global search bug fixes and performance improvements 2025-06-12 14:45:36 +10:00
Seth Burkart fa37fe9d21 Merge pull request #307 from BlastedMeteor44/main
Added easter egg to AddBetterSEQTAElements.ts
2025-06-08 10:24:01 +10:00
SethBurkart123 ccb4354b26 feat: assessments overview mark as complete 2025-06-07 23:49:28 +10:00
SethBurkart123 841426d7ec fix: delayed flicker in 12 hour times on timetable 2025-06-07 18:24:52 +10:00
SethBurkart123 b9f0675c4f fix: oversized empty lessons image 2025-06-07 18:21:14 +10:00
BlastedMeteor44 8ff5fd8d2f Added easter egg to AddBetterSEQTAElements.ts
never gonna give you up shows if you toggle light/dark mode 10 times
2025-06-06 17:16:42 +08:00
SethBurkart123 ff01b44ead fix: flashing news sidebar button #303 2025-06-06 12:40:24 +10:00
SethBurkart123 72f7eeb935 fix: 12 hour time in timetable not applying correctly #303 2025-06-06 12:30:36 +10:00
SethBurkart123 a009f40ac2 feat: submitted column + bug fixes 2025-06-06 12:21:46 +10:00
SethBurkart123 c202af9688 feat: horizontal scrolling on timetable in homepage 2025-06-06 12:12:51 +10:00
SethBurkart123 7a91550de5 feat: cleanup assessmentsOverview 2025-06-06 11:52:24 +10:00
SethBurkart123 c4dc4b58b8 feat: assessment overview get letter grades from Assessment Averages plugin 2025-06-06 10:36:19 +10:00
SethBurkart123 d59802d4c3 fix: settings popup not appearing on disabled pages 2025-06-06 10:27:00 +10:00
SethBurkart123 074c2ff4bb feat: performance and visual improvements 2025-06-06 10:16:42 +10:00
Seth Burkart e94008efba Merge pull request #304 from Jones8683/main
Fix profile pic and add light mode styling
2025-06-05 23:18:03 +10:00
SethBurkart123 972783eb13 feat: assessments kanban overview 2025-06-05 23:14:34 +10:00
Jones 9af3ca4516 Merge branch 'BetterSEQTA:main' into main 2025-06-05 14:08:03 +09:30
Jones8683 60b4438552 remove e 2025-06-05 14:07:29 +09:30
Jones8683 d3dadad982 Fix profile pic and add light mode styling 2025-06-05 14:07:04 +09:30
SethBurkart123 bf01c0ca7b feat: custom shortcut icons #258 2025-06-04 21:42:32 +10:00
SethBurkart123 3821034a5c feat: profile picture add border 2025-06-04 17:13:12 +10:00
SethBurkart123 c218f184c0 feat: performance improvements to profile picture plugin 2025-06-04 16:14:54 +10:00
Seth Burkart 4b67736da2 Merge pull request #291 from AdenMGB/main
Add comments to code
2025-06-04 16:12:12 +10:00
Seth Burkart 8e107800f1 Merge branch 'main' into main 2025-06-04 16:12:05 +10:00
Seth Burkart f44d28c2b8 Merge pull request #301 from Jones8683/patch-1
Update injected.scss - Add rounded corners for music lessons/tutorial…
2025-06-04 16:10:36 +10:00
Seth Burkart 058cbb0bfa Merge pull request #300 from Jones8683/main
Remove "here" button and replace with contrib.rocks graph
2025-06-04 16:10:08 +10:00
SethBurkart123 9b13e7571a feat: profile picture plugin #256 2025-06-04 16:08:01 +10:00
SethBurkart123 6c12f5cf00 feat: apply 12 hour time to timetable #280 2025-06-04 15:44:51 +10:00
Jones df385775d9 Update injected.scss - Add rounded corners for music lessons/tutorials and custom events 2025-06-04 14:26:45 +09:30
Jones dce112d129 Merge branch 'BetterSEQTA:main' into main 2025-06-04 14:12:50 +09:30
SethBurkart123 f62d712549 fix: sidebar icons revert back to old style after reload #282 2025-06-04 12:02:00 +10:00
Jones8683 148559556a realign 'buy me a cofffeee! 2025-06-04 08:30:43 +09:30
Jones8683 0c2bdf36cf Align links for parity with openaboutpage.ts 2025-06-04 08:27:53 +09:30
Jones ee002991af Update injected.scss
remove CSS for old "here" button
2025-06-04 07:43:45 +09:30
Jones8683 c53de6ed8d Adjust alignment 2025-06-03 19:11:31 +09:30
Jones8683 98560af0a3 align links properly 2025-06-03 19:10:35 +09:30
Jones8683 f9fa334e40 fix 2025-06-03 18:58:02 +09:30
Jones8683 a7e250a86d commit 2025-06-03 18:57:00 +09:30
Jones8683 b3db85c565 Neaten pfps 2025-06-03 18:42:08 +09:30
Jones8683 c9d9611e3e Re-align 'All Contributers' to left 2025-06-03 18:32:51 +09:30
Jones Jankovic a1f480855e add contributer graph to openaboutpage.ts 2025-06-03 14:51:10 +09:30
Seth Burkart 9c3c63e497 Merge pull request #293 from Jones8683/main
Restyle "here" button in openaboutpage.ts
2025-06-03 13:35:37 +10:00
Jones 418c3c010e Delete Push Procedure.txt idk why this was here it was an acciadent 2025-06-02 19:13:00 +09:30
Jones Jankovic 4c55cf4331 Change name of class selector 2025-06-02 19:10:52 +09:30
Alphons Joseph 0b93223b84 capitalise here 2025-06-02 17:00:01 +08:00
Jones 84ab19eee7 Update injected.scss - fix indents 2025-06-01 10:13:24 +09:30
Jones 94b479519a Merge branch 'BetterSEQTA:main' into main 2025-06-01 10:11:34 +09:30
Seth Burkart af68eb5534 Merge pull request #298 from Jones8683/patch-1
Update README.md - Fix indents
2025-06-01 05:22:16 +10:00
Jones 94e527c73d Merge branch 'BetterSEQTA:main' into main 2025-05-31 19:45:22 +09:30
Jones 3b9f8124cf Update README.md - Fix indents 2025-05-31 19:39:03 +09:30
Seth Burkart 4c69ba7794 Merge pull request #295 from Jones8683/patch-1
Fix numbering in instructions
2025-05-31 19:50:25 +10:00
Jones 6e43be2a18 Update README.md - Fix numbering for instructions 2025-05-30 19:49:17 +09:30
Jones 37a13cba07 Update README.md - Fix numbering for instructions 2025-05-30 19:47:31 +09:30
Jones a855fbe9ec Update README.md - Fix spelling
Somehow "preferred" was misspelled three whole times
2025-05-30 18:45:26 +09:30
Jones8683 1206fb655d Update injected.scss - add styling for openaboutpage.ts 2025-05-30 12:33:40 +09:30
Jones8683 1ae9bd0652 Add about-here-link styles to injected.scss 2025-05-30 12:28:47 +09:30
Jones 280163111e Update OpenAboutPage.ts - Move css to injected.scss 2025-05-30 12:16:53 +09:30
Jones 59e195d2aa Update OpenAboutPage.ts - Fix coloring 2025-05-30 12:10:36 +09:30
Jones 37d62cf2a8 Neaten "here" button in openaboutpage.ts 2025-05-30 12:08:11 +09:30
Jones8683 6e5c3b4733 Neaten 'here' button 2025-05-30 12:05:23 +09:30
Seth Burkart ac9761286c Merge pull request #292 from Jones8683/main
Increase chunk size limit warning so that it doesn't appear
2025-05-30 11:59:51 +10:00
Jones d7fc7582d1 Remove chunk size warning 2025-05-30 09:57:22 +09:30
Aden Lindsay 425860c7cc Merge pull request #3 from AdenMGB/add-jsdoc-comments
Add jsdoc comments
2025-05-29 22:27:08 +09:30
google-labs-jules[bot] 69ac159bad I've added JSDoc comments to various files in the lib directory.
This change introduces JSDoc-style comments to several TypeScript and JavaScript files within the `lib` directory. These files primarily consist of Vite plugins, build scripts, and type definitions.

Comments were added or improved in:
- `lib/base64loader.ts`: I documented the Vite plugin for loading files as base64 data URLs.
- `lib/createManifest.ts`: I enhanced existing comments for functions that create extension manifest objects.
- `lib/inlineWorker.ts`: I documented the Vite plugin for bundling and inlining web worker scripts.
- `lib/utils.ts`: I added comments to utility types and the `createEnum` function, including a note on its type signature vs. runtime behavior.
- `lib/closePlugin.ts`: I documented the Vite plugin for handling build completion and exiting the process.
- `lib/publish.js`: I added comments to functions within the command-line script used for publishing the extension.
- `lib/touchGlobalCSS.ts`: I documented the Vite plugin for improving HMR reliability for global CSS files.
- `lib/types.ts`: I added comments to various type definitions, interfaces, and enum-like objects related to manifests, build configurations, and supported technologies.
2025-05-29 12:55:09 +00:00
google-labs-jules[bot] 4c93bcd0d7 I've added JSDoc comments to interface hooks and utils.
This change introduces JSDoc-style comments to several TypeScript files within your `src/interface` directory to improve code understanding and maintainability, focusing on hooks and utility functions.

- `src/interface/hooks/BackgroundDataLoader.ts`: I added comments to all exported functions and the `BackgroundDB` interface, detailing IndexedDB interactions for background image storage.
- `src/interface/hooks/SettingsPopup.ts`: I documented the public methods of the `SettingsPopup` singleton, which handles event notifications for settings popup closures.
- `src/interface/utils/themeImageHandlers.ts`: I added comments to all exported functions, explaining their roles in managing images within custom themes (uploading, removing, etc.).
- `src/interface/hooks/BackgroundUpdates.ts`: I documented the `BackgroundUpdates` singleton class and its methods, used for broadcasting generic background update events.
- `src/interface/hooks/ThemeUpdates.ts`: I documented the `ThemeUpdates` singleton class and its methods, responsible for broadcasting theme-related update events.
2025-05-29 12:41:31 +00:00
Aden Lindsay c596e65449 Merge pull request #2 from AdenMGB/add-jsdoc-comments
added JSDoc comments to background, plugin core, and settings fi…
2025-05-29 21:58:45 +09:30
google-labs-jules[bot] afdbfe3190 I've added JSDoc comments to background, plugin core, and settings files.
This change introduces JSDoc-style comments to several key areas of the extension to improve your code's understanding and maintainability:

- `src/background/news.ts`: I added comments to `fetchNews`, `fetchAustraliaNews`, and `rssFeedsByCountry` to explain news fetching logic.
- `src/plugins/core/manager.ts`: I added comprehensive JSDoc comments to the `PluginManager` class and its methods, detailing its role in the plugin lifecycle.
- `src/plugins/core/createAPI.ts`: I documented `createPluginAPI` (which creates the main API for plugins) and `createSettingsAPI` (responsible for plugin settings management, initially misidentified as `createPluginSettings`).
- `src/plugins/core/settingsHelpers.ts`: I added comments to functions that define the structure of plugin settings (e.g., `numberSetting`, `stringSetting`, `defineSettings`, `Setting` decorator), clarifying their definition-time role.
2025-05-29 12:27:47 +00:00
Aden Lindsay f1137763a6 Merge pull request #1 from AdenMGB/add-jsdoc-comments
Add JSDoc comments to various utility functions and core files.
2025-05-29 21:52:12 +09:30
google-labs-jules[bot] 074e73b0fd Add JSDoc comments to various utility functions and core files.
This change adds JSDoc-style comments to several functions and classes across the codebase to improve readability and maintainability.

Comments were added to:
- `src/SEQTA.ts`: Explained the `init()` function.
- `src/seqta/utils/waitForElm.ts`: Detailed the `waitForElm()` function, its parameters, and behavior.
- `src/seqta/utils/stringToHTML.ts`: Clarified the `stringToHTML()` function, including its sanitization and styling features.
- `src/seqta/utils/delay.ts`: Added a brief explanation for the `delay()` utility.
- `src/seqta/utils/mutex.ts`: Documented the `Mutex` class and its `acquire` method (renamed from `lock`), explaining its asynchronous locking mechanism and the role of the returned unlock function.
2025-05-29 12:19:57 +00:00
Seth Burkart fc4b121d30 Merge pull request #262 from ar-cyber/patch-27
add a type to bug and fr reports
2025-05-29 13:48:17 +10:00
Seth Burkart 55c48cbe5c Merge pull request #286 from BlastedMeteor44/main
OpenAboutPages.ts update to include all contributors
2025-05-29 13:47:56 +10:00
Seth Burkart 72b18dfb7d Merge branch 'main' into main 2025-05-29 13:47:32 +10:00
Seth Burkart d0cb352e74 Merge pull request #285 from Jones8683/main
Remove un-needed scrollbar in openaboutpage.ts
2025-05-29 13:46:34 +10:00
BlastedMeteor44 8fee6ddb76 OpenAboutPages.ts update to include all contributors 2025-05-28 10:42:33 +08:00
Jones 599f20e6d0 Add oxford comma 2025-05-28 10:00:22 +09:30
Jones 07af33eb78 Update OpenAboutPage.ts - Remove scrollbar to nowhere 2025-05-28 08:33:26 +09:30
Jones f03d25f918 Update OpenAboutPage.ts - Remove scrollbar to nowhere 2025-05-28 08:30:10 +09:30
SethBurkart123 1adb18ca42 feat: cleanup and changelog for update 2025-05-28 07:41:36 +10:00
SethBurkart123 134dfcb5a2 feat: remove background migration 2025-05-26 22:46:13 +10:00
SethBurkart123 c2d701266a feat: auto lesson navigation command 2025-05-26 22:43:38 +10:00
SethBurkart123 34024d70c2 feat: performance and visual improvements 2025-05-26 21:45:17 +10:00
SethBurkart123 70a1ebf881 feat: improved calculator 2025-05-26 20:40:55 +10:00
SethBurkart123 731ce42e74 feat: improved commands and interface for globalsearch 2025-05-26 17:19:06 +10:00
SethBurkart123 2749e07a1b feat: compose message command 2025-05-26 13:17:57 +10:00
SethBurkart123 0bed8b875b feat: visual improvements to search 2025-05-26 12:27:42 +10:00
SethBurkart123 35c005f347 fix: search button in incorrect placement 2025-05-26 12:05:34 +10:00
SethBurkart123 a251827c4b fix: themes always locking after reload 2025-05-26 11:52:59 +10:00
SethBurkart123 854c6ea826 fix: indexer saving infinite items, other improvements 2025-05-25 22:28:40 +10:00
SethBurkart123 cefeac95ea feat: major indexing performance improvements + visual fixes 2025-05-25 20:11:45 +10:00
SethBurkart123 e09eeccfee style: visual tweaks to settings page 2025-05-25 18:30:37 +10:00
SethBurkart123 991f80d316 feat: improved hotkey support and controls 2025-05-25 18:15:06 +10:00
SethBurkart123 f66340cb63 feat: add beta tag to global search plugin 2025-05-25 10:21:24 +10:00
SethBurkart123 fc288bdf01 fix: incorrect imports 2025-05-25 10:15:24 +10:00
SethBurkart123 244e667d90 fix: themes always forcing current mode 2025-05-23 12:34:17 +10:00
SethBurkart123 8adba647d8 feat: use web transitions api for themes 2025-05-23 12:30:43 +10:00
SethBurkart123 da3e11e208 fix: requiring reload on install to function 2025-05-22 23:15:19 +10:00
SethBurkart123 843a0a4c7a style: visual tweaks to courses page 2025-05-22 21:52:41 +10:00
SethBurkart123 b339745697 feat: beautiful modern visual tweaks 2025-05-22 19:06:39 +10:00
SethBurkart123 efdd03ce8e feat: make message items in search open the message 2025-05-22 14:49:13 +10:00
SethBurkart123 6846d945f2 fix: adjust boosting scores to work better 2025-05-21 10:18:16 +10:00
SethBurkart123 bff48f0397 feat: boosted active subjects + visual indication of inactive subjects 2025-05-21 00:01:36 +10:00
SethBurkart123 d8512e44cf feat: button item + search storage reset 2025-05-20 21:03:55 +10:00
SethBurkart123 25623339f8 feat: safer text highlighting 2025-05-20 20:43:16 +10:00
Seth Burkart 281842ea48 Merge pull request #274 from NIDNHU/patch-8
Update README.md - remove random dot point and update credits
2025-05-14 09:37:07 +10:00
SethBurkart123 eaf8ec51cd fix: incorrect usage and cleanup 2025-05-14 09:32:24 +10:00
Seth Burkart 65921845ec Merge pull request #264 from AdenMGB/main
Subject Searching in Global Search
2025-05-14 09:15:31 +10:00
StroepWafel 68d7861afa Update README.md - remove random dot point and update credits 2025-05-13 13:42:58 +09:30
Seth Burkart 2dcb6db3b5 Merge pull request #268 from NIDNHU/patch-7
Update OpenAboutPage.ts - fix grammar + adjust credits
2025-05-12 12:10:46 +10:00
StroepWafel 50b9218224 Update OpenAboutPage.ts 2025-05-11 19:01:32 +09:30
StroepWafel a4d2743f4c Update OpenAboutPage.ts - fix grammar + adjust credits 2025-05-11 14:24:27 +09:30
Seth Burkart 53074d5534 Merge pull request #267 from NIDNHU/patch-5
Update OpenAboutPage.ts - add code contribution welcome
2025-05-11 14:33:40 +10:00
StroepWafel 64ac9019a3 Update OpenAboutPage.ts - add code contribution welcome
stated that we are always looking for more contributors
2025-05-11 14:00:06 +09:30
AdenMGB 27f357cc82 fix(sorting): re-work sorting system to use api response more effectivly, and some styling improvements 2025-05-11 13:27:58 +09:30
codefactor-io ed767131ad [CodeFactor] Apply fixes 2025-05-09 10:53:51 +00:00
AdenMGB 7499880d9d fix(packages): remove old unused package 2025-05-09 20:18:18 +09:30
AdenMGB 908bf8c759 feat(globalSearch): subject course & assesment indexing and searchabilty 2025-05-09 20:12:22 +09:30
SethBurkart123 297c30dc98 fix: shortcuts relying on displayname not being removed 2025-05-09 10:11:27 +10:00
SethBurkart123 f4711ae3d4 style: fixed viewbox for shortcut links 2025-05-09 10:04:46 +10:00
Seth Burkart 97d3098fa3 Merge pull request #260 from NIDNHU/main
Update links.json - add more sites
2025-05-09 09:51:42 +10:00
StroepWafel 6ffacc83a7 Update AddShortcuts.ts - use DisplayName
made use DisplayName for name with fallback if value is null, empty or invalid
2025-05-08 21:52:09 +09:30
StroepWafel fa41542ec6 Update shortcuts.svelte - use "DisplayName" for displayname
made code use "DisplayName" in object for display name instead of object name, includes fallback in case the object is missing displayname
2025-05-08 21:32:59 +09:30
StroepWafel 7a19074c4f Update links.json - fix referencer viewbox 2025-05-08 21:25:00 +09:30
Alphons Joseph ad93a2eb54 fix Aesthetic bug in sidebar of BetterSEQTA+ #251 2025-05-08 19:41:21 +08:00
StroepWafel b8bc54f967 Update links.json - remove social medias 2025-05-08 08:56:13 +09:30
StroepWafel 11c30226f0 Update links.json - fix names and add displayname field 2025-05-08 08:34:50 +09:30
StroepWafel e0e4ba65c7 Update links.json - fix viewBoxes and names 2025-05-07 21:36:38 +09:30
StroepWafel 2c9d24355e Merge pull request #1 from BetterSEQTA/main
Merge changes for testing PR
2025-05-07 20:40:54 +09:30
SethBurkart123 c206e38ee2 fix: change shortcuts to rely on links list 2025-05-07 21:03:46 +10:00
SethBurkart123 87bf526dc6 feat: update job title 2025-05-07 20:28:39 +10:00
Andrew R 8f7a9b655a Update feature_request.yml 2025-05-07 19:55:48 +09:30
Andrew R 899ba46995 Update feature_request.yml 2025-05-07 19:55:02 +09:30
Andrew R ccb465cc2d add a type to bug and fr reports 2025-05-07 19:54:18 +09:30
StroepWafel 49b9428fbb Update links.json 2025-05-07 13:49:25 +09:30
StroepWafel 6d904ff6f9 Update links.json 2025-05-07 13:37:28 +09:30
StroepWafel 14424b167e Update links.json 2025-05-07 12:15:29 +09:30
StroepWafel da68d9628d Update links.json - add more sites
Added: 
- Deezer
- Google Classroom
- Reddit
- Instagram
- Snapchat
- Harvard referencing Generator
2025-05-07 12:00:57 +09:30
SethBurkart123 79ed998edf style: fix light mode gradients 2025-05-05 23:00:56 +10:00
SethBurkart123 364a5c2f22 style: major interface improvements 2025-05-05 22:58:15 +10:00
SethBurkart123 eeb63b5d1a feat: improved search results 2025-05-05 21:56:50 +10:00
SethBurkart123 9aef4c7204 style: remove "Custom Shortcut" overlay text 2025-05-05 20:31:23 +10:00
SethBurkart123 91035172d2 feat: visual improvements 2025-05-05 20:03:57 +10:00
SethBurkart123 d3d9b45caa feat: forums + improvements 2025-05-05 19:49:19 +10:00
SethBurkart123 0f9f618164 format: run prettify 2025-05-05 18:04:10 +10:00
SethBurkart123 771169348f feat: supporting improved assessments and improved parsing 2025-05-05 17:58:40 +10:00
SethBurkart123 ec42f1bb27 feat: improved job indexing 2025-05-05 17:09:44 +10:00
Seth Burkart cd247cfde4 Merge pull request #259 from NIDNHU/patch-4
fixed a couple of wording issues in FEATURE_REQUEST.yml
2025-05-05 11:08:57 +10:00
StroepWafel 44bf8efd71 fixed a couple of wording issues in FEATURE_REQUEST.yml
some wording made no sense so I fixed it
2025-05-05 09:47:12 +09:30
SethBurkart123 955213d577 fix: indexer not saving vectorized items properly 2025-05-04 12:01:03 +10:00
SethBurkart123 40924b5b33 fix: initial load not loading betterseqta 2025-05-04 07:22:24 +10:00
SethBurkart123 69b6b116a0 feat: update crxjs and remove extra included files 2025-05-04 07:20:45 +10:00
SethBurkart123 63a4bd4211 feat: refresh vector cache on complete 2025-05-03 20:40:39 +10:00
SethBurkart123 6ac54eae4b feat: add default enabled toggle 2025-05-03 20:17:53 +10:00
SethBurkart123 c791998b30 feat: migrate to embeddia (from local dir) 2025-05-03 19:27:18 +10:00
Seth Burkart 8a5100c06f Merge branch 'global-search' into main 2025-05-03 18:58:33 +10:00
Alphons Joseph cf2778951f feat: old scrolling sidebar system 2025-05-03 18:57:31 +10:00
StroepWafel 6fa1af2f68 Update feature_request.yml 2025-05-03 18:57:31 +10:00
StroepWafel b8286b6f22 fix validations indentation 2025-05-03 18:57:31 +10:00
StroepWafel 8466ef7691 fix feature_request.yml 2025-05-03 18:57:31 +10:00
StroepWafel d75959eeb1 fix feature_request.yml 2025-05-03 18:57:31 +10:00
StroepWafel 94a2f4ac34 Update and retype feature_request.md to feature_request.yml 2025-05-03 18:57:31 +10:00
StroepWafel 1647870186 Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel b332de52ff Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel daf7ea8e83 Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel 341087b6a0 Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel a0d8e05fd0 Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel 399f68c547 Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel 3ddf1d0c4f Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel 10a6c458b1 Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel 33825843b7 Update and retyped bug_report.md to bug_report.yml 2025-05-03 18:57:31 +10:00
SethBurkart123 56dabc8fd5 fix: news not loading 2025-05-03 18:57:31 +10:00
SethBurkart123 0c1a71f398 fix: sidebar layout not being applied on pageload #249 2025-05-03 18:56:50 +10:00
SethBurkart123 bb6ee72159 bump(version): 3.4.6.1 + changelog 2025-05-03 18:56:50 +10:00
SethBurkart123 d52a59ae48 fix: storage always resetting to default 2025-05-03 18:56:50 +10:00
SethBurkart123 1c9e361f78 fix: builds running incorrectly 2025-05-03 18:56:50 +10:00
SethBurkart123 ec3396c52e fix: dynamic seqta classes failing to load #248 2025-05-03 18:56:50 +10:00
SethBurkart123 5b5dba69dc fix: handle failed sortmessagepageitems 2025-05-03 18:56:50 +10:00
Andrew R 449b54ae32 Update README.md 2025-05-03 18:56:19 +10:00
codefactor-io c29dc45697 [CodeFactor] Apply fixes to commit d412762 2025-05-01 11:34:34 +00:00
SethBurkart123 d4127626b1 feat: cleaned code + improved performance 2025-05-01 21:34:17 +10:00
Alphons Joseph 907f970018 feat: old scrolling sidebar system 2025-04-28 21:32:08 +08:00
codefactor-io d9b1482255 [CodeFactor] Apply fixes to commit 454ab28 2025-04-13 00:58:22 +00:00
SethBurkart123 454ab283ab feat: improve results and performance (syncronous) 2025-04-13 10:58:05 +10:00
Seth Burkart 0ef43eb9b5 Merge pull request #254 from NIDNHU/main
complete revamp of bug report system
2025-04-13 09:06:55 +10:00
StroepWafel ecbdffbbde Update feature_request.yml 2025-04-13 00:05:07 +09:30
StroepWafel 92344400e1 fix validations indentation 2025-04-13 00:04:34 +09:30
StroepWafel ca20ba4e07 fix feature_request.yml 2025-04-13 00:02:15 +09:30
StroepWafel 694d4ea0a1 fix feature_request.yml 2025-04-13 00:01:35 +09:30
StroepWafel 72a529ee1d Update and retype feature_request.md to feature_request.yml 2025-04-13 00:00:31 +09:30
StroepWafel 0a3ee5c666 Update bug_report.yml 2025-04-12 23:50:50 +09:30
StroepWafel ef6176b6a4 Update bug_report.yml 2025-04-12 23:50:32 +09:30
StroepWafel b3c395cca1 Update bug_report.yml 2025-04-12 23:47:13 +09:30
StroepWafel 8c2539f130 Update bug_report.yml 2025-04-12 23:46:44 +09:30
StroepWafel 442ea04a2f Update bug_report.yml 2025-04-12 23:45:57 +09:30
StroepWafel bd812ffdae Update bug_report.yml 2025-04-12 23:45:28 +09:30
StroepWafel 6377a0c909 Update bug_report.yml 2025-04-12 23:44:57 +09:30
StroepWafel d8829d5716 Update bug_report.yml 2025-04-12 23:43:21 +09:30
StroepWafel 7fd85a5529 Update and retyped bug_report.md to bug_report.yml 2025-04-12 23:42:25 +09:30
SethBurkart123 9562368157 feat: vector search worker improvements 2025-04-11 07:10:55 +10:00
codefactor-io ab867af57d [CodeFactor] Apply fixes to commit 886d0a9 2025-04-10 14:07:58 +00:00
SethBurkart123 886d0a95f1 feat: add working workers with builds 2025-04-11 00:07:29 +10:00
SethBurkart123 dd47deb954 fix: news not loading 2025-04-09 14:46:57 +10:00
SethBurkart123 fbf066cea8 fix: sidebar layout not being applied on pageload #249 2025-04-06 14:03:51 +10:00
SethBurkart123 eb2c665843 bump(version): 3.4.6.1 + changelog 2025-04-03 22:28:50 +11:00
SethBurkart123 45a16de405 fix: storage always resetting to default 2025-04-03 22:24:25 +11:00
codefactor-io 814647e835 [CodeFactor] Apply fixes 2025-04-01 12:16:13 +00:00
SethBurkart123 07aa9524aa feat: early vector search testing 2025-04-01 23:14:45 +11:00
SethBurkart123 13f830ee16 feat: add custom items 2025-04-01 22:01:18 +11:00
SethBurkart123 1b4708261d feat: improve scrolling with calculator 2025-04-01 19:25:27 +11:00
SethBurkart123 6a556b6940 feat: further interface tweaks 2025-04-01 18:20:17 +11:00
codefactor-io d0edad8134 [CodeFactor] Apply fixes 2025-04-01 06:46:45 +00:00
SethBurkart123 5e93ae6e4b feat: add bottom bar 2025-04-01 17:45:30 +11:00
SethBurkart123 0788b78e73 feat: improve units 2025-04-01 17:24:18 +11:00
SethBurkart123 e884b0526b feat: improve searchbar 2025-04-01 16:02:54 +11:00
SethBurkart123 ea77224c75 feat: add calculator 2025-04-01 15:27:46 +11:00
SethBurkart123 18441712c9 feat: complete fuzzy search rebuild 2025-04-01 13:51:45 +11:00
SethBurkart123 8df138a374 feat: upgrade to flexsearch for better keyword search performance 2025-03-31 23:11:41 +11:00
SethBurkart123 068e4ab778 style: further UI tweaks 2025-03-31 23:06:56 +11:00
SethBurkart123 adbba730c4 style: search styling improvements 2025-03-31 23:03:45 +11:00
SethBurkart123 1f3354c47b feat: add global search UI 2025-03-31 22:54:03 +11:00
202 changed files with 39862 additions and 21117 deletions
+94 -111
View File
@@ -2,87 +2,83 @@
module.exports = {
forbidden: [
{
name: 'no-circular',
severity: 'warn',
name: "no-circular",
severity: "warn",
comment:
'This dependency is part of a circular relationship. You might want to revise ' +
'your solution (i.e. use dependency inversion, make sure the modules have a single responsibility) ',
"This dependency is part of a circular relationship. You might want to revise " +
"your solution (i.e. use dependency inversion, make sure the modules have a single responsibility) ",
from: {},
to: {
circular: true
}
circular: true,
},
},
{
name: 'no-orphans',
name: "no-orphans",
comment:
"This is an orphan module - it's likely not used (anymore?). Either use it or " +
"remove it. If it's logical this module is an orphan (i.e. it's a config file), " +
"add an exception for it in your dependency-cruiser configuration. By default " +
"this rule does not scrutinize dot-files (e.g. .eslintrc.js), TypeScript declaration " +
"files (.d.ts), tsconfig.json and some of the babel and webpack configs.",
severity: 'warn',
severity: "warn",
from: {
orphan: true,
pathNot: [
'(^|/)[.][^/]+[.](?:js|cjs|mjs|ts|cts|mts|json)$', // dot files
'[.]d[.]ts$', // TypeScript declaration files
'(^|/)tsconfig[.]json$', // TypeScript config
'(^|/)(?:babel|webpack)[.]config[.](?:js|cjs|mjs|ts|cts|mts|json)$' // other configs
]
"(^|/)[.][^/]+[.](?:js|cjs|mjs|ts|cts|mts|json)$", // dot files
"[.]d[.]ts$", // TypeScript declaration files
"(^|/)tsconfig[.]json$", // TypeScript config
"(^|/)(?:babel|webpack)[.]config[.](?:js|cjs|mjs|ts|cts|mts|json)$", // other configs
],
},
to: {},
},
{
name: 'no-deprecated-core',
name: "no-deprecated-core",
comment:
'A module depends on a node core module that has been deprecated. Find an alternative - these are ' +
"A module depends on a node core module that has been deprecated. Find an alternative - these are " +
"bound to exist - node doesn't deprecate lightly.",
severity: 'warn',
severity: "warn",
from: {},
to: {
dependencyTypes: [
'core'
],
dependencyTypes: ["core"],
path: [
'^v8/tools/codemap$',
'^v8/tools/consarray$',
'^v8/tools/csvparser$',
'^v8/tools/logreader$',
'^v8/tools/profile_view$',
'^v8/tools/profile$',
'^v8/tools/SourceMap$',
'^v8/tools/splaytree$',
'^v8/tools/tickprocessor-driver$',
'^v8/tools/tickprocessor$',
'^node-inspect/lib/_inspect$',
'^node-inspect/lib/internal/inspect_client$',
'^node-inspect/lib/internal/inspect_repl$',
'^async_hooks$',
'^punycode$',
'^domain$',
'^constants$',
'^sys$',
'^_linklist$',
'^_stream_wrap$'
"^v8/tools/codemap$",
"^v8/tools/consarray$",
"^v8/tools/csvparser$",
"^v8/tools/logreader$",
"^v8/tools/profile_view$",
"^v8/tools/profile$",
"^v8/tools/SourceMap$",
"^v8/tools/splaytree$",
"^v8/tools/tickprocessor-driver$",
"^v8/tools/tickprocessor$",
"^node-inspect/lib/_inspect$",
"^node-inspect/lib/internal/inspect_client$",
"^node-inspect/lib/internal/inspect_repl$",
"^async_hooks$",
"^punycode$",
"^domain$",
"^constants$",
"^sys$",
"^_linklist$",
"^_stream_wrap$",
],
}
},
},
{
name: 'not-to-deprecated',
name: "not-to-deprecated",
comment:
'This module uses a (version of an) npm module that has been deprecated. Either upgrade to a later ' +
'version of that module, or find an alternative. Deprecated modules are a security risk.',
severity: 'warn',
"This module uses a (version of an) npm module that has been deprecated. Either upgrade to a later " +
"version of that module, or find an alternative. Deprecated modules are a security risk.",
severity: "warn",
from: {},
to: {
dependencyTypes: [
'deprecated'
]
}
dependencyTypes: ["deprecated"],
},
},
{
name: 'no-non-package-json',
severity: 'error',
name: "no-non-package-json",
severity: "error",
comment:
"This module depends on an npm package that isn't in the 'dependencies' section of your package.json. " +
"That's problematic as the package either (1) won't be available on live (2 - worse) will be " +
@@ -90,84 +86,75 @@ module.exports = {
"in your package.json.",
from: {},
to: {
dependencyTypes: [
'npm-no-pkg',
'npm-unknown'
]
}
dependencyTypes: ["npm-no-pkg", "npm-unknown"],
},
},
{
name: 'not-to-unresolvable',
name: "not-to-unresolvable",
comment:
"This module depends on a module that cannot be found ('resolved to disk'). If it's an npm " +
'module: add it to your package.json. In all other cases you likely already know what to do.',
severity: 'error',
"module: add it to your package.json. In all other cases you likely already know what to do.",
severity: "error",
from: {},
to: {
couldNotResolve: true
}
couldNotResolve: true,
},
},
{
name: 'no-duplicate-dep-types',
name: "no-duplicate-dep-types",
comment:
"Likely this module depends on an external ('npm') package that occurs more than once " +
"in your package.json i.e. bot as a devDependencies and in dependencies. This will cause " +
"maintenance problems later on.",
severity: 'warn',
severity: "warn",
from: {},
to: {
moreThanOneDependencyType: true,
// as it's pretty common to have a type import be a type only import
// _and_ (e.g.) a devDependency - don't consider type-only dependency
// types for this rule
dependencyTypesNot: ["type-only"]
}
dependencyTypesNot: ["type-only"],
},
},
/* rules you might want to tweak for your specific situation: */
{
name: 'not-to-spec',
name: "not-to-spec",
comment:
'This module depends on a spec (test) file. The sole responsibility of a spec file is to test code. ' +
"This module depends on a spec (test) file. The sole responsibility of a spec file is to test code. " +
"If there's something in a spec that's of use to other modules, it doesn't have that single " +
'responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.',
severity: 'error',
"responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.",
severity: "error",
from: {},
to: {
path: '[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$'
}
path: "[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$",
},
},
{
name: 'not-to-dev-dep',
severity: 'error',
name: "not-to-dev-dep",
severity: "error",
comment:
"This module depends on an npm package from the 'devDependencies' section of your " +
'package.json. It looks like something that ships to production, though. To prevent problems ' +
"package.json. It looks like something that ships to production, though. To prevent problems " +
"with npm packages that aren't there on production declare it (only!) in the 'dependencies'" +
'section of your package.json. If this module is development only - add it to the ' +
'from.pathNot re of the not-to-dev-dep rule in the dependency-cruiser configuration',
"section of your package.json. If this module is development only - add it to the " +
"from.pathNot re of the not-to-dev-dep rule in the dependency-cruiser configuration",
from: {
path: '^(src)',
pathNot: '[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$'
path: "^(src)",
pathNot: "[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$",
},
to: {
dependencyTypes: [
'npm-dev',
],
dependencyTypes: ["npm-dev"],
// type only dependencies are not a problem as they don't end up in the
// production code or are ignored by the runtime.
dependencyTypesNot: [
'type-only'
],
pathNot: [
'node_modules/@types/'
]
}
dependencyTypesNot: ["type-only"],
pathNot: ["node_modules/@types/"],
},
},
{
name: 'optional-deps-used',
severity: 'info',
name: "optional-deps-used",
severity: "info",
comment:
"This module depends on an npm package that is declared as an optional dependency " +
"in your package.json. As this makes sense in limited situations only, it's flagged here. " +
@@ -175,33 +162,28 @@ module.exports = {
"dependency-cruiser configuration.",
from: {},
to: {
dependencyTypes: [
'npm-optional'
]
}
dependencyTypes: ["npm-optional"],
},
},
{
name: 'peer-deps-used',
name: "peer-deps-used",
comment:
"This module depends on an npm package that is declared as a peer dependency " +
"in your package.json. This makes sense if your package is e.g. a plugin, but in " +
"other cases - maybe not so much. If the use of a peer dependency is intentional " +
"add an exception to your dependency-cruiser configuration.",
severity: 'warn',
severity: "warn",
from: {},
to: {
dependencyTypes: [
'npm-peer'
]
}
}
dependencyTypes: ["npm-peer"],
},
},
],
options: {
/* Which modules not to follow further when encountered */
doNotFollow: {
/* path: an array of regular expressions in strings to match against */
path: ['node_modules']
path: ["node_modules"],
},
/* Which modules to exclude */
@@ -274,7 +256,7 @@ module.exports = {
defaults to './tsconfig.json'.
*/
tsConfig: {
fileName: 'tsconfig.json'
fileName: "tsconfig.json",
},
/* Webpack configuration to use to get resolve options from.
@@ -364,8 +346,8 @@ module.exports = {
"bun:wrap",
"detect-libc",
"undici",
"ws"
]
"ws",
],
},
reporterOptions: {
@@ -375,7 +357,7 @@ module.exports = {
collapses everything in node_modules to one folder deep so you see
the external modules, but their innards.
*/
collapsePattern: 'node_modules/(?:@[^/]+/[^/]+|[^/]+)',
collapsePattern: "node_modules/(?:@[^/]+/[^/]+|[^/]+)",
/* Options to tweak the appearance of your graph.See
https://github.com/sverweij/dependency-cruiser/blob/main/doc/options-reference.md#reporteroptions
@@ -397,7 +379,8 @@ module.exports = {
dependency graph reporter (`archi`) you probably want to tweak
this collapsePattern to your situation.
*/
collapsePattern: '^(?:packages|src|lib(s?)|app(s?)|bin|test(s?)|spec(s?))/[^/]+|node_modules/(?:@[^/]+/[^/]+|[^/]+)',
collapsePattern:
"^(?:packages|src|lib(s?)|app(s?)|bin|test(s?)|spec(s?))/[^/]+|node_modules/(?:@[^/]+/[^/]+|[^/]+)",
/* Options to tweak the appearance of your graph. If you don't specify a
theme for 'archi' dependency-cruiser will use the one specified in the
@@ -405,10 +388,10 @@ module.exports = {
*/
// theme: { },
},
"text": {
"highlightFocused": true
text: {
highlightFocused: true,
},
},
},
}
}
};
// generated: dependency-cruiser@16.10.0 on 2025-02-16T22:32:01.621Z
+5 -2
View File
@@ -12,12 +12,15 @@
},
"rules": {
// allow importing ts extensions
"sort-imports": ["error", {
"sort-imports": [
"error",
{
"ignoreCase": true,
"ignoreDeclarationSort": true,
"ignoreMemberSort": false,
"memberSyntaxSortOrder": ["none", "all", "multiple", "single"]
}],
}
],
"import/extensions": [
"error",
"ignorePackages",
-23
View File
@@ -1,23 +0,0 @@
---
name: Report A bug.
about: Create a report of a present bug.
title: "[BUG]"
labels: bug
assignees: ''
---
**Bug Description**
Please provide a clear and concise description of the bug.
**Steps to Reproduce**
Please list the actions that caused the issue.
**Expected Behavior**
Please describe how you think the program should have behaved, making sure to be as clear and concise as possible.
**Screenshots**
If applicable, please provide screenshots. This will help us to reproduce the issue and better understand what we are looking for.
**Additional Context**
Feel free to provide any additional, applicable context or information that is relevant to the problem.
+57
View File
@@ -0,0 +1,57 @@
name: Bug report
description: Report an issue with the modpack in its unmodified state. For other issues, use Discord.
labels: bug
title: "[BUG]"
type: "Bug"
body:
- type: markdown
attributes:
value: |
Before reporting an issue, [please search](https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues) to make sure it has not already been reported (make sure to search closed issues as well!).
- type: textarea
attributes:
label: Describe the bug
description: Describe your issue. For general issues and questions you'll get a faster answer [from our Discord.](https://discord.gg/YzmbnCDkat)
validations:
required: true
- type: input
attributes:
label: Extension version
description: What version of the extension are you using?
placeholder: Find it by opening the config menu and clicking the about icon in the top right.
validations:
required: true
- type: dropdown
attributes:
label: Browser
description: Which Browser are you using?
options:
- Chrome
- Firefox
- Brave
- Safari
- DuckDuckGO
- Microsoft Edge
- Other Chromium-Based Browser
- Other Non-Chromium-Based Browser
validations:
required: true
- type: checkboxes
attributes:
label: Confirm
options:
- label: This bug report is about an issue with the extension itself. I have not modified the extension nor added any unsupported plugins. If this is not the case, I know that I should post the issue to the extension's Discord support channel instead.
required: true
- type: textarea
attributes:
label: Additional context
description: Screenshots, video or any other information. Include photos of the console if possible
placeholder: |
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false
-14
View File
@@ -1,14 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FR] "
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? if so, Please describe the issue or link to a pre-existing bug report**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen. Provide reference art/pictures if poccible
@@ -0,0 +1,52 @@
name: Feature request
description: Suggest a new Feature to be added or replaced in BetterSeqtaPLUS
labels: enhancement
title: "[FR]"
body:
- type: checkboxes
attributes:
label: Confirm
options:
- label: "Is this feature request related to a Bug report?"
required: false
- type: input
attributes:
label: Bug report link
description: "If this feature request is related to a bug report, please insert the link to the bug report here"
placeholder: "https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues/..."
validations:
required: false
- type: markdown
attributes:
value: |
## Feature details
Before you request a feature, [please search](https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues) if it has already been requested. (Make sure to check closed issues as well!)
- type: dropdown
attributes:
label: Feature type
multiple: false
options:
- Graphical
- Functional
- Not Sure
validations:
required: true
- type: input
attributes:
label: Feature Details
description: Please write, with as much detail as possible, what you would like to see from this feature.
placeholder: it would be cool if
validations:
required: false
- type: textarea
attributes:
label: Additional details
description: Anything else that would help describe your vision (reference images, descriptions, etc)
validations:
required: false
+2 -2
View File
@@ -2,9 +2,9 @@ name: NodeJS Build
on:
push:
branches: [ "main" ]
branches: ["main"]
pull_request:
branches: [ "main" ]
branches: ["main"]
jobs:
build:
+1 -1
View File
@@ -1,5 +1,5 @@
{
"tabWidth": 2,
"useTabs": false,
"semi": false
"semi": true
}
+10 -10
View File
@@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
+15 -16
View File
@@ -1,4 +1,3 @@
#
<a href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel">
@@ -59,15 +58,13 @@ Don't worry- if you get stuck feel free to ask around in the [discord](https://d
## Getting started
1. Clone the repository
&nbsp;&nbsp;&nbsp; **1. Clone the repository**
```
git clone https://github.com/BetterSEQTA/BetterSEQTA-Plus
```
1. Install dependencies
&nbsp;&nbsp;&nbsp; **2. Install dependencies**
You may install the dependencies like below:
@@ -80,29 +77,30 @@ But it is recommended to do it like this:
```
npm install --legacy-peer-deps # Only NPM supported
```
### Running Development
2. Run the dev script (it updates as you save files)
&nbsp;&nbsp;&nbsp; **3. Run the dev script (it updates as you save files)**
```
npm run dev # or use your perferred package manager
npm run dev # or use your preferred package manager
```
### Building for production
2. Run the build script
&nbsp;&nbsp;&nbsp; **4. Run the build script**
```
npm run build # or use your perferred package manager
npm run build # or use your preferred package manager
```
2.1. Package it up (optional)
&nbsp;&nbsp;&nbsp; **4.1. Package it up (optional)**
```
npm run zip # This REQUIRES 7-Zip to be installed in order to work. You can also use your perferred package manager
npm run zip # This REQUIRES 7-Zip to be installed in order to work. You can also use your preferred package manager
```
3. Load the extension into chrome
&nbsp;&nbsp;&nbsp; **5. Load the extension into chrome**
- Go to `chrome://extensions`
- Enable developer mode
@@ -116,7 +114,7 @@ Just remember, in order to update changes to the extension if you are running in
The folder structure is as follows:
- The `src` folder contains source files that are compiled to the build directory.
-
- The `src/plugins` folder contains vital loaders required for BetterSEQTA+ functionality.
- The `src/interface` folder contains source React & Svelte files that are required for the Settings page.
@@ -130,9 +128,10 @@ The folder structure is as follows:
</a>
Want to contribute? [Click Here!](https://github.com/BetterSEQTA/BetterSEQTA-Plus/blob/main/CONTRIBUTING.md)
## Credits
This extension was initially developed by [Nulkem](https://github.com/Nulkem/betterseqta), was ported to manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) and is currently under active development by [SethBurkart123](https://github.com/SethBurkart123) and [Crazypersonalph](https://github.com/Crazypersonalph)
This extension was initially developed by [Nulkem](https://github.com/Nulkem/betterseqta), was ported to manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) and is currently under active development from lead developers [SethBurkart123](https://github.com/SethBurkart123) and [Crazypersonalph](https://github.com/Crazypersonalph) with help from other volunteers
## Star History
+2 -1
View File
@@ -5,11 +5,12 @@
Below here is the supported versions of BetterSEQTA+. Anything older than this is not supported and contains bugs.
| Version | Supported |
| ------- | ------------------ |
| ------- | --------- |
| 3.4.3 | ✅ |
| < 3.4.3 | :x: |
`*` May not work on other devices.
## Reporting a Vulnerability
If you find vulnerabilities, REPORT IT IMMEDIATELY. open the [advisories tab](https://github.com/BetterSEQTA/BetterSEQTA-Plus/security/advisories) on the left and click the green "report a vulnerability" button or use [this quick-link](https://github.com/BetterSEQTA/BetterSEQTA-Plus/security/advisories/new) to create a new report
+2
View File
@@ -7,11 +7,13 @@ Welcome to the BetterSEQTA+ documentation! This documentation will help you unde
## Table of Contents
### Getting Started
- [Project Overview](./README.md) - This file
- [Installation Guide](./installation.md) - How to install and set up BetterSEQTA+
- [Contributing Guide](../CONTRIBUTING.md) - How to contribute to BetterSEQTA+
### Plugin System
- [Creating Your First Plugin](./plugins/README.md) - A comprehensive, beginner-friendly guide to creating plugins
- [Plugin API Reference](./plugins/api-reference.md) - Detailed technical documentation of the plugin APIs
+6
View File
@@ -22,6 +22,7 @@ Thank you for your interest in contributing to BetterSEQTA+! This document provi
BetterSEQTA+ is committed to providing a welcoming and inclusive environment for all contributors. We expect all participants to adhere to our Code of Conduct, which promotes respectful and harassment-free interaction.
Key points:
- Be respectful and inclusive
- Focus on what is best for the community
- Show empathy towards other community members
@@ -105,6 +106,7 @@ git checkout -b feature/my-new-feature
2. **Write Clear Commit Messages**
Follow the conventional commits format:
```
feat: add new feature
fix: resolve bug with timetable
@@ -118,6 +120,7 @@ git checkout -b feature/my-new-feature
4. **Run Tests**
Make sure all tests pass before submitting your PR:
```bash
npm test
```
@@ -157,6 +160,7 @@ We follow TypeScript best practices and have a consistent code style:
5. **Use Linters**
We use ESLint and Prettier. Run them before submitting your PR:
```bash
npm run lint
npm run format
@@ -173,6 +177,7 @@ If you find a bug, please report it by creating an issue on GitHub:
2. **Use the Bug Report Template**
Fill in all sections of the bug report template:
- Description
- Steps to reproduce
- Expected behavior
@@ -195,6 +200,7 @@ We welcome feature suggestions! To suggest a new feature:
2. **Use the Feature Request Template**
Fill in all sections of the feature request template:
- Description
- Use case
- Potential implementation
+2
View File
@@ -132,6 +132,7 @@ bun install
#### Extension not appearing in SEQTA
Make sure:
- You're visiting a SEQTA Learn page
- The extension is enabled
- You've refreshed the page after installing the extension
@@ -139,6 +140,7 @@ Make sure:
#### Development build not updating
Try:
1. Stopping the development server
2. Clearing your browser cache
3. Removing the extension from your browser
+75 -35
View File
@@ -5,6 +5,7 @@ Hey there! 👋 So you want to create a plugin for BetterSEQTA+? That's awesome!
## What is a Plugin?
In BetterSEQTA+, a plugin is like a mini-app that adds new features to SEQTA. Think of it as a piece of LEGO that you can snap onto SEQTA to make it do new things. For example, you could create a plugin that:
- Changes how SEQTA looks
- Adds new buttons or features
- Shows extra information on your timetable
@@ -16,29 +17,32 @@ In BetterSEQTA+, a plugin is like a mini-app that adds new features to SEQTA. Th
Let's create a super simple plugin together. We'll make one that adds a friendly message to the SEQTA homepage. Here's what we'll need:
```typescript
import type { Plugin } from '@/plugins/core/types';
import type { Plugin } from "@/plugins/core/types";
const myFirstPlugin: Plugin = {
// Every plugin needs these basic details
id: 'my-first-plugin',
name: 'My First Plugin',
description: 'Adds a friendly message to SEQTA',
version: '1.0.0',
id: "my-first-plugin",
name: "My First Plugin",
description: "Adds a friendly message to SEQTA",
version: "1.0.0",
// This tells BetterSEQTA+ that users can turn our plugin on/off
disableToggle: true,
// Optional: Mark your plugin as beta to show a "Beta" tag in settings
beta: true,
// This is where the magic happens!
run: async (api) => {
// Wait for the homepage to load
api.seqta.onMount('.home-page', (homePage) => {
api.seqta.onMount(".home-page", (homePage) => {
// Create our message
const message = document.createElement('div');
message.textContent = 'Hello from my first plugin! 🎉';
message.style.padding = '20px';
message.style.backgroundColor = '#e9f5ff';
message.style.borderRadius = '8px';
message.style.margin = '20px';
const message = document.createElement("div");
message.textContent = "Hello from my first plugin! 🎉";
message.style.padding = "20px";
message.style.backgroundColor = "#e9f5ff";
message.style.borderRadius = "8px";
message.style.margin = "20px";
// Add it to the page
homePage.prepend(message);
@@ -46,10 +50,10 @@ const myFirstPlugin: Plugin = {
// Return a cleanup function that removes our message when the plugin is disabled
return () => {
const message = document.querySelector('.home-page > div');
const message = document.querySelector(".home-page > div");
message?.remove();
};
}
},
};
export default myFirstPlugin;
@@ -64,10 +68,11 @@ Let's break down what's happening here:
- `description`: Explain what your plugin does
- `version`: Your plugin's version number
3. We set `disableToggle: true` so users can turn our plugin on/off in settings
4. The `run` function is where we put our plugin's code
5. We use `api.seqta.onMount` to wait for the homepage to load
6. We create and style a message element
7. We return a cleanup function that removes our changes when the plugin is disabled
4. We set `beta: true` to mark the plugin as beta
5. The `run` function is where we put our plugin's code
6. We use `api.seqta.onMount` to wait for the homepage to load
7. We create and style a message element
8. We return a cleanup function that removes our changes when the plugin is disabled
## The Plugin API
@@ -79,13 +84,13 @@ This helps you interact with SEQTA's pages:
```typescript
// Wait for an element to appear on the page
api.seqta.onMount('.some-class', (element) => {
api.seqta.onMount(".some-class", (element) => {
// Do something with the element
});
// Know when the user changes pages
api.seqta.onPageChange((page) => {
console.log('User went to:', page);
console.log("User went to:", page);
});
// Get the current page
@@ -97,8 +102,12 @@ const currentPage = api.seqta.getCurrentPage();
Want to let users customize your plugin? Use settings!
```typescript
import { BasePlugin } from '@/plugins/core/settings';
import { booleanSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers';
import { BasePlugin } from "@/plugins/core/settings";
import {
booleanSetting,
defineSettings,
Setting,
} from "@/plugins/core/settingsHelpers";
// Define your settings
const settings = defineSettings({
@@ -106,7 +115,7 @@ const settings = defineSettings({
default: true,
title: "Show Welcome Message",
description: "Show a friendly message on the homepage",
})
}),
});
// Create a class for your plugin
@@ -129,14 +138,14 @@ const myPlugin: Plugin<typeof settings> = {
}
// Listen for setting changes
api.settings.onChange('showMessage', (newValue) => {
api.settings.onChange("showMessage", (newValue) => {
if (newValue) {
// Show the message
} else {
// Hide the message
}
});
}
},
};
```
@@ -146,14 +155,14 @@ Need to save some data? The storage API has got you covered:
```typescript
// Save some data
await api.storage.set('lastVisit', new Date().toISOString());
await api.storage.set("lastVisit", new Date().toISOString());
// Get it back later
const lastVisit = await api.storage.get('lastVisit');
const lastVisit = await api.storage.get("lastVisit");
// Listen for changes
api.storage.onChange('lastVisit', (newValue) => {
console.log('Last visit updated:', newValue);
api.storage.onChange("lastVisit", (newValue) => {
console.log("Last visit updated:", newValue);
});
```
@@ -163,12 +172,12 @@ Want your plugin to be able to interface with other plugins? Then use events!
```typescript
// Listen for an event
api.events.on('myCustomEvent', (data) => {
console.log('Got event:', data);
api.events.on("myCustomEvent", (data) => {
console.log("Got event:", data);
});
// Send an event
api.events.emit('myCustomEvent', { some: 'data' });
api.events.emit("myCustomEvent", { some: "data" });
```
## Adding Styles
@@ -199,7 +208,7 @@ const myPlugin: Plugin = {
run: async (api) => {
// Your plugin code here
}
},
};
```
@@ -208,28 +217,31 @@ const myPlugin: Plugin = {
Here are some tips to make your plugin awesome:
1. **Always Clean Up**: When your plugin is disabled, clean up any changes you made:
```typescript
run: async (api) => {
// Add stuff to the page
const element = document.createElement('div');
const element = document.createElement("div");
document.body.appendChild(element);
// Return a cleanup function
return () => {
element.remove();
};
}
};
```
2. **Use TypeScript**: It helps catch errors before they happen and makes your code easier to understand.
3. **Test Your Plugin**: Make sure it works in different situations:
- When SEQTA is loading
- When the user switches pages
- When the plugin is enabled/disabled
- When settings are changed
4. **Keep It Fast**: Don't slow down SEQTA:
- Use `onMount` instead of intervals or timeouts
- Clean up event listeners when they're not needed
- Don't do heavy calculations on the main thread
@@ -238,10 +250,37 @@ Here are some tips to make your plugin awesome:
- Add clear settings with good descriptions
- Use `disableToggle: true` so users can turn it off if needed
- Add helpful error messages if something goes wrong
- Use `beta: true` for experimental features to let users know they're trying something new
## Plugin Metadata Options
Your plugin object supports several optional flags to customize how it appears and behaves:
```typescript
const myPlugin: Plugin = {
id: "my-plugin",
name: "My Plugin",
description: "What my plugin does",
version: "1.0.0",
// Optional flags:
disableToggle: true, // Show enable/disable toggle in settings
defaultEnabled: false, // Start disabled by default (requires disableToggle: true)
beta: true, // Show "Beta" tag in settings UI
// Your plugin code...
run: async (api) => { /* ... */ },
};
```
- **`disableToggle`**: When `true`, users can enable/disable your plugin in settings
- **`defaultEnabled`**: When `false`, your plugin starts disabled (only works with `disableToggle: true`)
- **`beta`**: When `true`, shows an orange "Beta" tag next to your plugin name in settings
## Examples
Want to see more examples? Check out our built-in plugins:
- [themes](../../src/plugins/built-in/themes/index.ts): Shows how to change SEQTA's appearance
- [notificationCollector](../../src/plugins/built-in/notificationCollector/index.ts): Shows how to work with SEQTA's notifications
- [timetable](../../src/plugins/built-in/timetable/index.ts): Shows how to modify SEQTA's timetable view
@@ -250,6 +289,7 @@ Want to see more examples? Check out our built-in plugins:
## Need Help?
Got stuck? No worries! Here's where you can get help:
- Join our [Discord server](https://discord.gg/YzmbnCDkat)
- Check out the built-in plugins in the `src/plugins/built-in` folder
- Open an issue on our [GitHub page](https://github.com/betterseqta/betterseqta-plus/issues)
+126 -74
View File
@@ -7,9 +7,13 @@ This document provides detailed technical information about BetterSEQTA+'s plugi
Here's how a plugin is structured:
```typescript
import type { Plugin } from '@/plugins/core/types';
import { BasePlugin } from '@/plugins/core/settings';
import { booleanSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers';
import type { Plugin } from "@/plugins/core/types";
import { BasePlugin } from "@/plugins/core/settings";
import {
booleanSetting,
defineSettings,
Setting,
} from "@/plugins/core/settingsHelpers";
// First, define your settings
const settings = defineSettings({
@@ -17,7 +21,7 @@ const settings = defineSettings({
default: true,
title: "Enable Feature",
description: "Turn this feature on or off",
})
}),
});
// Create a class to handle your settings
@@ -31,59 +35,92 @@ const settingsInstance = new MyPluginClass();
// Create your plugin
const myPlugin: Plugin<typeof settings> = {
id: 'my-plugin',
name: 'My Plugin',
description: 'A cool plugin that does things',
version: '1.0.0',
id: "my-plugin",
name: "My Plugin",
description: "A cool plugin that does things",
version: "1.0.0",
settings: settingsInstance.settings,
disableToggle: true,
beta: true,
run: async (api) => {
console.log('Plugin is running!');
console.log("Plugin is running!");
// Do stuff when settings change
api.settings.onChange('enabled', (enabled) => {
api.settings.onChange("enabled", (enabled) => {
if (enabled) {
console.log('Feature enabled!');
console.log("Feature enabled!");
}
});
// Return a cleanup function
return () => {
console.log('Plugin cleanup');
console.log("Plugin cleanup");
};
}
},
};
export default myPlugin;
```
## Plugin Metadata
The plugin object supports several metadata fields and options:
```typescript
interface Plugin {
// Required fields
id: string; // Unique identifier (lowercase, dashes)
name: string; // Display name shown to users
description: string; // Brief description of what the plugin does
version: string; // Semantic version (e.g., "1.0.0")
settings: PluginSettings; // Plugin settings object
run: (api: PluginAPI) => void; // Main plugin function
// Optional fields
styles?: string; // CSS styles to inject
disableToggle?: boolean; // Show enable/disable toggle in settings
defaultEnabled?: boolean; // Start enabled/disabled (requires disableToggle)
beta?: boolean; // Show "Beta" tag in settings UI
}
```
### Metadata Options
- **`disableToggle`**: When `true`, users can enable/disable your plugin in the settings page
- **`defaultEnabled`**: When `false`, your plugin starts disabled by default (only works with `disableToggle: true`)
- **`beta`**: When `true`, displays an orange "Beta" tag next to your plugin name in the settings UI
- **`styles`**: CSS string that gets injected into the page when your plugin runs
## SEQTA API
The SEQTA API helps you interact with SEQTA's pages:
```typescript
import type { Plugin } from '@/plugins/core/types';
import type { Plugin } from "@/plugins/core/types";
const seqtaPlugin: Plugin<typeof settings> = {
id: 'seqta-example',
name: 'SEQTA Example',
description: 'Shows how to use the SEQTA API',
version: '1.0.0',
id: "seqta-example",
name: "SEQTA Example",
description: "Shows how to use the SEQTA API",
version: "1.0.0",
settings: {},
disableToggle: true,
run: async (api) => {
// Wait for elements to appear
const { unregister: timetableUnregister } = api.seqta.onMount('.timetable', (timetable) => {
const button = document.createElement('button');
button.textContent = 'Export';
const { unregister: timetableUnregister } = api.seqta.onMount(
".timetable",
(timetable) => {
const button = document.createElement("button");
button.textContent = "Export";
timetable.appendChild(button);
});
},
);
// Track page changes
const { unregister: pageUnregister } = api.seqta.onPageChange((page) => {
console.log('User went to:', page);
console.log("User went to:", page);
});
// Clean up when disabled
@@ -91,7 +128,7 @@ const seqtaPlugin: Plugin<typeof settings> = {
timetableUnregister();
pageUnregister();
};
}
},
};
export default seqtaPlugin;
@@ -102,22 +139,29 @@ export default seqtaPlugin;
Here's how to add settings to your plugin:
```typescript
import type { Plugin } from '@/plugins/core/types';
import { BasePlugin } from '@/plugins/core/settings';
import { booleanSetting, stringSetting, numberSetting, selectSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers';
import type { Plugin } from "@/plugins/core/types";
import { BasePlugin } from "@/plugins/core/settings";
import {
booleanSetting,
stringSetting,
numberSetting,
selectSetting,
defineSettings,
Setting,
} from "@/plugins/core/settingsHelpers";
// Define your settings
const settings = defineSettings({
darkMode: booleanSetting({
default: false,
title: "Dark Mode",
description: "Enable dark mode"
description: "Enable dark mode",
}),
userName: stringSetting({
default: "",
title: "User Name",
description: "Your display name",
placeholder: "Enter your name..."
placeholder: "Enter your name...",
}),
theme: selectSetting({
default: "light",
@@ -125,9 +169,9 @@ const settings = defineSettings({
description: "Choose your theme",
options: [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" }
]
})
{ value: "dark", label: "Dark" },
],
}),
});
// Create your settings class
@@ -144,29 +188,29 @@ class ThemePluginClass extends BasePlugin<typeof settings> {
// Create the plugin
const themePlugin: Plugin<typeof settings> = {
id: 'theme-example',
name: 'Theme Example',
description: 'Shows how to use settings',
version: '1.0.0',
id: "theme-example",
name: "Theme Example",
description: "Shows how to use settings",
version: "1.0.0",
settings: new ThemePluginClass().settings,
disableToggle: true,
run: async (api) => {
// Apply initial settings
if (api.settings.darkMode) {
document.body.classList.add('dark');
document.body.classList.add("dark");
}
// Listen for changes
const { unregister } = api.settings.onChange('darkMode', (enabled) => {
document.body.classList.toggle('dark', enabled);
const { unregister } = api.settings.onChange("darkMode", (enabled) => {
document.body.classList.toggle("dark", enabled);
});
return () => {
unregister();
document.body.classList.remove('dark');
document.body.classList.remove("dark");
};
}
},
};
export default themePlugin;
@@ -177,13 +221,13 @@ export default themePlugin;
Here's how to use storage in your plugin:
```typescript
import type { Plugin } from '@/plugins/core/types';
import type { Plugin } from "@/plugins/core/types";
const storagePlugin: Plugin<typeof settings> = {
id: 'storage-example',
name: 'Storage Example',
description: 'Shows how to use storage',
version: '1.0.0',
id: "storage-example",
name: "Storage Example",
description: "Shows how to use storage",
version: "1.0.0",
settings: {},
disableToggle: true,
@@ -192,21 +236,21 @@ const storagePlugin: Plugin<typeof settings> = {
await api.storage.loaded;
// Save some data
await api.storage.set('lastVisit', new Date().toISOString());
await api.storage.set("lastVisit", new Date().toISOString());
// Get saved data
const lastVisit = await api.storage.get('lastVisit');
console.log('Last visit:', lastVisit);
const lastVisit = await api.storage.get("lastVisit");
console.log("Last visit:", lastVisit);
// Listen for changes
const { unregister } = api.storage.onChange('lastVisit', (newValue) => {
console.log('Last visit updated:', newValue);
const { unregister } = api.storage.onChange("lastVisit", (newValue) => {
console.log("Last visit updated:", newValue);
});
return () => {
unregister();
};
}
},
};
export default storagePlugin;
@@ -217,33 +261,39 @@ export default storagePlugin;
Here's how to use events in your plugin:
```typescript
import type { Plugin } from '@/plugins/core/types';
import type { Plugin } from "@/plugins/core/types";
const eventsPlugin: Plugin<typeof settings> = {
id: 'events-example',
name: 'Events Example',
description: 'Shows how to use events',
version: '1.0.0',
id: "events-example",
name: "Events Example",
description: "Shows how to use events",
version: "1.0.0",
settings: {},
disableToggle: true,
run: async (api) => {
// Listen for theme changes
const { unregister: themeListener } = api.events.on('theme.changed', (theme) => {
console.log('Theme changed to:', theme);
});
const { unregister: themeListener } = api.events.on(
"theme.changed",
(theme) => {
console.log("Theme changed to:", theme);
},
);
// Listen for notifications
const { unregister: notifyListener } = api.events.on('notification.new', (notification) => {
console.log('New notification:', notification);
});
const { unregister: notifyListener } = api.events.on(
"notification.new",
(notification) => {
console.log("New notification:", notification);
},
);
// Clean up listeners
return () => {
themeListener();
notifyListener();
};
}
},
};
export default eventsPlugin;
@@ -254,20 +304,20 @@ export default eventsPlugin;
Here's how to write efficient plugins:
```typescript
import type { Plugin } from '@/plugins/core/types';
import type { Plugin } from "@/plugins/core/types";
const efficientPlugin: Plugin<typeof settings> = {
id: 'efficient-example',
name: 'Efficient Example',
description: 'Shows performance best practices',
version: '1.0.0',
id: "efficient-example",
name: "Efficient Example",
description: "Shows performance best practices",
version: "1.0.0",
settings: {},
disableToggle: true,
run: async (api) => {
// ✅ Good: Use onMount
const { unregister } = api.seqta.onMount('.timetable', (el) => {
el.classList.add('enhanced');
const { unregister } = api.seqta.onMount(".timetable", (el) => {
el.classList.add("enhanced");
});
// ❌ Bad: Don't use intervals
@@ -277,7 +327,7 @@ const efficientPlugin: Plugin<typeof settings> = {
// }, 100);
// ✅ Good: Cache DOM elements
const header = document.querySelector('.header');
const header = document.querySelector(".header");
if (header) {
// Reuse header instead of querying again
}
@@ -285,7 +335,7 @@ const efficientPlugin: Plugin<typeof settings> = {
// ✅ Good: Batch DOM updates
const fragment = document.createDocumentFragment();
for (let i = 0; i < 10; i++) {
const div = document.createElement('div');
const div = document.createElement("div");
fragment.appendChild(div);
}
document.body.appendChild(fragment);
@@ -294,13 +344,14 @@ const efficientPlugin: Plugin<typeof settings> = {
unregister();
// clearInterval(interval); // If you used the bad approach
};
}
},
};
export default efficientPlugin;
```
Each plugin should be in its own file and exported as the default export. The plugin should:
1. Import necessary types and helpers
2. Define settings if needed
3. Create a settings class if using settings
@@ -308,6 +359,7 @@ Each plugin should be in its own file and exported as the default export. The pl
5. Export the plugin as default
Remember to always:
- Use proper TypeScript types
- Clean up when your plugin is disabled
- Handle errors gracefully
+17
View File
@@ -0,0 +1,17 @@
export default {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: [
'**/__tests__/**/*.ts',
'**/?(*.)+(spec|test).ts'
],
transform: {
'^.+\\.ts$': 'ts-jest',
},
moduleFileExtensions: ['ts', 'js', 'json'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
],
};
+34 -1
View File
@@ -1,13 +1,46 @@
import fs from "fs";
import mime from "mime-types";
/**
* A Vite plugin designed to load files as base64 encoded data URLs.
* This plugin intercepts module imports that have a `?base64` query parameter
* appended to the file path. It then reads the targeted file, converts its content
* to a base64 string, and constructs a data URL which is then exported as the
* default export of a new JavaScript module.
*
* @example
* // To use this loader, import a file with ?base64 query:
* // import myImageBase64 from './path/to/myimage.png?base64';
* // myImageBase64 will then be a string like "data:image/png;base64,..."
*/
export const base64Loader = {
/**
* The name of the Vite plugin.
* @type {string}
*/
name: "base64-loader",
/**
* The core transformation function of the Vite plugin.
* It is called by Vite for modules that might need transformation. This function
* checks if the module ID includes the `?base64` query. If so, it reads the
* specified file, converts it to a base64 data URL, and returns a new
* JavaScript module that default exports this data URL.
*
* @param {any} _ The original code of the file. This parameter is unused by this loader.
* @param {string} id The ID of the module being transformed. This string typically
* contains the absolute file path and any query parameters
* (e.g., "/path/to/file.png?base64").
* @returns {string | null} If the module ID does not contain `?base64` query,
* it returns `null` to indicate no transformation.
* Otherwise, it returns a string of JavaScript code
* that default exports the base64 data URL of the file.
* For example: `export default 'data:image/png;base64,xxxx';`
*/
transform(_: any, id: string) {
const [filePath, query] = id.split("?");
if (query !== "base64") return null;
const data = fs.readFileSync(filePath, { encoding: 'base64' });
const data = fs.readFileSync(filePath, { encoding: "base64" });
const mimeType = mime.lookup(filePath);
const dataURL = `data:${mimeType};base64,${data}`;
+45 -12
View File
@@ -1,25 +1,58 @@
// ref: https://stackoverflow.com/a/76920975
import type { Plugin } from 'vite';
import type { Plugin } from "vite";
/**
* Creates a Vite plugin designed to gracefully handle the conclusion of the build process.
* This plugin utilizes the `buildEnd` and `closeBundle` hooks provided by Vite.
* It checks for errors at the end of the build:
* - If an error occurred during the build (`buildEnd` hook receives an error), it logs the error
* and explicitly exits the Node.js process with a status code of 1 (indicating failure).
* - If the build completes without errors and the bundle is successfully generated
* (`closeBundle` hook is called), it logs a success message and exits the process
* with a status code of 0 (indicating success).
* This explicit process exiting can be useful in CI/CD environments or scripts that
* rely on the process status code to determine the build outcome.
* The core logic for using these hooks to exit the process is inspired by
* a solution found on StackOverflow (https://stackoverflow.com/a/76920975).
*
* @returns {Plugin} A Vite plugin object configured with `name`, `buildEnd`, and `closeBundle` hooks.
*/
export default function ClosePlugin(): Plugin {
return {
name: 'ClosePlugin', // required, will show up in warnings and errors
/**
* The unique name of this Vite plugin. This name is used by Vite for identification
* purposes and will appear in warnings, errors, and logs related to this plugin.
* @type {string}
*/
name: "ClosePlugin", // required, will show up in warnings and errors
// use this to catch errors when building
/**
* A Vite hook that is called when the build process has finished, regardless of
* whether it was successful or encountered an error.
*
* @param {Error} [error] An optional error object. If the build failed, this parameter
* will contain the error that occurred. If the build was successful,
* this parameter will be undefined or null.
*/
buildEnd(error) {
if(error) {
console.error('Error bundling')
console.error(error)
process.exit(1)
if (error) {
console.error("Error bundling");
console.error(error);
process.exit(1); // Exit with status 1 indicating an error
} else {
console.log('Build ended')
console.log("Build ended"); // Log successful completion of the build phase
}
},
// use this to catch the end of a build without errors
/**
* A Vite hook that is called after the `buildEnd` hook, but only if the build
* was successful (i.e., no errors were passed to `buildEnd`) and all output
* files have been generated and written to disk. This signifies the successful
* completion of the entire bundling process.
*/
closeBundle() {
console.log('Bundle closed')
process.exit(0)
console.log("Bundle closed"); // Log successful closure of the bundle
process.exit(0); // Exit with status 0 indicating a successful build
},
}
};
}
+27 -14
View File
@@ -1,12 +1,22 @@
import type { Browser, BuildTarget, Manifest } from './types'
import type { AnyCase } from './utils'
import type { Browser, BuildTarget, Manifest } from "./types";
import type { AnyCase } from "./utils";
/**
*
* Packages a given manifest object with a specific target browser identifier.
* This function is typically used in multi-browser extension build processes
* to create a configuration object that pairs the manifest data with the browser
* it's intended for. The `AnyCase<Browser>` type for the browser parameter
* implies that browser names like 'chrome', 'firefox', etc., can be provided
* in various casings.
*
* @export
* @param {Manifest} manifest
* @param {AnyCase<Browser>} browser
* @return {*} {@link BuildTarget}
* @param {Manifest} manifest The core manifest data for the extension,
* compatible with `chrome.runtime.ManifestV3` as defined by the {@link Manifest} type.
* @param {AnyCase<Browser>} browser The target browser identifier (e.g., 'chrome', 'firefox', 'CHROME').
* Refers to the {@link Browser} type, allowing for flexible casing.
* @returns {BuildTarget} An object that pairs the `manifest` with its target `browser`.
* The structure is `{ manifest: Manifest; browser: AnyCase<Browser>; }`
* as defined by the {@link BuildTarget} type.
*/
export function createManifest(
manifest: Manifest,
@@ -15,19 +25,22 @@ export function createManifest(
return {
manifest,
browser,
}
};
}
/**
* create a base Manifest to inherit from
* type Manifest = chrome.runtime.ManifestV3
*
* use as shared base to extend inBrowser manifests
* Defines a base manifest object.
* This function is typically used to establish a common, shared foundation for an extension's manifest
* (compatible with `chrome.runtime.ManifestV3` as per the {@link Manifest} type).
* This base can then be extended or modified for different browsers or specific build configurations.
* For example, you might define core permissions and properties here, and then add
* browser-specific keys in subsequent steps.
*
* @export
* @param {Manifest} manifest
* @return {*} {@link Manifest}
* @param {Manifest} manifest The core manifest data to be used as a base.
* This should conform to the {@link Manifest} type structure.
* @returns {Manifest} The provided manifest object, intended to serve as a reusable base.
*/
export function createManifestBase(manifest: Manifest): Manifest {
return manifest
return manifest;
}
+70
View File
@@ -0,0 +1,70 @@
// vite-plugin-inline-worker-dev.ts
// vite-plugin-inline-worker-dev.ts
import { Plugin } from "vite";
import fs from "fs/promises";
import { build } from "esbuild";
/**
* Creates a Vite plugin designed for bundling and inlining web worker scripts during development.
* This plugin specifically targets module imports that include a `?inlineWorker` query parameter.
* When such an import is encountered, the plugin bundles the worker script using `esbuild`
* and then generates JavaScript code that inlines this bundled worker as a Blob,
* creating the worker instance via `URL.createObjectURL()`.
* The name "vite:inline-worker-dev" suggests it's primarily intended for development builds.
*
* @returns {Plugin} A Vite plugin object with `name` and `load` properties.
*/
export default function InlineWorkerDevPlugin(): Plugin {
return {
/**
* The unique name of this Vite plugin.
* @type {string}
*/
name: "vite:inline-worker-dev",
/**
* The Vite hook responsible for loading and transforming modules.
* This function intercepts modules imported with `?inlineWorker`.
* For such modules, it bundles the worker script and returns JavaScript code
* that, when executed, will create an instance of this worker from an inlined Blob.
*
* @async
* @param {string} id The path or ID of the module Vite is attempting to load,
* potentially including query parameters (e.g., "/path/to/worker.ts?inlineWorker").
* @returns {Promise<string | null>} A promise that resolves to:
* - `null` if the module ID does not include `?inlineWorker`.
* - A string of JavaScript code if the module is an inline worker.
* This code will define a default export function (e.g., `InlineWorker`)
* that, when called, creates and returns a new `Worker` instance
* from the bundled and inlined worker script.
*/
async load(id) {
if (id.includes("?inlineWorker")) {
const [cleanPath] = id.split("?");
// Note: Original code had `await fs.readFile(cleanPath, "utf-8");` but `code` wasn't used.
// `esbuild` directly takes `cleanPath` as an entry point.
const result = await build({
entryPoints: [cleanPath], // esbuild uses the file path directly
bundle: true,
write: false, // We want the output in memory, not written to disk
platform: "browser", // Target environment for the worker code
format: "iife", // Immediately Invoked Function Expression, suitable for workers
target: "esnext", // Transpile to modern JavaScript
});
const workerCode = result.outputFiles[0].text;
// Construct JavaScript code that will create the worker from a Blob.
// This code is what gets returned to Vite and replaces the original import.
const workerBlobCode = `
const code = ${JSON.stringify(workerCode)};
export default function InlineWorker() {
const blob = new Blob([code], { type: 'application/javascript' });
return new Worker(URL.createObjectURL(blob), { type: 'module' });
}
`;
return workerBlobCode;
}
return null; // Let Vite handle other modules normally
},
};
}
-79
View File
@@ -1,79 +0,0 @@
/*
TEMPORARY FIX FOR CHROME 130+ builds
*/
import path from 'node:path';
import fs from 'fs';
import { PluginOption } from 'vite';
import { ManifestV3Export } from '@crxjs/vite-plugin';
const manifestPath = path.resolve('dist/chrome/manifest.json');
export function updateManifestPlugin(): PluginOption {
return {
name: 'update-manifest-plugin',
enforce: 'post',
closeBundle() {
forceDisableUseDynamicUrl();
},
configureServer(server) {
server.httpServer?.once('listening', () => {
const updated = forceDisableUseDynamicUrl();
if (updated) {
server.ws.send({ type: 'full-reload' });
console.log('** updated **');
}
// Implement retry mechanism for file watching
const watchWithRetry = () => {
if (!fs.existsSync(manifestPath)) {
console.log('Manifest not found, retrying in 1 second...');
setTimeout(watchWithRetry, 1000);
return;
}
fs.watchFile(manifestPath, () => {
try {
const manifestContents = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
if (manifestContents.web_accessible_resources?.some((resource: any) => resource.use_dynamic_url)) {
const updated = forceDisableUseDynamicUrl();
if (updated) {
server.ws.send({ type: 'full-reload' });
console.log('** updated **');
}
}
} catch (error) {
console.log('Error reading manifest, will retry on next change:', error.message);
}
});
};
watchWithRetry();
});
},
writeBundle() {
console.log('### writeBundle ##');
forceDisableUseDynamicUrl();
},
};
}
function forceDisableUseDynamicUrl() {
if (!fs.existsSync(manifestPath)) {
return false;
}
const manifestContents = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Awaited<ManifestV3Export>;
if (typeof manifestContents === 'function' || !manifestContents.web_accessible_resources) return false;
if (manifestContents.web_accessible_resources.every((resource) => !resource.use_dynamic_url)) return false;
manifestContents.web_accessible_resources.forEach((resource) => {
if (resource.use_dynamic_url) resource.use_dynamic_url = false;
});
fs.writeFileSync(manifestPath, JSON.stringify(manifestContents, null, 2));
return true;
}
+154 -48
View File
@@ -1,94 +1,199 @@
const glob = require('glob');
const semver = require('semver');
const { execSync } = require('child_process');
const path = require('path');
/**
* @fileoverview
* This script is a command-line utility for publishing the BetterSEQTA+ extension.
* It automates the process of finding the latest built extension ZIP files for specified
* browsers, zipping the project source code (for Firefox), and then invoking the
* `publish-extension` tool with the appropriate arguments.
*
* To use this script, invoke it with Node.js followed by browser arguments:
* e.g., `node lib/publish.js --b chrome firefox`
* or `node lib/publish.js --b chrome`
* or `node lib/publish.js --b firefox`
*/
const glob = require("glob");
const semver = require("semver");
const { execSync } = require("child_process");
const path = require("path");
/**
* Determines the latest version string from a list of filenames that include version numbers.
* Filenames are expected to follow a pattern like `betterseqtaplus@3.4.5.1-chrome.zip`.
* This function handles potential 4-part versions (e.g., `3.4.5.1`) by trimming them
* to 3 parts (e.g., `3.4.5`) for comparison using the `semver` library. After identifying
* the latest semver-compatible version, it returns the original full version string
* (e.g., "3.4.5.1") that corresponds to this latest version.
*
* @param {string[]} files An array of filenames.
* @returns {string | null} The latest version string (e.g., "3.4.5.1") found among the files,
* or `null` if no valid version numbers are found or no files are provided.
*/
function getLatestVersion(files) {
console.log('Files passed to getLatestVersion:', files);
console.log("Files passed to getLatestVersion:", files);
const versions = files.map(file => {
const versions = files
.map((file) => {
const match = file.match(/@([\d\.]+)-/);
console.log('Matching file:', file, 'Version found:', match ? match[1] : 'None');
console.log(
"Matching file:",
file,
"Version found:",
match ? match[1] : "None",
);
if (!match) return null;
const fullVersion = match[1]; // Original version (e.g., 3.4.5.1)
const semverVersion = fullVersion.split('.').slice(0, 3).join('.'); // Trim to 3.4.5
// Trim to 3 parts for semver comparison, as semver typically handles X.Y.Z
const semverVersion = fullVersion.split(".").slice(0, 3).join(".");
return { fullVersion, semverVersion };
}).filter(Boolean);
})
.filter(Boolean); // Remove null entries if any file didn't match
console.log('Extracted versions:', versions.map(v => v.semverVersion));
console.log(
"Extracted versions:",
versions.map((v) => v.semverVersion),
);
if (versions.length === 0) {
console.log("No versions extracted.");
return null;
}
// Find latest version using the trimmed semver format
const latestSemver = semver.maxSatisfying(versions.map(v => v.semverVersion), '*');
console.log('Latest SemVer-compatible version:', latestSemver);
const latestSemver = semver.maxSatisfying(
versions.map((v) => v.semverVersion),
"*", // Satisfy any version, effectively finding the max
);
console.log("Latest SemVer-compatible version:", latestSemver);
// Get the full version that matches the latest SemVer version
const latestVersion = versions.find(v => v.semverVersion === latestSemver)?.fullVersion || null;
if (!latestSemver) {
console.log("Could not determine latest semver version.");
return null;
}
console.log('Final selected latest version:', latestVersion);
return latestVersion;
// Get the original full version string that matches the identified latest SemVer version
const latestVersionData = versions.find(
(v) => v.semverVersion === latestSemver,
);
const latestFullVersion = latestVersionData ? latestVersionData.fullVersion : null;
console.log("Final selected latest version:", latestFullVersion);
return latestFullVersion;
}
/**
* Finds the path to the latest built ZIP file for a specific browser.
* It constructs a glob pattern based on the browser name (e.g., `dist/betterseqtaplus@*-*chrome.zip`),
* finds all matching files, and then uses `getLatestVersion` to identify the version string
* of the most recent file. Finally, it returns the full path to that specific file.
*
* @param {string} browser A string indicating the target browser (e.g., "chrome", "firefox").
* @returns {string | undefined} The filepath string to the latest ZIP file for the specified browser,
* or `undefined` if no matching file is found or if the latest version
* cannot be determined.
*/
function getLatestFiles(browser) {
const pattern = `dist/betterseqtaplus@*-*${browser}.zip`;
console.log('Glob pattern:', pattern);
console.log("Glob pattern:", pattern);
const files = glob.sync(pattern);
console.log('Files found for browser', browser, ':', files);
console.log("Files found for browser", browser, ":", files);
if (files.length === 0) {
console.log("No files found for browser", browser);
return undefined;
}
const latestVersion = getLatestVersion(files);
if (!latestVersion) {
console.log("Could not determine latest version for browser", browser);
return undefined;
}
// Find the exact file by matching the original full version
const latestFile = files.find(file => file.includes(`@${latestVersion}-`));
// Find the exact file by matching the original full version string
const latestFile = files.find((file) => file.includes(`@${latestVersion}-`));
console.log('Latest file for browser', browser, ':', latestFile);
console.log("Latest file for browser", browser, ":", latestFile);
return latestFile;
}
/**
* Creates a ZIP file of the project's source code, excluding specified development-related
* files and directories such as `node_modules`, `dist`, `.git`, etc.
* It uses the `7z` command-line tool to perform the archiving.
* The output filename is fixed as `dist/betterseqtaplus@latest-sources.zip`.
*
* @returns {string} The filename of the created ZIP file (e.g., `dist/betterseqtaplus@latest-sources.zip`).
*/
function zipSources() {
const zipFileName = `dist/betterseqtaplus@latest-sources.zip`;
const excludePatterns = [
'node_modules',
'dist',
'.env*',
'.git',
'.github',
'.vscode',
'LICENSE',
'package.json'
].map(pattern => `-x!${pattern}`).join(' ');
"node_modules",
"dist",
".env*",
".git",
".github",
".vscode",
"LICENSE",
"package.json",
]
.map((pattern) => `-x!${pattern}`) // Format for 7z exclude syntax
.join(" ");
// Command to zip the current directory's contents into zipFileName, applying exclude patterns
const zipCommand = `7z a ${zipFileName} . ${excludePatterns}`;
console.log('Zipping project sources with command:', zipCommand);
execSync(zipCommand, { stdio: 'inherit' });
console.log("Zipping project sources with command:", zipCommand);
execSync(zipCommand, { stdio: "inherit" }); // Execute synchronously and show output
return zipFileName;
}
/**
* Orchestrates the extension publishing process for the specified browsers.
* This function performs the following steps:
* 1. Calls `getLatestFiles` to find the latest built ZIP for Chrome if "chrome" is in `browsers`.
* 2. Calls `getLatestFiles` to find the latest built ZIP for Firefox if "firefox" is in `browsers`.
* 3. Calls `zipSources` to create a source code ZIP if "firefox" is in `browsers` (required for Mozilla Add-ons).
* 4. Validates that all required files were found and that at least one browser was specified. Exits if not.
* 5. Constructs the `publish-extension` command-line string with the appropriate arguments
* based on the found ZIP files for the specified browsers.
* 6. Executes the constructed `publish-extension` command.
*
* @param {string[]} browsers An array of browser strings (e.g., ["chrome", "firefox"]) for which to publish the extension.
*/
function runPublishCommand(browsers) {
const chromeZip = browsers.includes('chrome') ? getLatestFiles('chrome') : null;
const firefoxZip = browsers.includes('firefox') ? getLatestFiles('firefox') : null;
const firefoxSourcesZip = browsers.includes('firefox') ? zipSources() : null;
const chromeZip = browsers.includes("chrome")
? getLatestFiles("chrome")
: null;
const firefoxZip = browsers.includes("firefox")
? getLatestFiles("firefox")
: null;
// Sources are typically only needed for Firefox submissions
const firefoxSourcesZip = browsers.includes("firefox") ? zipSources() : null;
console.log('Chrome zip:', chromeZip);
console.log('Firefox zip:', firefoxZip);
console.log('Firefox sources zip:', firefoxSourcesZip);
console.log("Chrome zip:", chromeZip);
console.log("Firefox zip:", firefoxZip);
console.log("Firefox sources zip:", firefoxSourcesZip);
if (browsers.length === 0) {
console.log('No browsers specified. Exiting.');
process.exit(0);
console.log("No browsers specified. Exiting.");
process.exit(0); // Exit gracefully if no action is needed
}
if ((browsers.includes('chrome') && !chromeZip) || (browsers.includes('firefox') && (!firefoxZip || !firefoxSourcesZip))) {
console.error('Could not find required zip files for specified browsers.');
process.exit(1);
// Check if required files are missing for the specified browsers
if (
(browsers.includes("chrome") && !chromeZip) ||
(browsers.includes("firefox") && (!firefoxZip || !firefoxSourcesZip))
) {
console.error("Could not find required zip files for specified browsers.");
process.exit(1); // Exit with error status
}
let command = 'publish-extension';
let command = "publish-extension";
if (chromeZip) {
command += ` --chrome-zip ${chromeZip}`;
}
@@ -96,13 +201,14 @@ function runPublishCommand(browsers) {
command += ` --firefox-zip ${firefoxZip} --firefox-sources-zip ${firefoxSourcesZip}`;
}
console.log('Running command:', command);
execSync(command, { stdio: 'inherit' });
console.log("Running command:", command);
execSync(command, { stdio: "inherit" }); // Execute and show output
}
// Parse command-line arguments
// Parse command-line arguments to determine which browsers to publish for
const args = process.argv.slice(2);
const browserIndex = args.indexOf('--b');
const browserIndex = args.indexOf("--b"); // Find the --b flag
// If --b is found, take all subsequent arguments as browser names
const browsers = browserIndex !== -1 ? args.slice(browserIndex + 1) : [];
runPublishCommand(browsers);
+46 -8
View File
@@ -1,17 +1,55 @@
import fs from 'fs';
import fs from "fs";
/**
* Creates a Vite plugin designed to improve the reliability of Hot Module Replacement (HMR)
* for global CSS files.
*
* When a JavaScript/TypeScript module that imports a CSS file is updated, Vite's HMR
* might not always reliably update the styles injected by that global CSS. This plugin
* attempts to mitigate this by listening for hot updates. If an updated module
* has direct importers that are CSS files (e.g., a JS file imports a global CSS file),
* this plugin will "touch" those CSS files by updating their access and modification
* timestamps using `fs.utimesSync`. This action can help signal to Vite or the browser
* that the CSS file has changed, potentially triggering a more reliable style reload.
*
* @returns {import('vite').Plugin} A Vite plugin object configured with `name` and `handleHotUpdate` hooks.
*/
export default function touchGlobalCSSPlugin() {
return {
name: 'touch-global-css',
/**
* The unique name of this Vite plugin.
* This name is used by Vite for identification purposes and will appear in logs.
* @type {string}
*/
name: "touch-global-css",
/**
* A Vite hook that is called when a module is hot-updated.
* This function inspects the importers of the updated module. If any of these
* importers are CSS files, their filesystem timestamps are updated ("touched").
*
* @param {object} context The context object provided by Vite's `handleHotUpdate` hook.
* @param {Array<import('vite').ModuleNode>} context.modules An array of `ModuleNode` instances that have been updated.
* This plugin specifically accesses `modules[0]._clientModule.importers`
* to find CSS files that import the updated module.
*/
handleHotUpdate({ modules }) {
// log all of the staticImportedUrls
const importers = modules[0]._clientModule.importers
// It's assumed `modules[0]` is the primary updated module of interest.
// `_clientModule` and `importers` might be internal or less stable Vite APIs.
const importers = modules[0]?._clientModule?.importers;
if (importers) {
importers.forEach((importer) => {
if (importer.file.includes('.css')) {
console.log("touching", importer.file)
fs.utimesSync(importer.file, new Date(), new Date())
// Check if the importer is a CSS file
if (importer.file && importer.file.includes(".css")) {
console.log("[touch-global-css] touching", importer.file);
try {
// Update the access and modification times of the CSS file to the current time
fs.utimesSync(importer.file, new Date(), new Date());
} catch (err) {
console.error(`[touch-global-css] Error touching file ${importer.file}:`, err);
}
})
}
});
}
},
};
}
+205 -69
View File
@@ -1,104 +1,240 @@
import type { ManifestV3Export } from '@crxjs/vite-plugin'
import { type AnyCase, createEnum } from './utils'
import type { ManifestV3Export } from "@crxjs/vite-plugin";
import { type AnyCase, createEnum, ObjectValues } from "./utils";
/**
* Enumerates supported JavaScript frameworks for project generation or configuration.
*/
export const FrameworkEnum = {
React: 'React',
Vanilla: 'Vanilla',
Preact: 'Preact',
Lit: 'Lit',
Svelte: 'Svelte',
Vue: 'Vue',
} as const
React: "React",
Vanilla: "Vanilla",
Preact: "Preact",
Lit: "Lit",
Svelte: "Svelte",
Vue: "Vue",
} as const;
/**
* Enumerates supported web browsers, typically for targeting builds or configurations.
*/
export const BrowserEnum = {
Chrome: 'Chrome',
Brave: 'Brave',
Opera: 'Opera',
Edge: 'Edge',
Firefox: 'Firefox',
Safari: 'Safari',
} as const
Chrome: "Chrome",
Brave: "Brave",
Opera: "Opera",
Edge: "Edge",
Firefox: "Firefox",
Safari: "Safari",
} as const;
/**
* @private
* Enumerates supported programming languages for project setup.
* This enum is not exported, suggesting it's for internal use within this module or related modules.
*/
const LanguageEnum = {
TypeScript: 'TypeScript',
JavaScript: 'JavaScript',
} as const
TypeScript: "TypeScript",
JavaScript: "JavaScript",
} as const;
/**
* Enumerates supported styling options or libraries.
*/
export const StyleEnum = {
Tailwind: 'Tailwind',
} as const
Tailwind: "Tailwind",
} as const;
/**
* Enumerates supported package managers.
*/
export const PackageManagerEnum = {
Bun: 'Bun',
PnPm: 'PnPm',
Npm: 'Npm',
Yarn: 'Yarn',
} as const
Bun: "Bun",
PnPm: "PnPm",
Npm: "Npm",
Yarn: "Yarn",
} as const;
// see: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/firefox-webext-browser/index.d.ts
/**
* Defines the structure for browser-specific settings within a web extension manifest.
* This is particularly used for Firefox (gecko) extensions to specify properties like
* an extension ID, and minimum/maximum supported browser versions.
* The structure is based on common manifest extensions for Firefox.
* See: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_specific_settings
* The link in the original code (// see: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/firefox-webext-browser/index.d.ts)
* also points to type definitions that include this structure.
*
* @property {object} [browser_specific_settings] - Container for browser-specific settings.
* @property {object} [browser_specific_settings.gecko] - Settings specific to Gecko-based browsers (e.g., Firefox).
* @property {string} [browser_specific_settings.gecko.id] - The unique identifier for the extension in Firefox.
* @property {string} [browser_specific_settings.gecko.strict_min_version] - The minimum version of Firefox the extension is compatible with.
* @property {string} [browser_specific_settings.gecko.strict_max_version] - The maximum version of Firefox the extension is compatible with.
*/
export type BrowserSpecificSettings = {
browser_specific_settings?: {
gecko?: {
id: string
strict_min_version?: string
strict_max_version?: string
}
}
}
id: string;
strict_min_version?: string;
strict_max_version?: string;
};
};
};
export type Manifest = ManifestV3Export
export type ManifestIcons = chrome.runtime.ManifestIcons
export type ManifestBackground = chrome.runtime.ManifestV3['background']
/**
* Represents the structure of a Chrome Manifest V3 file.
* This type is an alias for `ManifestV3Export` from the `@crxjs/vite-plugin`,
* which provides a comprehensive definition for Chrome extension manifests.
*/
export type Manifest = ManifestV3Export;
/** Alias for the `icons` property within a Chrome Manifest V3. */
export type ManifestIcons = chrome.runtime.ManifestIcons;
/** Alias for the `background` property within a Chrome Manifest V3. */
export type ManifestBackground = chrome.runtime.ManifestV3["background"];
/** Alias for the `content_scripts` property within a Chrome Manifest V3. */
export type ManifestContentScripts =
chrome.runtime.ManifestV3['content_scripts']
chrome.runtime.ManifestV3["content_scripts"];
/** Alias for the `web_accessible_resources` property within a Chrome Manifest V3. */
export type ManifestWebAccessibleResources =
chrome.runtime.ManifestV3['web_accessible_resources']
export type ManifestCommands = chrome.runtime.ManifestV3['commands']
export type ManifestAction = chrome.runtime.ManifestV3['action']
export type ManifestPermissions = chrome.runtime.ManifestV3['permissions']
export type ManifestOptionsUI = chrome.runtime.ManifestV3['options_ui']
chrome.runtime.ManifestV3["web_accessible_resources"];
/** Alias for the `commands` property within a Chrome Manifest V3. */
export type ManifestCommands = chrome.runtime.ManifestV3["commands"];
/** Alias for the `action` property (or `browser_action`/`page_action`) within a Chrome Manifest V3. */
export type ManifestAction = chrome.runtime.ManifestV3["action"];
/** Alias for the `permissions` property within a Chrome Manifest V3. */
export type ManifestPermissions = chrome.runtime.ManifestV3["permissions"];
/** Alias for the `options_ui` property within a Chrome Manifest V3. */
export type ManifestOptionsUI = chrome.runtime.ManifestV3["options_ui"];
/** Alias for the `chrome_url_overrides` property within a Chrome Manifest V3. */
export type ManifestURLOverrides =
chrome.runtime.ManifestV3['chrome_url_overrides']
chrome.runtime.ManifestV3["chrome_url_overrides"];
export type BrowserName<T extends string> = Capitalize<T> | Lowercase<T>
/**
* Creates a type that accepts a string literal `T` in either its capitalized or lowercase form.
* Useful for defining types that should be case-insensitive for specific known strings.
* @template T - A string literal type.
*/
export type BrowserName<T extends string> = Capitalize<T> | Lowercase<T>;
/**
* Creates a record type where both keys and values are derived from a string literal `T`,
* specifically using `BrowserName<T>` which allows for capitalized or lowercase forms.
* This could be used to define an object where, for example, keys are 'Chrome' or 'chrome'
* and values are also 'Chrome' or 'chrome'.
* @template T - A string literal type, typically representing a browser name.
*/
export type BrowserEnumType<T extends string> = {
[browser in BrowserName<T>]: BrowserName<T>
}
[browser in BrowserName<T>]: BrowserName<T>;
};
export type BuildMode = AnyCase<Browser>
/**
* Represents the target browser for a build, allowing for various casings of browser names
* (e.g., "chrome", "Chrome", "CHROME") through the `AnyCase<Browser>` utility type.
* `Browser` itself is a union of specific browser name strings (e.g., "Chrome" | "Firefox").
*/
export type BuildMode = AnyCase<Browser>;
/**
* Defines an object structure that pairs a web extension `Manifest`
* with its target `browser` (represented as `AnyCase<Browser>`).
* This is commonly used in build processes to manage configurations for different browsers.
*/
export type BuildTarget = {
manifest: Manifest
browser: AnyCase<Browser>
}
manifest: Manifest;
browser: AnyCase<Browser>;
};
/**
* Defines the configuration options for a build process.
* @property {"build" | "serve"} [command] - The type of build command (e.g., 'build' for production, 'serve' for development).
* @property {AnyCase<Browser> | string | undefined} [mode] - The target build mode, typically a browser name (allowing various casings)
* or potentially other custom mode strings.
*/
export type BuildConfig = {
command?: 'build' | 'serve'
mode?: AnyCase<Browser> | string | undefined
}
command?: "build" | "serve";
mode?: AnyCase<Browser> | string | undefined;
};
/**
* Defines the structure for repository information, commonly found in `package.json`.
* @property {string} type - The type of the repository (e.g., "git").
* @property {string} [url] - The URL of the repository.
* @property {Bugs} [bugs] - An object containing information about where to report bugs.
*/
export interface Repository {
type: string
url?: string
bugs?: Bugs
type: string;
url?: string;
bugs?: Bugs;
}
/**
* Defines the structure for bug reporting information, often part of the `Repository` interface.
* @property {string} [url] - The URL of the issue tracker.
* @property {string} [email] - The email address for reporting bugs.
*/
export interface Bugs {
url?: string
email?: string
url?: string;
email?: string;
}
export type Browser = (typeof BrowserEnum)[keyof typeof BrowserEnum]
export const Browser: AnyCase<Browser> = createEnum(BrowserEnum)
/**
* A string literal union type representing supported browser names, derived from the values of `BrowserEnum`.
* e.g., "Chrome" | "Firefox" | ...
*/
export type Browser = ObjectValues<typeof BrowserEnum>;
export type PackageManager =
(typeof PackageManagerEnum)[keyof typeof PackageManagerEnum]
/**
* A constant intended to provide access to browser names, potentially in various casings.
* Its type `AnyCase<Browser>` suggests it can be used where case-insensitivity for browser names is needed.
* The `createEnum(BrowserEnum)` call aims to produce a representation of browser names from `BrowserEnum`.
* Note: `createEnum` from `lib/utils.ts` has a declared return type of `ObjectValues<T>` (a union of values),
* while its implementation uses `Object.values()` which returns an array. This constant will hold the
* runtime array value, but its JSDoc type refers to the more restrictive `AnyCase<Browser>` union type.
*/
export const Browser: AnyCase<Browser> = createEnum(BrowserEnum);
/**
* A string literal union type representing supported package managers, derived from the values of `PackageManagerEnum`.
* e.g., "Bun" | "PnPm" | "Npm" | "Yarn"
*/
export type PackageManager = ObjectValues<typeof PackageManagerEnum>;
/**
* A constant intended to provide access to package manager names, potentially in various casings.
* Its type `AnyCase<PackageManager>` suggests it can be used where case-insensitivity for package manager names is needed.
* Utilizes `createEnum(PackageManagerEnum)`. Refer to notes on `Browser` constant regarding `createEnum` behavior.
*/
export const PackageManager: AnyCase<PackageManager> =
createEnum(PackageManagerEnum)
createEnum(PackageManagerEnum);
export type Framework = (typeof FrameworkEnum)[keyof typeof FrameworkEnum]
export const Framework: AnyCase<Framework> = createEnum(FrameworkEnum)
/**
* A string literal union type representing supported JavaScript frameworks, derived from the values of `FrameworkEnum`.
* e.g., "React" | "Vanilla" | ...
*/
export type Framework = ObjectValues<typeof FrameworkEnum>;
/**
* A constant intended to provide access to framework names, potentially in various casings.
* Its type `AnyCase<Framework>` suggests it can be used where case-insensitivity for framework names is needed.
* Utilizes `createEnum(FrameworkEnum)`. Refer to notes on `Browser` constant regarding `createEnum` behavior.
*/
export const Framework: AnyCase<Framework> = createEnum(FrameworkEnum);
export type Style = (typeof StyleEnum)[keyof typeof StyleEnum]
export const Style: AnyCase<Style> = createEnum(StyleEnum)
/**
* A string literal union type representing supported styling options, derived from the values of `StyleEnum`.
* e.g., "Tailwind"
*/
export type Style = ObjectValues<typeof StyleEnum>;
/**
* A constant intended to provide access to style option names, potentially in various casings.
* Its type `AnyCase<Style>` suggests it can be used where case-insensitivity for style names is needed.
* Utilizes `createEnum(StyleEnum)`. Refer to notes on `Browser` constant regarding `createEnum` behavior.
*/
export const Style: AnyCase<Style> = createEnum(StyleEnum);
export type Language = (typeof LanguageEnum)[keyof typeof LanguageEnum]
export const Language: AnyCase<Language> = createEnum(LanguageEnum)
/**
* A string literal union type representing supported programming languages, derived from the values of `LanguageEnum`.
* e.g., "TypeScript" | "JavaScript"
*/
export type Language = ObjectValues<typeof LanguageEnum>;
/**
* A constant intended to provide access to programming language names, potentially in various casings.
* Its type `AnyCase<Language>` suggests it can be used where case-insensitivity for language names is needed.
* Utilizes `createEnum(LanguageEnum)`. Refer to notes on `Browser` constant regarding `createEnum` behavior.
*/
export const Language: AnyCase<Language> = createEnum(LanguageEnum);
+69 -6
View File
@@ -1,21 +1,84 @@
export type ObjectValues<T> = T[keyof T]
/**
* Extracts a union type of all values from the properties of an object type `T`.
*
* @template T - An object type (typically a Record or an enum-like object).
* @example
* type MyObject = { a: "foo", b: "bar", c: 123 };
* type MyObjectValues = ObjectValues<MyObject>; // "foo" | "bar" | 123
*/
export type ObjectValues<T> = T[keyof T];
/**
* Creates a union of an object's string values, often used to represent the set of possible values for an enum-like object.
* Note: The implementation `Object.values(enumObj) as unknown as ObjectValues<T>` returns an array at runtime,
* but the declared return type `ObjectValues<T>` is a union of the object's property values.
* This type signature suggests it's intended to represent the set of possible string values from `enumObj`.
*
* @template T - An object type where keys are strings and values are strings (e.g., `const MyEnum = { VAL_A: "A", VAL_B: "B" }`).
* @param {T} enumObj - The object from which to extract values.
* @returns {ObjectValues<T>} A union type representing all possible string values of the `enumObj`.
* For example, if `enumObj` is `{ A: "valA", B: "valB" }`, the return type is `"valA" | "valB"`.
* (Runtime behavior of `Object.values()` is to return an array like `["valA", "valB"]`).
*/
export function createEnum<T extends Record<string, string>>(enumObj: T) {
return Object.values(enumObj) as unknown as ObjectValues<T>
return Object.values(enumObj) as unknown as ObjectValues<T>;
}
/**
* Creates a union type that includes various case formats (uppercase, lowercase, capitalized, uncapitalized)
* of a given string literal type `T`.
*
* @template T - A string literal type.
* @example
* type MyString = "example";
* type MyStringAnyCase = AnyCase<MyString>; // "EXAMPLE" | "example" | "Example" | "example" (Uncapitalize<"Example"> is "example")
*/
export type AnyCase<T extends string> =
| Uppercase<T>
| Lowercase<T>
| Capitalize<T>
| Uncapitalize<T>
| Uncapitalize<T>;
/**
* Creates a union type that includes various case formats (uppercase, lowercase, capitalized, uncapitalized)
* of the union of two given string literal types `T` and `K`.
* This is useful for representing a combined set of related string constants where case variations are permitted for each.
*
* @template T - A string literal type.
* @template K - Another string literal type.
* @example
* type Lang1 = "english";
* type Lang2 = "french";
* type CombinedLangsAnyCase = AnyCaseLanguage<Lang1, Lang2>;
* // Result includes: "ENGLISH" | "english" | "English" | "FRENCH" | "french" | "French" etc.
* // for all case variations of "english" and "french".
*/
export type AnyCaseLanguage<T extends string, K extends string> =
| Uppercase<T | K>
| Lowercase<T | K>
| Capitalize<T | K>
| Uncapitalize<T | K>
| Uncapitalize<T | K>;
/**
* Extracts a new object type containing only the keys of `T` whose properties are optional
* (i.e., their type includes `undefined`). The values associated with these keys retain their original types.
*
* @template T - An object type.
* @example
* type MyObject = {
* requiredProp: string;
* optionalProp?: number;
* anotherOptional?: boolean | undefined;
* nullProp: string | null;
* };
* type MyOptionalProps = OptionalKeys<MyObject>;
* // MyOptionalProps would be conceptually equivalent to:
* // {
* // optionalProp?: number;
* // anotherOptional?: boolean | undefined;
* // }
* // The actual resulting type is an object type with only these optional keys.
*/
export type OptionalKeys<T> = {
[K in keyof T as undefined extends T[K] ? K : never]: T[K]
}
[K in keyof T as undefined extends T[K] ? K : never]: T[K];
};
+8 -2
View File
@@ -1,6 +1,6 @@
{
"name": "betterseqtaplus",
"version": "3.4.6",
"version": "3.4.8",
"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",
@@ -36,7 +36,7 @@
"@babel/plugin-transform-runtime": "^7.26.9",
"@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.0.91",
"@crxjs/vite-plugin": "2.0.0-beta.25",
"@crxjs/vite-plugin": "2.0.0-beta.32",
"@types/mime-types": "^2.1.4",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
@@ -75,15 +75,21 @@
"@uiw/codemirror-extensions-color": "^4.23.10",
"@uiw/codemirror-theme-github": "^4.23.10",
"autoprefixer": "^10.4.21",
"canvas-confetti": "^1.9.3",
"codemirror": "^6.0.1",
"color": "^5.0.0",
"dompurify": "^3.2.4",
"embeddia": "^1.2.1",
"embla-carousel-autoplay": "^8.5.2",
"embla-carousel-svelte": "^8.5.2",
"esbuild": "^0.25.3",
"events": "^3.3.0",
"flexsearch": "^0.8.147",
"fuse.js": "^7.1.0",
"idb": "^8.0.2",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"mathjs": "^14.4.0",
"million": "^3.1.11",
"motion": "^12.4.12",
"postcss": "^8.5.3",
+50 -29
View File
@@ -1,62 +1,83 @@
import {
initializeSettingsState,
settingsState,
} from "@/seqta/utils/listeners/SettingsState"
import documentLoadCSS from "@/css/documentload.scss?inline"
import icon48 from "@/resources/icons/icon-48.png?base64"
import browser from "webextension-polyfill"
} from "@/seqta/utils/listeners/SettingsState";
import documentLoadCSS from "@/css/documentload.scss?inline";
import icon48 from "@/resources/icons/icon-48.png?base64";
import browser from "webextension-polyfill";
import * as plugins from "@/plugins"
import { main } from "@/seqta/main"
import * as plugins from "@/plugins";
import { main } from "@/seqta/main";
import { delay } from "./seqta/utils/delay";
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
export let MenuOptionsOpen = false;
export let MenuOptionsOpen = false
var IsSEQTAPage = false
let hasSEQTAText = false
var IsSEQTAPage = false;
let hasSEQTAText = false;
// This check is placed outside of the document load event due to issues with EP (https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues/84)
if (document.childNodes[1]) {
hasSEQTAText =
document.childNodes[1].textContent?.includes(
"Copyright (c) SEQTA Software",
) ?? false
init()
) ?? false;
init();
}
/**
* Initializes BetterSEQTA+ on a SEQTA page.
*
* This function performs the following steps:
* 1. Verifies that the current page is a SEQTA page.
* 2. Injects CSS styles for document loading.
* 3. Changes the page's favicon.
* 4. Initializes the extension's settings state.
* 5. Sets default storage if settings are not already defined.
* 6. Calls the main function to apply core BetterSEQTA+ modifications.
* 7. Initializes legacy and new plugins if the extension is enabled.
* 8. Logs success or error messages during initialization.
*/
async function init() {
const hasSEQTATitle = document.title.includes("SEQTA Learn")
const hasSEQTATitle = document.title.includes("SEQTA Learn");
if (hasSEQTAText && hasSEQTATitle && !IsSEQTAPage) { // Verify we are on a SEQTA page
IsSEQTAPage = true
console.info("[BetterSEQTA+] Verified SEQTA Page")
if (hasSEQTAText && hasSEQTATitle && !IsSEQTAPage) {
// Verify we are on a SEQTA page
IsSEQTAPage = true;
console.info("[BetterSEQTA+] Verified SEQTA Page");
const documentLoadStyle = document.createElement("style")
documentLoadStyle.textContent = documentLoadCSS
document.head.appendChild(documentLoadStyle)
const documentLoadStyle = document.createElement("style");
documentLoadStyle.textContent = documentLoadCSS;
document.head.appendChild(documentLoadStyle);
const icon = document.querySelector('link[rel*="icon"]')! as HTMLLinkElement
icon.href = icon48 // Change the icon
const icon = document.querySelector(
'link[rel*="icon"]',
)! as HTMLLinkElement;
icon.href = icon48; // Change the icon
try {
await initializeSettingsState();
if (typeof settingsState.onoff === "undefined") {
browser.runtime.sendMessage({ type: "setDefaultStorage" })
await browser.runtime.sendMessage({ type: "setDefaultStorage" });
await delay(5);
}
await main()
await main();
plugins.Monofile();
if (settingsState.onoff) {
// Initialize legacy plugins
plugins.Monofile()
// Initialize new plugin system
await plugins.initializePlugins();
}
initializeHideSensitiveToggle();
console.info(
"[BetterSEQTA+] Successfully initialised BetterSEQTA+, starting to load assets.",
)
);
} catch (error: any) {
console.error(error)
console.error(error);
}
}
}
+69 -84
View File
@@ -1,63 +1,68 @@
import browser from 'webextension-polyfill'
import browser from "webextension-polyfill";
import type { SettingsState } from "@/types/storage";
import { fetchNews } from './background/news';
import { fetchNews } from "./background/news";
function reloadSeqtaPages() {
const result = browser.tabs.query({})
function open (tabs: any) {
const result = browser.tabs.query({});
function open(tabs: any) {
for (let tab of tabs) {
if (tab.title.includes('SEQTA Learn')) {
if (tab.title.includes("SEQTA Learn")) {
browser.tabs.reload(tab.id);
}
}
}
result.then(open, console.error)
result.then(open, console.error);
}
// @ts-ignore
browser.runtime.onMessage.addListener((request: any, _: any, sendResponse: (response?: any) => void) => {
browser.runtime.onMessage.addListener(
(request: any, _: any, sendResponse: (response?: any) => void) => {
switch (request.type) {
case 'reloadTabs':
case "reloadTabs":
reloadSeqtaPages();
break;
case 'extensionPages':
case "extensionPages":
browser.tabs.query({}).then(function (tabs) {
for (let tab of tabs) {
if (tab.url?.includes('chrome-extension://')) {
if (tab.url?.includes("chrome-extension://")) {
browser.tabs.sendMessage(tab.id!, request);
}
}
});
break;
case 'currentTab':
browser.tabs.query({ active: true, currentWindow: true }).then(function (tabs) {
browser.tabs.sendMessage(tabs[0].id!, request).then(function (response) {
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' });
case "githubTab":
browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" });
break;
case 'setDefaultStorage':
case "setDefaultStorage":
SetStorageValue(DefaultValues);
break;
case 'sendNews':
fetchNews(request.source ?? 'australia', sendResponse);
case "sendNews":
fetchNews(request.source ?? "australia", sendResponse);
return true;
default:
console.log('Unknown request type');
console.log("Unknown request type");
}
return false;
});
},
);
const DefaultValues: SettingsState = {
onoff: true,
@@ -86,66 +91,31 @@ const DefaultValues: SettingsState = {
},
menuorder: [],
subjectfilters: {},
selectedTheme: '',
selectedColor: 'linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)',
originalSelectedColor: '',
selectedTheme: "",
selectedColor:
"linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)",
originalSelectedColor: "",
DarkMode: true,
animations: true,
assessmentsAverage: true,
defaultPage: 'home',
defaultPage: "home",
shortcuts: [
{
name: 'YouTube',
enabled: false,
},
{
name: 'Outlook',
name: "Outlook",
enabled: true,
},
{
name: 'Office',
name: "Office",
enabled: true,
},
{
name: 'Spotify',
enabled: false,
},
{
name: 'Google',
name: "Google",
enabled: true,
},
{
name: 'DuckDuckGo',
enabled: false,
},
{
name: 'Cool Math Games',
enabled: false,
},
{
name: 'SACE',
enabled: false,
},
{
name: 'Google Scholar',
enabled: false,
},
{
name: 'Gmail',
enabled: false,
},
{
name: 'Netflix',
enabled: false,
},
{
name: 'Education Perfect',
enabled: false,
},
],
customshortcuts: [],
lettergrade: false,
newsSource: 'australia',
newsSource: "australia",
};
function SetStorageValue(object: any) {
@@ -158,7 +128,8 @@ function convertBksliderToSpeed(bksliderinput: number): number {
const minBase = 50;
const maxBase = 150;
const scaledValue = 2 + ((maxBase - bksliderinput) / (maxBase - minBase)) ** 4;
const scaledValue =
2 + ((maxBase - bksliderinput) / (maxBase - minBase)) ** 4;
const baseSpeed = 3;
const speed = baseSpeed / scaledValue;
@@ -166,50 +137,64 @@ function convertBksliderToSpeed(bksliderinput: number): number {
}
async function migrateLegacySettings() {
const storage = await browser.storage.local.get(null) as unknown as SettingsState;
const storage = (await browser.storage.local.get(
null,
)) as unknown as SettingsState;
// Animated Background Migration
if ('animatedbk' in storage || 'bksliderinput' in storage) {
if ("animatedbk" in storage || "bksliderinput" in storage) {
const animatedSettings = {
enabled: storage.animatedbk ?? true,
speed: storage.bksliderinput ? convertBksliderToSpeed(parseFloat(storage.bksliderinput)) : 1
speed: storage.bksliderinput
? convertBksliderToSpeed(parseFloat(storage.bksliderinput))
: 1,
};
await browser.storage.local.set({ 'plugin.animated-background.settings': animatedSettings });
await browser.storage.local.set({
"plugin.animated-background.settings": animatedSettings,
});
}
// Assessments Average Migration
if ('assessmentsAverage' in storage || 'lettergrade' in storage) {
if ("assessmentsAverage" in storage || "lettergrade" in storage) {
const assessmentsSettings = {
enabled: storage.assessmentsAverage ?? true,
lettergrade: storage.lettergrade ?? false
lettergrade: storage.lettergrade ?? false,
};
await browser.storage.local.set({ 'plugin.assessments-average.settings': assessmentsSettings });
await browser.storage.local.set({
"plugin.assessments-average.settings": assessmentsSettings,
});
}
if ('selectedTheme' in storage) {
if ("selectedTheme" in storage) {
const themesSettings = { enabled: true };
await browser.storage.local.set({ 'plugin.themes.settings': themesSettings });
await browser.storage.local.set({
"plugin.themes.settings": themesSettings,
});
}
if (storage.notificationCollector !== false) {
await browser.storage.local.set({ 'plugin.notificationCollector.settings': { enabled: true } });
await browser.storage.local.set({
"plugin.notificationCollector.settings": { enabled: true },
});
} else {
await browser.storage.local.set({ 'plugin.notificationCollector.settings': { enabled: false } });
await browser.storage.local.set({
"plugin.notificationCollector.settings": { enabled: false },
});
}
const keysToRemove = [
'animatedbk',
'bksliderinput',
'assessmentsAverage',
'lettergrade'
"animatedbk",
"bksliderinput",
"assessmentsAverage",
"lettergrade",
];
await browser.storage.local.remove(keysToRemove);
}
browser.runtime.onInstalled.addListener(function (event) {
browser.storage.local.remove(['justupdated']);
browser.storage.local.remove(['data']);
browser.storage.local.remove(["justupdated"]);
browser.storage.local.remove(["data"]);
if ( event.reason == 'install' || event.reason == 'update' ) {
if (event.reason == "install" || event.reason == "update") {
browser.storage.local.set({ justupdated: true });
migrateLegacySettings();
}
+62 -20
View File
@@ -1,17 +1,36 @@
import Parser from 'rss-parser';
import Parser from "rss-parser";
/**
* Fetches news articles specifically for Australia from the NewsAPI.
*
* This function handles a specific case for fetching Australian news. It includes a
* mechanism to retry the fetch operation by appending "%00" to the URL if a
* rate limit error (`response.code == "rateLimited"`) is encountered. This is
* likely a workaround for cache-busting or bypassing certain rate-limiting measures.
*
* @param {string} url The NewsAPI URL to fetch Australian news from.
* @param {any} sendResponse A callback function (likely from a browser extension message listener)
* to send the fetched news data back to the caller.
* It's called with an object like `{ news: responseData }`.
*/
const fetchAustraliaNews = async (url: string, sendResponse: any) => {
fetch(url)
.then((result) => result.json())
.then((response) => {
if (response.code == 'rateLimited') {
fetchAustraliaNews(url += '%00', sendResponse);
if (response.code == "rateLimited") {
fetchAustraliaNews((url += "%00"), sendResponse);
} else {
sendResponse({ news: response });
}
});
};
/**
* A record mapping lowercase country codes (e.g., "usa", "canada") to an array
* of RSS feed URLs for news sources in that country.
*
* @type {Record<string, string[]>}
*/
const rssFeedsByCountry: Record<string, string[]> = {
usa: [
"https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml",
@@ -19,20 +38,25 @@ const rssFeedsByCountry: Record<string, string[]> = {
"https://www.npr.org/rss/rss.php",
],
taiwan: [
"https://focustaiwan.tw/rss",
"https://www.taipeitimes.com/rss/all.xml",
"https://news.ltn.com.tw/rss/all.xml",
"https://www.taipeitimes.com/xml/index.rss",
"https://international.thenewslens.com/rss",
],
hong_kong: [
"https://news.rthk.hk/rthk/en/rss.htm",
"https://rthk9.rthk.hk/rthk/news/rss/e_expressnews_elocal.xml",
"https://www.scmp.com/rss/91/feed",
],
panama: [
"http://www.panama-guide.com/backend.php",
"https://critica.com.pa/rss.xml",
"https://www.panamaamerica.com.pa/rss.xml",
"https://noticiassin.com/feed/",
"https://elcapitalfinanciero.com/feed/",
],
canada: [
"https://www.cbc.ca/cmlink/rss-topstories",
"https://www.theglobeandmail.com/?service=rss",
"https://calgaryherald.com/feed",
"https://ottawacitizen.com/feed",
"https://www.montrealgazette.com/feed",
],
singapore: [
"https://www.straitstimes.com/news/singapore/rss.xml",
@@ -43,28 +67,40 @@ const rssFeedsByCountry: Record<string, string[]> = {
"https://www.theguardian.com/uk/rss",
],
japan: [
"https://www.japantimes.co.jp/feed/topstories.xml",
"https://www3.nhk.or.jp/nhkworld/en/news/feeds/",
"https://news.livedoor.com/topics/rss/int.xml",
],
netherlands: [
"https://www.dutchnews.nl/feed/",
"http://feeds.nos.nl/nosnieuwsalgemeen",
],
netherlands: ["https://www.dutchnews.nl/feed/", "https://www.nrc.nl/rss/"],
};
/**
* Fetches news articles based on a specified source.
*
* The source can be:
* 1. The string "australia": Fetches news from Australian sources via NewsAPI,
* handled by the `fetchAustraliaNews` function.
* 2. A lowercase country code (e.g., "usa", "canada"): Fetches news from a predefined
* list of RSS feeds for that country, as specified in `rssFeedsByCountry`.
* 3. A direct RSS feed URL (starting with "http"): Fetches news directly from this URL.
*
* The fetched articles are then sent back to the caller using the `sendResponse` callback.
*
* @param {string} source The news source identifier. This can be "australia", a
* lowercase country code, or a direct RSS feed URL.
* @param {any} sendResponse A callback function (typically from a browser extension
* message listener, like `chrome.runtime.onMessage`)
* 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) {
const parser = new Parser();
let feeds: string[];
console.log('fetchNews', source)
if (source === "australia") {
const date = new Date();
const from =
date.getFullYear() +
'-' +
"-" +
(date.getMonth() + 1) +
'-' +
"-" +
(date.getDate() - 5);
const url = `https://newsapi.org/v2/everything?domains=abc.net.au&from=${from}&apiKey=17c0da766ba347c89d094449504e3080`;
@@ -73,6 +109,10 @@ export async function fetchNews(source: string, sendResponse: any) {
return;
}
const parser = new Parser();
let feeds: string[];
console.log("fetchNews", source);
if (rssFeedsByCountry[source.toLowerCase()]) {
// If the source is a country, fetch from predefined feeds
feeds = rssFeedsByCountry[source.toLowerCase()];
@@ -80,7 +120,9 @@ export async function fetchNews(source: string, sendResponse: any) {
// If the source is a URL, use it directly
feeds = [source];
} else {
throw new Error("Invalid source. Provide a country code or a valid RSS feed URL.");
throw new Error(
"Invalid source. Provide a country code or a valid RSS feed URL.",
);
}
const articlesPromises = feeds.map(async (feedUrl) => {
+58 -2
View File
@@ -15,7 +15,7 @@
* along with EvenBetterSEQTA. If not, see <https://www.gnu.org/licenses/>.
*/
@use 'injected/popup.scss';
@use "injected/popup.scss";
html {
background: #161616 !important;
@@ -77,7 +77,9 @@ html {
transform-origin: top;
transition: transform 0.2s;
}
body:has(.outside-container:not(.hide)) #AddedSettings.tooltip:hover > .tooltiptext {
body:has(.outside-container:not(.hide))
#AddedSettings.tooltip:hover
> .tooltiptext {
transform: scale(0);
}
.assessmenttooltip svg {
@@ -92,3 +94,57 @@ body:has(.outside-container:not(.hide)) #AddedSettings.tooltip:hover > .tooltipt
background: var(--text-primary) !important;
color: var(--theme-primary) !important;
}
.fixed-tooltip {
display: inline-block;
z-index: 5 !important;
width: 28px;
background: none;
box-shadow: none;
padding: 2px;
position: absolute;
}
.fixed-tooltip svg {
fill: var(--theme-primary);
}
.tooltiptext-fixed {
width: 120px;
transform: scale(0);
transition: transform 0.2s;
transform-origin: top;
background: var(--background-primary);
color: var(--text-primary);
text-align: center;
border-radius: 6px;
padding: 2px;
position: fixed;
z-index: 1000;
top: 0;
left: 0;
margin-left: -62px;
}
.tooltiptext-fixed::after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: transparent transparent var(--text-primary) transparent;
}
.tooltiptext-fixed.show {
transform: scale(1);
transform-origin: top;
transition: transform 0.2s;
}
.tooltiptext-fixed p:hover {
cursor: pointer;
background: rgba(0, 0, 0, 0.3) !important;
transition: 200ms;
}
.tooltiptext-fixed p {
border-radius: 8px !important;
padding-top: 2px;
padding-bottom: 2px;
margin: 2px;
}
+1 -1
View File
@@ -1 +1 @@
import './documentload.scss';
import "./documentload.scss";
+11 -2
View File
@@ -15,7 +15,7 @@
* along with EvenBetterSEQTA. If not, see <https://www.gnu.org/licenses/>.
*/
body {
body {
background: transparent;
}
@@ -25,7 +25,9 @@
span,
body {
color: white !important;
text-shadow: 1px 1px 2px #161616, 0 0 1em #161616;
text-shadow:
1px 1px 2px #161616,
0 0 1em #161616;
}
body {
@@ -112,3 +114,10 @@
transition: text-shadow 0.5s;
}
}
.cke_panel_listItem > a {
&:hover {
background: #3d3d3e !important;
}
}
+676 -76
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -36,5 +36,7 @@
transform-origin: 70% 0;
will-change: opacity, transform;
transform: translateZ(0); // promotes GPU rendering
transition: opacity 0.05s, transform 0.05s;
transition:
opacity 0.05s,
transform 0.05s;
}
+1 -1
View File
@@ -25,7 +25,7 @@
padding-top: 2px;
}
.sub:has(ul>li.hasChildren.active) > .nav > .back {
.sub:has(ul > li.hasChildren.active) > .nav > .back {
display: none !important;
}
+8 -12
View File
@@ -8,7 +8,6 @@ html.transparencyEffects:not(.dark) {
--background-secondary: rgba(229, 231, 235, 0.6);
}
html.transparencyEffects {
/* Background Fixes */
[class*="notifications__item___"],
@@ -22,6 +21,9 @@ html.transparencyEffects {
}
/* Blurs */
.search,
.document,
.border,
.draggable,
.notice,
[class*="BasicPanel__BasicPanel___"],
@@ -37,34 +39,28 @@ html.transparencyEffects {
[class*="LabelList__selected___"],
.buttonChecklist,
.pane,
.legacy-root button, .legacy-root a,
.legacy-root button,
.legacy-root a,
[class*="MessageList__MessageList___"] {
backdrop-filter: blur(80px);
}
.filter-select,
.report {
backdrop-filter: blur(10px) !important;
}
#menu,
.kanban-column,
.whatsnewContainer,
[class*="Message__Message___"] {
backdrop-filter: blur(50px);
}
#menu {
backdrop-filter: blur(20px);
}
.title > a {
backdrop-filter: blur(0px) !important;
}
.search,
.document,
.border {
backdrop-filter: blur(80px);
}
#main > .dashboard {
section,
.dashlet {
+11 -6
View File
@@ -1,9 +1,14 @@
declare module '*.mp4';
declare module '*.woff';
declare module '*.scss';
declare module '*.png';
declare module '*.html';
declare module '*.svelte';
declare module "*.mp4";
declare module "*.woff";
declare module "*.scss";
declare module "*.png";
declare module "*.html";
declare module "*.svelte";
declare module "*?inlineWorker" {
const value: () => Worker;
export default value;
}
declare module "*.png?base64" {
const value: string;
+1 -1
View File
@@ -2,6 +2,6 @@
let { onClick, text } = $props<{ onClick: () => void, text: string, [key: string]: any }>();
</script>
<button onclick={onClick} class='px-4 py-1 text-[0.75rem] dark:bg-[#38373D] bg-[#DDDDDD] dark:text-white rounded-md'>
<button onclick={onClick} class='px-5 py-1.5 text-[0.75rem] shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg'>
{text}
</button>
+3 -3
View File
@@ -81,20 +81,20 @@
</script>
{#if standalone}
<div class="h-auto rounded-xl overflow-clip">
<div class="h-auto overflow-clip rounded-xl">
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} />
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={background}
class="absolute top-0 left-0 z-50 flex items-center justify-center w-full h-full cursor-pointer bg-black/20"
class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full shadow-2xl cursor-pointer bg-black/20 border border-[#DDDDDD]/30 dark:border-[#38373D]/30"
onclick={handleBackgroundClick}
onkeydown={(e) => { e.key === 'Enter' && handleBackgroundClick }}
>
<div
bind:this={content}
class="h-auto p-4 bg-white border shadow-lg cursor-auto rounded-xl dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
class="p-4 h-auto bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
>
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} />
</div>
+42 -27
View File
@@ -1,6 +1,6 @@
import ColorPicker from "react-best-gradient-color-picker"
import { useEffect, useRef, useState } from "react"
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
import ColorPicker from "react-best-gradient-color-picker";
import { useEffect, useRef, useState } from "react";
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts";
const defaultPresets = [
"linear-gradient(30deg, rgba(229,209,218,1) 0%, RGBA(235,169,202,1) 46%, rgba(214,155,162,1) 100%)",
@@ -22,12 +22,12 @@ const defaultPresets = [
"rgba(30, 64, 175, 0.89)",
"rgba(134, 25, 143, 1)",
"rgba(14, 165, 233, 0.9)",
]
];
interface PickerProps {
customOnChange?: (color: string) => void
customState?: string
savePresets?: boolean
customOnChange?: (color: string) => void;
customState?: string;
savePresets?: boolean;
}
export default function Picker({
@@ -35,32 +35,44 @@ export default function Picker({
customState,
savePresets = true,
}: PickerProps) {
const [customThemeColor, setCustomThemeColor] = useState<string | null>()
const [presets, setPresets] = useState<string[]>()
const [customThemeColor, setCustomThemeColor] = useState<string | null>();
const [presets, setPresets] = useState<string[]>();
const latestValuesRef = useRef({ customThemeColor, customOnChange, savePresets, presets });
const latestValuesRef = useRef({
customThemeColor,
customOnChange,
savePresets,
presets,
});
useEffect(() => {
if (customState !== undefined && customState !== null) {
setCustomThemeColor(customState)
setCustomThemeColor(customState);
} else {
setCustomThemeColor(settingsState.selectedColor ?? null)
setCustomThemeColor(settingsState.selectedColor ?? null);
}
if (presets === undefined) {
const savedPresets = localStorage.getItem("colorPickerPresets")
setPresets(savedPresets ? JSON.parse(savedPresets) : defaultPresets)
const savedPresets = localStorage.getItem("colorPickerPresets");
setPresets(savedPresets ? JSON.parse(savedPresets) : defaultPresets);
}
}, [])
}, []);
useEffect(() => {
latestValuesRef.current = { customThemeColor, customOnChange, savePresets, presets };
latestValuesRef.current = {
customThemeColor,
customOnChange,
savePresets,
presets,
};
}, [customThemeColor, customOnChange, savePresets, presets]);
useEffect(() => {
return () => {
const { customThemeColor, customOnChange, savePresets, presets } = latestValuesRef.current;
if (!(customThemeColor && !customOnChange && savePresets && presets)) return;
const { customThemeColor, customOnChange, savePresets, presets } =
latestValuesRef.current;
if (!(customThemeColor && !customOnChange && savePresets && presets))
return;
// Only proceed if presets are different (avoid unnecessary updates)
const existingIndex = presets.indexOf(customThemeColor);
@@ -79,15 +91,18 @@ export default function Picker({
updatedPresets = [customThemeColor, ...presets].slice(0, 18);
}
localStorage.setItem("colorPickerPresets", JSON.stringify(updatedPresets));
}
}, [])
localStorage.setItem(
"colorPickerPresets",
JSON.stringify(updatedPresets),
);
};
}, []);
useEffect(() => {
if (customThemeColor && !customOnChange) {
settingsState.selectedColor = customThemeColor
settingsState.selectedColor = customThemeColor;
}
}, [customThemeColor, customOnChange])
}, [customThemeColor, customOnChange]);
return (
<ColorPicker
@@ -97,12 +112,12 @@ export default function Picker({
value={customThemeColor ?? ""}
onChange={(color: string) => {
if (customOnChange) {
customOnChange(color)
setCustomThemeColor(color)
customOnChange(color);
setCustomThemeColor(color);
} else {
setCustomThemeColor(color)
setCustomThemeColor(color);
}
}}
/>
)
);
}
+227
View File
@@ -0,0 +1,227 @@
<script lang="ts">
import { isValidHotkey, parseHotkey } from '@/plugins/built-in/globalSearch/src/utils/hotkeyUtils';
let { value, onChange } = $props<{
value: string,
onChange: (newValue: string) => void
}>();
let isRecording = $state(false);
let recordedKeys = $state<Set<string>>(new Set());
let inputElement = $state<HTMLInputElement>();
const formatKeyForHotkey = (key: string): string => {
// Map special keys to their hotkey format
const keyMap: Record<string, string> = {
'Control': 'ctrl',
'Meta': 'cmd',
'Alt': 'alt',
'Shift': 'shift',
' ': 'space',
'ArrowUp': 'up',
'ArrowDown': 'down',
'ArrowLeft': 'left',
'ArrowRight': 'right',
'Escape': 'esc',
'Enter': 'enter',
'Tab': 'tab',
'Backspace': 'backspace',
'Delete': 'delete',
};
return keyMap[key] || key.toLowerCase();
};
const formatKeyForDisplay = (key: string): string => {
// Map keys to their display format
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const keyMap: Record<string, string> = {
'ctrl': isMac ? '⌃' : 'Ctrl',
'cmd': '⌘',
'meta': '⌘',
'alt': isMac ? '⌥' : 'Alt',
'shift': isMac ? '⇧' : 'Shift',
'space': 'Space',
'up': '↑',
'down': '↓',
'left': '←',
'right': '→',
'esc': 'Esc',
'enter': 'Enter',
'tab': 'Tab',
'backspace': 'Backspace',
'delete': 'Delete',
};
return keyMap[key.toLowerCase()] || key.toUpperCase();
};
const getHotkeyParts = (hotkeyString: string): string[] => {
if (!hotkeyString || !isValidHotkey(hotkeyString)) {
return [];
}
const parsed = parseHotkey(hotkeyString);
const parts: string[] = [];
// Add modifiers in a consistent order
if (parsed.ctrl) parts.push('ctrl');
if (parsed.meta) parts.push('cmd');
if (parsed.alt) parts.push('alt');
if (parsed.shift) parts.push('shift');
// Add the main key
if (parsed.key) parts.push(parsed.key);
return parts;
};
const startRecording = () => {
isRecording = true;
recordedKeys.clear();
inputElement?.focus();
};
const stopRecording = () => {
if (recordedKeys.size > 0) {
if (recordedKeys.has('esc')) {
onChange('');
isRecording = false;
recordedKeys.clear();
inputElement?.blur();
return;
}
// Build the hotkey string
const modifiers: string[] = [];
let mainKey = '';
for (const key of recordedKeys) {
if (['ctrl', 'cmd', 'alt', 'shift'].includes(key)) {
modifiers.push(key);
} else {
mainKey = key;
}
}
if (mainKey) {
const hotkeyString = [...modifiers, mainKey].join('+');
if (isValidHotkey(hotkeyString)) {
onChange(hotkeyString);
}
}
}
isRecording = false;
recordedKeys.clear();
inputElement?.blur();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!isRecording) return;
e.preventDefault();
e.stopPropagation();
const key = formatKeyForHotkey(e.key);
// Add modifiers
if (e.ctrlKey) recordedKeys.add('ctrl');
if (e.metaKey) recordedKeys.add('cmd');
if (e.altKey) recordedKeys.add('alt');
if (e.shiftKey) recordedKeys.add('shift');
// Add the main key (ignore modifier keys themselves)
if (!['ctrl', 'cmd', 'alt', 'shift'].includes(key)) {
recordedKeys.add(key);
}
// Auto-stop recording if we have a main key
if (!['ctrl', 'cmd', 'alt', 'shift'].includes(key)) {
setTimeout(stopRecording, 100);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (!isRecording) return;
e.preventDefault();
e.stopPropagation();
};
const handleBlur = () => {
if (isRecording) {
stopRecording();
}
};
$effect(() => {
if (isRecording && inputElement) {
inputElement.focus();
}
});
// Get the parts to display
const hotkeyParts = $derived(isRecording
? Array.from(recordedKeys).map(formatKeyForDisplay)
: getHotkeyParts(value).map(formatKeyForDisplay));
</script>
<div class="flex gap-2 items-center">
<div class="relative">
{#if isRecording}
<!-- Recording state -->
<div
class="flex items-center justify-center px-3 py-1.5 text-sm rounded-md dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white border cursor-pointer text-nowrap"
onclick={startRecording}
onkeydown={startRecording}
role="button"
tabindex="0"
>
Press keys...
</div>
{:else if hotkeyParts.length > 0}
<!-- Display current hotkey -->
<div
class="flex gap-1 items-center text-sm rounded-md border-none cursor-pointer dark:text-white"
onclick={startRecording}
onkeydown={startRecording}
role="button"
tabindex="0"
>
{#each hotkeyParts as part}
<div class="size-8 text-sm flex items-center justify-center rounded-md border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30">
{part}
</div>
{/each}
</div>
{:else}
<!-- Empty state -->
<div
class="flex items-center justify-center px-3 py-2 text-sm rounded-md dark:bg-[#38373D]/50 bg-[#DDDDDD] dark:text-white border-none cursor-pointer text-nowrap"
onclick={startRecording}
onkeydown={startRecording}
role="button"
tabindex="0"
>
<span class="text-gray-500 dark:text-gray-400">Click to set</span>
</div>
{/if}
<!-- Hidden input for focus management -->
<input
bind:this={inputElement}
type="text"
readonly
class="absolute inset-0 opacity-0 pointer-events-none"
onkeydown={handleKeyDown}
onkeyup={handleKeyUp}
onblur={handleBlur}
/>
</div>
</div>
<style>
input:focus {
outline: none;
}
</style>
+2 -1
View File
@@ -5,7 +5,8 @@
</script>
<button
aria-label="Color Picker Swatch"
onclick={onClick}
style="background: {$settingsState.selectedColor}"
class="w-16 h-8 rounded-md"
class="w-16 h-8 rounded-md shadow-2xl ring-[1px] ring-[#DDDDDD]/30 dark:ring-[#38373D]/30"
></button>
+6 -4
View File
@@ -8,15 +8,17 @@
let select: HTMLSelectElement;
</script>
<select
<div class="border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 shadow-2xl rounded-lg w-full overflow-clip">
<select
bind:this={select}
value={state}
onchange={() => onChange(select.value)}
class="px-4 py-1 text-[0.75rem] dark:bg-[#38373D] bg-[#DDDDDD] dark:text-white rounded-md w-full"
>
class="px-4 py-1 text-[0.75rem] dark:text-white w-full border-none bg-transparent focus:ring-0 focus:bg-white/20 dark:focus:bg-black/10"
>
{#each options as option}
<option value={option.value}>
{option.label}
</option>
{/each}
</select>
</select>
</div>
+3 -2
View File
@@ -16,9 +16,9 @@
max={max}
step={step}
bind:value={state}
style={`background: linear-gradient(to right, #30D259 ${percentage}%, #dddddd ${percentage}%)`}
style={`background: linear-gradient(to right, #30d259ad 0%, #30D259 ${percentage}%, #dddddd ${percentage}%)`}
onchange={(e) => onChange(Number(e.currentTarget.value))}
class="w-full h-1 rounded-full appearance-none cursor-pointer dark:bg-[#38373D] bg-[#DDDDDD] slider"
class="w-full h-1 rounded-full appearance-none cursor-pointer slider"
/>
</div>
@@ -38,6 +38,7 @@
height: 24px;
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.3);
background: white;
color: #30d259ad;
cursor: pointer;
border-radius: 50%;
}
+1 -1
View File
@@ -1,4 +1,4 @@
.dark .switch[data-ison="true"],
.switch[data-ison="true"] {
background-color: #30D259;
background-color: #30d259;
}
+1 -8
View File
@@ -30,8 +30,7 @@
</script>
<div
class="flex w-14 p-1 cursor-pointer transition-all duration-150 rounded-full dark:bg-[#38373D] bg-[#DDDDDD] switch select-none"
data-ison={state}
class="flex w-14 p-1 cursor-pointer transition-all duration-150 rounded-full bg-gradient-to-tr select-none shadow-2xl ring-[1px] ring-[#DDDDDD]/30 dark:ring-[#38373D]/30 {state ? 'to-[#30D259]/80 from-[#30D259] dark:from-[#30D259]/40 dark:to-[#30D259]' : 'dark:from-[#38373D]/50 dark:to-[#38373D] to-[#DDDDDD]/50 from-[#DDDDDD]'}"
onclick={() => onChange(!state)}
onkeydown={(e) => e.key === "Enter" && onChange(!state)}
role="switch"
@@ -43,9 +42,3 @@
class="w-6 h-6 bg-white dark:bg-[#FEFEFE] rounded-full drop-shadow-md"
></div>
</div>
<style>
.switch[data-ison="true"] {
background-color: #30D259;
}
</style>
@@ -43,7 +43,7 @@
<div class="top-0 z-10 text-[0.875rem] pb-0.5 mx-4 px-2 tab-width-container">
<div bind:this={containerRef} class="flex relative">
<MotionDiv
class="absolute top-0 left-0 z-0 h-full bg-[#DDDDDD] dark:bg-[#38373D] rounded-full opacity-40 tab-width"
class="absolute top-0 left-0 z-0 h-full bg-gradient-to-tr dark:from-[#38373D]/80 dark:to-[#38373D] from-[#DDDDDD]/80 to-[#DDDDDD] rounded-full opacity-40 tab-width"
animate={{ x: calcXPos(activeTab) }}
transition={springTransition}
/>
@@ -65,8 +65,9 @@
>
<div class="flex">
{#each tabs as { Content, props }, index}
<div class="absolute focus:outline-none w-full transition-opacity duration-300 overflow-y-scroll no-scrollbar h-full tab {activeTab === index ? 'opacity-100 active' : 'opacity-0'}"
<div class="absolute focus:outline-none w-full pt-2 transition-opacity duration-300 overflow-y-scroll no-scrollbar pb-2 h-full tab {activeTab === index ? 'opacity-100 active' : 'opacity-0'}"
style="left: {index * 100}%;">
<div style="left: {index * 100}%;" class="fixed top-0 w-full h-8 bg-gradient-to-b to-transparent pointer-events-none z-[100] from-white dark:from-zinc-800 dark:to-transparent"></div>
<Content {...props} />
</div>
{/each}
@@ -2,7 +2,7 @@
import { hasEnoughStorageSpace, isIndexedDBSupported, writeData, openDatabase, readAllData, deleteData } from '@/interface/hooks/BackgroundDataLoader';
import Spinner from '../Spinner.svelte';
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
import Fuse from 'fuse.js';
import { Index } from 'flexsearch';
import { backgroundUpdates } from '@/interface/hooks/BackgroundUpdates'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
@@ -20,19 +20,12 @@
let savedBackgrounds = $state<string[]>([]);
let installingBackgrounds = $state<Set<string>>(new Set());
let debugInfo = $state<string>('');
let searchIndex = $state<Index | null>(null);
// New state variables
let activeTab = $state<'all' | 'installed' | 'photos' | 'videos'>('all');
let sortBy = $state<'newest' | 'popular' | 'name'>('newest');
// Add Fuse.js options
const fuseOptions = {
keys: ['name', 'description'],
threshold: 0.4,
ignoreLocation: true
};
let fuse: Fuse<Background>;
// Existing functions
const loadStore = async () => {
try {
@@ -43,7 +36,19 @@
}
const data = await response.json();
backgrounds = data.backgrounds;
fuse = new Fuse(backgrounds, fuseOptions);
// Initialize FlexSearch index
const index = new Index({
tokenize: "forward",
preset: "score"
});
// Add backgrounds to the index
backgrounds.forEach((bg, i) => {
index.add(i, bg.name + " " + bg.description);
});
searchIndex = index;
debugInfo = `Loaded ${backgrounds.length} backgrounds`;
await loadSavedBackgrounds();
} catch (e) {
@@ -74,14 +79,10 @@
let filteredBackgrounds = $derived((() => {
let filtered = backgrounds;
// Use Fuse.js search if there's a search term
if (searchTerm.trim()) {
// @ts-ignore
if (fuse) {
filtered = fuse.search(searchTerm).map((result: any) => result.item) ?? [];
} else {
filtered = backgrounds.filter(bg => bg.name.toLowerCase().includes(searchTerm.toLowerCase()));
}
// Use FlexSearch if there's a search term
if (searchTerm.trim() && searchIndex) {
const results = searchIndex.search(searchTerm) as number[];
filtered = results.map(i => backgrounds[i]);
}
// Apply category filtering
@@ -14,12 +14,14 @@
let isDragging = $state(false);
let tempTheme = $state(null);
const handleThemeClick = async (theme: CustomTheme) => {
const handleThemeClick = async (theme: CustomTheme, e: MouseEvent) => {
if (isEditMode) return;
if (theme.id === themes?.selectedTheme) {
themeManager.setTransitionPoint(e.clientX, e.clientY);
await themeManager.disableTheme();
themes.selectedTheme = '';
} else {
themeManager.setTransitionPoint(e.clientX, e.clientY);
await themeManager.setTheme(theme.id);
if (!themes) return;
themes.selectedTheme = theme.id;
@@ -127,7 +129,7 @@
{#each themes.themes as theme (theme.id)}
<button
class="relative group w-full aspect-theme flex justify-center items-center rounded-xl transition ring dark:ring-white ring-zinc-300 {theme.id === themes.selectedTheme ? 'dark:ring-2 ring-4' : 'ring-0'}"
onclick={() => handleThemeClick(theme)}
onclick={(e) => handleThemeClick(theme, e)}
>
{#if isEditMode}
<div
+101 -18
View File
@@ -1,8 +1,20 @@
import { type DBSchema, type IDBPDatabase, openDB } from 'idb';
import { type DBSchema, type IDBPDatabase, openDB } from "idb";
/**
* Defines the schema for the IndexedDB database used for storing background image data.
*
* @interface BackgroundDB
* @extends {DBSchema}
* @property {object} backgrounds - The object store for background images.
* @property {string} backgrounds.key - The type of the key for the object store (in this case, it's `id` as defined in `keyPath`).
* @property {object} backgrounds.value - The structure of the objects stored.
* @property {string} backgrounds.value.id - The unique identifier for the background image record.
* @property {string} backgrounds.value.type - The MIME type of the image (e.g., "image/png", "image/jpeg").
* @property {Blob} backgrounds.value.blob - The binary large object (Blob) containing the image data.
*/
interface BackgroundDB extends DBSchema {
backgrounds: {
key: string;
key: string; // Corresponds to the 'id' property due to keyPath: "id"
value: {
id: string;
type: string;
@@ -13,43 +25,100 @@ interface BackgroundDB extends DBSchema {
let db: IDBPDatabase<BackgroundDB> | null = null;
/**
* Initializes and opens an IndexedDB connection or returns an existing one.
* If the database doesn't exist or needs an upgrade, the `upgrade` callback
* creates the 'backgrounds' object store with 'id' as the keyPath.
*
* @async
* @returns {Promise<IDBPDatabase<BackgroundDB>>} A promise that resolves with the database instance.
*/
export async function openDatabase(): Promise<IDBPDatabase<BackgroundDB>> {
if (db) return db;
db = await openDB<BackgroundDB>('BackgroundDB', 1, {
db = await openDB<BackgroundDB>("BackgroundDB", 1, {
upgrade(db: IDBPDatabase<BackgroundDB>) {
db.createObjectStore('backgrounds', { keyPath: 'id' });
db.createObjectStore("backgrounds", { keyPath: "id" });
},
});
return db;
}
export async function readAllData(): Promise<Array<{ id: string; type: string; blob: Blob }>> {
/**
* Retrieves all background image records from the 'backgrounds' object store in IndexedDB.
*
* @async
* @returns {Promise<Array<{id: string, type: string, blob: Blob}>>} A promise that resolves with an array of all background image records.
*/
export async function readAllData(): Promise<
Array<{ id: string; type: string; blob: Blob }>
> {
const db = await openDatabase();
return db.getAll('backgrounds');
return db.getAll("backgrounds");
}
export async function writeData(id: string, type: string, blob: Blob): Promise<void> {
/**
* Writes or updates a background image record in the 'backgrounds' object store.
* If a record with the given `id` already exists, it will be updated. Otherwise, a new record is created.
*
* @async
* @param {string} id - The unique identifier for the background image record.
* @param {string} type - The MIME type of the image (e.g., "image/png").
* @param {Blob} blob - The Blob object containing the image data.
* @returns {Promise<void>} A promise that resolves when the data has been successfully written.
*/
export async function writeData(
id: string,
type: string,
blob: Blob,
): Promise<void> {
const db = await openDatabase();
await db.put('backgrounds', { id, type, blob });
await db.put("backgrounds", { id, type, blob });
}
/**
* Deletes a background image record from the 'backgrounds' object store by its ID.
*
* @async
* @param {string} id - The unique identifier of the background image record to delete.
* @returns {Promise<void>} A promise that resolves when the data has been successfully deleted.
*/
export async function deleteData(id: string): Promise<void> {
const db = await openDatabase();
await db.delete('backgrounds', id);
await db.delete("backgrounds", id);
}
/**
* Clears all records from the 'backgrounds' object store in IndexedDB.
*
* @async
* @returns {Promise<void>} A promise that resolves when all data has been successfully cleared.
*/
export async function clearAllData(): Promise<void> {
const db = await openDatabase();
await db.clear('backgrounds');
await db.clear("backgrounds");
}
export async function getDataById(id: string): Promise<{ id: string; type: string; blob: Blob } | undefined> {
/**
* Retrieves a single background image record from the 'backgrounds' object store by its ID.
*
* @async
* @param {string} id - The unique identifier of the background image record to retrieve.
* @returns {Promise<{id: string, type: string, blob: Blob} | undefined>} A promise that resolves with the
* background image record if found, or undefined otherwise.
*/
export async function getDataById(
id: string,
): Promise<{ id: string; type: string; blob: Blob } | undefined> {
const db = await openDatabase();
return db.get('backgrounds', id);
return db.get("backgrounds", id);
}
/**
* Closes the active IndexedDB connection and nullifies the global `db` variable.
* This is important to release resources and allow for proper database management.
*/
export function closeDatabase(): void {
if (db) {
db.close();
@@ -57,17 +126,31 @@ export function closeDatabase(): void {
}
}
// Helper function to check if IndexedDB is supported
/**
* Checks if IndexedDB is supported by the current browser environment.
*
* @returns {boolean} True if IndexedDB is supported, false otherwise.
*/
export function isIndexedDBSupported(): boolean {
return 'indexedDB' in window;
return "indexedDB" in window;
}
// Helper function to check if there's enough storage space
export async function hasEnoughStorageSpace(requiredSpace: number): Promise<boolean> {
if ('storage' in navigator && 'estimate' in navigator.storage) {
/**
* Estimates available storage space and checks if it's sufficient for the specified `requiredSpace`.
* Uses the `navigator.storage.estimate()` API if available.
* If the API is not available or cannot determine space, it defaults to assuming enough space is available.
*
* @async
* @param {number} requiredSpace - The amount of storage space required, in bytes.
* @returns {Promise<boolean>} A promise that resolves with true if enough space is estimated to be available, false otherwise.
*/
export async function hasEnoughStorageSpace(
requiredSpace: number,
): Promise<boolean> {
if ("storage" in navigator && "estimate" in navigator.storage) {
const { quota, usage } = await navigator.storage.estimate();
if (quota !== undefined && usage !== undefined) {
return (quota - usage) > requiredSpace;
return quota - usage > requiredSpace;
}
}
// If we can't determine, assume there's enough space
+28 -1
View File
@@ -1,11 +1,21 @@
type BackgroundUpdateCallback = () => void;
/**
* A singleton class used to notify listeners about generic background updates or events.
* These updates typically signify that UI components or other parts of the application
* might need to refresh or re-evaluate background-related data (e.g., after a new background
* image is added, removed, or changed).
*/
class BackgroundUpdates {
private static instance: BackgroundUpdates;
private listeners: Set<BackgroundUpdateCallback> = new Set();
private constructor() {}
/**
* Gets the singleton instance of the BackgroundUpdates class.
* @returns {BackgroundUpdates} The singleton instance.
*/
public static getInstance(): BackgroundUpdates {
if (!BackgroundUpdates.instance) {
BackgroundUpdates.instance = new BackgroundUpdates();
@@ -13,16 +23,33 @@ class BackgroundUpdates {
return BackgroundUpdates.instance;
}
/**
* Registers a callback function to be invoked when a background update is triggered.
*
* @param {BackgroundUpdateCallback} callback The function to call when a background update occurs.
* This callback takes no arguments and returns void.
*/
public addListener(callback: BackgroundUpdateCallback): void {
this.listeners.add(callback);
}
/**
* Unregisters a previously added callback function.
* After calling this method, the provided callback will no longer be invoked when a background update is triggered.
*
* @param {BackgroundUpdateCallback} callback The callback function to remove from the listeners.
*/
public removeListener(callback: BackgroundUpdateCallback): void {
this.listeners.delete(callback);
}
/**
* Invokes all registered listener callbacks, signifying that a background update has occurred.
* This method should be called whenever a change to background data happens that requires
* other parts of the application to be notified.
*/
public triggerUpdate(): void {
this.listeners.forEach(callback => callback());
this.listeners.forEach((callback) => callback());
}
}
+18 -2
View File
@@ -7,7 +7,7 @@ type SettingsPopupCallback = () => void;
* settingsPopup.addListener(() => {
* console.log('Settings popup closed');
* });
*/
*/
class SettingsPopup {
private static instance: SettingsPopup;
private listeners: Set<SettingsPopupCallback> = new Set();
@@ -21,16 +21,32 @@ class SettingsPopup {
return SettingsPopup.instance;
}
/**
* Registers a callback function to be invoked when the settings popup is closed.
*
* @param {SettingsPopupCallback} callback The function to call when the settings popup closes.
* This callback takes no arguments and returns void.
*/
public addListener(callback: SettingsPopupCallback): void {
this.listeners.add(callback);
}
/**
* Unregisters a previously added callback function.
* After calling this method, the provided callback will no longer be invoked when the settings popup closes.
*
* @param {SettingsPopupCallback} callback The callback function to remove from the listeners.
*/
public removeListener(callback: SettingsPopupCallback): void {
this.listeners.delete(callback);
}
/**
* Invokes all registered listener callbacks.
* This method should be called when the settings popup is closed to notify all subscribed components or services.
*/
public triggerClose(): void {
this.listeners.forEach(callback => callback());
this.listeners.forEach((callback) => callback());
}
}
+28 -1
View File
@@ -1,11 +1,21 @@
type ThemeUpdateCallback = () => void;
/**
* A singleton class used to notify listeners about theme-related updates.
* These updates can include events like theme changes, custom theme modifications,
* or any other event that might require UI components to refresh their appearance
* or re-apply theme styles.
*/
class ThemeUpdates {
private static instance: ThemeUpdates;
private listeners: Set<ThemeUpdateCallback> = new Set();
private constructor() {}
/**
* Gets the singleton instance of the ThemeUpdates class.
* @returns {ThemeUpdates} The singleton instance.
*/
public static getInstance(): ThemeUpdates {
if (!ThemeUpdates.instance) {
ThemeUpdates.instance = new ThemeUpdates();
@@ -13,16 +23,33 @@ class ThemeUpdates {
return ThemeUpdates.instance;
}
/**
* Registers a callback function to be invoked when a theme update is triggered.
*
* @param {ThemeUpdateCallback} callback The function to call when a theme update occurs.
* This callback takes no arguments and returns void.
*/
public addListener(callback: ThemeUpdateCallback): void {
this.listeners.add(callback);
}
/**
* Unregisters a previously added callback function.
* After calling this method, the provided callback will no longer be invoked when a theme update is triggered.
*
* @param {ThemeUpdateCallback} callback The callback function to remove from the listeners.
*/
public removeListener(callback: ThemeUpdateCallback): void {
this.listeners.delete(callback);
}
/**
* Invokes all registered listener callbacks, signifying that a theme-related update has occurred.
* This method should be called whenever a change related to themes happens that requires
* other parts of the application to be notified.
*/
public triggerUpdate(): void {
this.listeners.forEach(callback => callback());
this.listeners.forEach((callback) => callback());
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
@import './components/ColourPicker.css';
@import "./components/ColourPicker.css";
@tailwind base;
@tailwind components;
+1 -1
View File
@@ -6,7 +6,7 @@
<title>BetterSEQTA+ Settings</title>
</head>
<body class="h-[600px]">
<div id="app" style="height: 100%;"></div>
<div id="app" style="height: 100%"></div>
<script type="module" src="./index.ts"></script>
</body>
</html>
+20 -15
View File
@@ -1,29 +1,34 @@
import "./index.css"
import Settings from "./pages/settings.svelte"
import IconFamily from '@/resources/fonts/IconFamily.woff'
import browser from "webextension-polyfill"
import renderSvelte from "./main"
import "./index.css";
import Settings from "./pages/settings.svelte";
import IconFamily from "@/resources/fonts/IconFamily.woff";
import browser from "webextension-polyfill";
import renderSvelte from "./main";
import { initializeSettingsState } from "@/seqta/utils/listeners/SettingsState";
function InjectCustomIcons() {
console.info('[BetterSEQTA+] Injecting Icons')
console.info("[BetterSEQTA+] Injecting Icons");
const style = document.createElement('style')
style.setAttribute('type', 'text/css')
const style = document.createElement("style");
style.setAttribute("type", "text/css");
style.innerHTML = `
@font-face {
font-family: 'IconFamily';
src: url('${browser.runtime.getURL(IconFamily)}') format('woff');
font-weight: normal;
font-style: normal;
}`
document.head.appendChild(style)
}`;
document.head.appendChild(style);
}
const mountPoint = document.getElementById('app')
const mountPoint = document.getElementById("app");
if (!mountPoint) {
console.error('Mount point #app not found')
throw new Error('Mount point #app not found')
console.error("Mount point #app not found");
throw new Error("Mount point #app not found");
}
InjectCustomIcons()
renderSvelte(Settings, mountPoint, { standalone: true })
InjectCustomIcons();
(async () => {
await initializeSettingsState();
renderSvelte(Settings, mountPoint, { standalone: true });
})();
+1 -1
View File
@@ -1,4 +1,4 @@
import './index.css';
import "./index.css";
declare module "*.png";
declare module "*.svg";
+11 -9
View File
@@ -1,9 +1,9 @@
import { mount } from "svelte"
import type { ComponentType } from "svelte"
import style from './index.css?inline'
import { mount } from "svelte";
import type { SvelteComponent } from "svelte";
import style from "./index.css?inline";
export default function renderSvelte(
Component: ComponentType | any,
Component: SvelteComponent | any,
mountPoint: ShadowRoot | HTMLElement,
props: Record<string, any> = {},
) {
@@ -13,11 +13,13 @@ export default function renderSvelte(
standalone: false,
...props,
},
})
});
const styleElement = document.createElement('style')
styleElement.textContent = style
mountPoint.appendChild(styleElement)
if (mountPoint instanceof ShadowRoot) {
const styleElement = document.createElement("style");
styleElement.textContent = style;
mountPoint.appendChild(styleElement);
}
return app
return app;
}
+3 -5
View File
@@ -7,7 +7,7 @@
import { standalone as StandaloneStore } from '../utils/standalone.svelte';
import { onMount } from 'svelte'
import { initializeSettingsState, settingsState } from '@/seqta/utils/listeners/SettingsState'
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
import { OpenAboutPage } from "@/seqta/utils/Openers/OpenAboutPage"
@@ -52,21 +52,19 @@
let { standalone } = $props<{ standalone?: boolean }>();
let showColourPicker = $state<boolean>(false);
onMount(() => {
onMount(async () => {
settingsPopup.addListener(() => {
showColourPicker = false;
});
if (!standalone) return;
initializeSettingsState();
console.log('settingsState', $settingsState);
StandaloneStore.setStandalone(true);
});
</script>
<div class="w-[384px] no-scrollbar shadow-2xl {$settingsState.DarkMode ? 'dark' : ''} { standalone ? 'h-[600px]' : 'h-full rounded-xl' } overflow-clip">
<div class="flex relative flex-col gap-2 h-full overflow-clip bg-white dark:bg-zinc-800 dark:text-white">
<div class="grid place-items-center border-b border-b-zinc-200/40">
<div class="grid place-items-center border-b border-b-zinc-200/40 dark:border-b-zinc-700/40">
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<img src={browser.runtime.getURL('resources/icons/betterseqta-dark-full.png')} class="w-4/5 dark:hidden" alt="Light logo" onclick={handleDevModeToggle} />
+68 -11
View File
@@ -3,16 +3,16 @@
import Button from "../../components/Button.svelte"
import Slider from "../../components/Slider.svelte"
import Select from "@/interface/components/Select.svelte"
import HotkeyInput from "@/interface/components/HotkeyInput.svelte"
import browser from "webextension-polyfill"
import type { SettingsList } from "@/interface/types/SettingsProps"
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent"
import { getAllPluginSettings } from "@/plugins"
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting } from "@/plugins/core/types"
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
// Union type representing all possible settings
type SettingType =
@@ -23,12 +23,26 @@
type: 'select',
id: string,
options: string[]
}) |
(Omit<ButtonSetting, 'type'> & {
type: 'button',
id: string
}) |
(Omit<HotkeySetting, 'type'> & {
type: 'hotkey',
id: string
}) |
(Omit<ComponentSetting, 'type'> & {
type: 'component',
id: string,
component: any
});
interface Plugin {
pluginId: string;
name: string;
description: string;
beta?: boolean;
settings: Record<string, SettingType>;
}
@@ -45,7 +59,11 @@
pluginSettingsValues[plugin.pluginId] = stored[storageKey] || {};
for (const [key, setting] of Object.entries(plugin.settings)) {
if (pluginSettingsValues[plugin.pluginId][key] === undefined) {
if (
pluginSettingsValues[plugin.pluginId][key] === undefined &&
setting.type !== 'button' &&
setting.type !== 'component'
) {
pluginSettingsValues[plugin.pluginId][key] = setting.default;
}
}
@@ -184,12 +202,20 @@
{/each}
{#each pluginSettings as plugin}
<div>
<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' : ''}">
<!-- Always show enable toggle if disableToggle is true -->
{#if (plugin as any).disableToggle}
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Enable {plugin.name}</h2>
<h2 class="flex gap-2 items-center text-sm font-bold">
Enable {plugin.name}
{#if plugin.beta}
<span class="px-2 py-0.5 text-xs font-medium text-orange-800 bg-orange-100 rounded-full border border-orange-300/30 dark:bg-orange-900/30 dark:text-orange-300 dark:border-orange-900/30">
Beta
</span>
{/if}
</h2>
<p class="text-xs">{plugin.description}</p>
</div>
<div>
@@ -201,7 +227,6 @@
</div>
{/if}
<!-- Only show other settings if plugin is enabled or has no disableToggle -->
{#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)}
{#each Object.entries(plugin.settings) as [key, setting]}
<!-- Skip the 'enabled' setting if it's part of the settings object -->
@@ -228,7 +253,7 @@
{:else if setting.type === 'string'}
<input
type="text"
class="px-2 py-1 text-sm rounded-md dark:bg-[#38373D] bg-[#DDDDDD] dark:text-white"
class="px-2 py-1 text-sm rounded-md dark:bg-[#38373D]/50 bg-[#DDDDDD] dark:text-white border-none"
value={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
oninput={(e) => updatePluginSetting(plugin.pluginId, key, e.currentTarget.value)}
/>
@@ -241,6 +266,21 @@
label: opt.charAt(0).toUpperCase() + opt.slice(1)
}))}
/>
{:else if setting.type === 'button'}
<Button
onClick={() => setting.trigger?.()}
text={setting.title}
/>
{:else if setting.type === 'hotkey'}
<HotkeyInput
value={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
/>
{:else if setting.type === 'component'}
{#if setting.component}
{@const Component = setting.component}
<Component />
{/if}
{/if}
</div>
</div>
@@ -248,8 +288,11 @@
{/each}
{/if}
</div>
</div>
{/each}
<div class="p-1 border-none"></div>
{@render Setting({
title: "BetterSEQTA+",
description: "Enables BetterSEQTA+ features",
@@ -262,7 +305,8 @@
})}
{#if $settingsState.devMode}
<div class="flex items-center justify-between px-4 py-3 mt-4 pt-[1.75rem]">
<div class="flex-col p-1 my-1 bg-gradient-to-br from-white rounded-xl border shadow-sm to-zinc-100 border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Developer Mode</h2>
<p class="text-xs">Enables developer mode, allowing you to test new features and changes.</p>
@@ -277,11 +321,24 @@
<p class="text-xs">Replace sensitive content with mock data</p>
</div>
<div>
<Button
onClick={() => hideSensitiveContent()}
text="Hide"
<Switch
state={$settingsState.hideSensitiveContent ?? false}
onChange={(isOn: boolean) => settingsState.hideSensitiveContent = isOn}
/>
</div>
</div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Mock Notices</h2>
<p class="text-xs">Use fake notice data on homepage instead of real data</p>
</div>
<div>
<Switch
state={$settingsState.mockNotices ?? false}
onChange={(isOn: boolean) => settingsState.mockNotices = isOn}
/>
</div>
</div>
</div>
{/if}
</div>
+87 -21
View File
@@ -3,8 +3,10 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
import Switch from "@/interface/components/Switch.svelte"
import { onMount } from 'svelte';
import Shortcuts from "@/seqta/content/links.json"
let isLoaded = $state(false);
let fileInput = $state<HTMLInputElement | null>(null);
onMount(async () => {
// Wait for settingsState to be initialized
@@ -21,15 +23,38 @@
});
});
const switchChange = (index: number) => {
const updatedShortcuts = [...settingsState.shortcuts];
updatedShortcuts[index].enabled = !updatedShortcuts[index].enabled;
settingsState.shortcuts = updatedShortcuts;
const switchChange = (shortcut: any) => {
const value = $settingsState.shortcuts.find(s => s.name === shortcut);
if (value) {
value.enabled = !value.enabled;
settingsState.shortcuts = settingsState.shortcuts;
} else {
settingsState.shortcuts = [...settingsState.shortcuts, { name: shortcut, enabled: true }];
}
}
let isFormVisible = $state(false);
let newTitle = $state("");
let newURL = $state("");
let newIcon = $state<string | null>(null);
function handleIconChange(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (file && file.type === "image/svg+xml") {
const reader = new FileReader();
reader.onload = () => {
newIcon = reader.result as string;
};
reader.readAsText(file);
}
}
const clearIcon = () => {
newIcon = null;
if (fileInput) {
fileInput.value = ""; // Clear the file input so the same file can be re-selected
}
};
const toggleForm = () => {
isFormVisible = !isFormVisible;
@@ -49,11 +74,13 @@
const addNewCustomShortcut = () => {
if (isValidTitle(newTitle) && isValidURL(newURL)) {
const newShortcut = { name: newTitle.trim(), url: formatUrl(newURL).trim(), icon: newTitle[0] };
const icon = newIcon || newTitle[0];
const newShortcut = { name: newTitle.trim(), url: formatUrl(newURL).trim(), icon };
settingsState.customshortcuts = [...settingsState.customshortcuts, newShortcut];
newTitle = "";
newURL = "";
newIcon = null;
isFormVisible = false;
} else {
alert("Please enter a valid title and URL.");
@@ -65,15 +92,6 @@
};
</script>
{#snippet Shortcuts([index, Shortcut]: [string, { name: string, enabled: boolean }]) }
<div class="flex items-center justify-between px-4 py-3">
<div class="pr-4">
<h2 class="text-sm">{Shortcut.name}</h2>
</div>
<Switch state={Shortcut.enabled} onChange={() => switchChange(parseInt(index))} />
</div>
{/snippet}
<div class="flex flex-col pt-4 divide-y divide-zinc-100 dark:divide-zinc-700">
{#if isLoaded}
<div>
@@ -95,7 +113,7 @@
class="w-full"
>
<input
class="w-full p-2 transition border-0 rounded-lg placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
class="p-2 w-full rounded-lg border-0 transition placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
type="text"
placeholder="Shortcut Name"
bind:value={newTitle}
@@ -105,14 +123,56 @@
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05, duration: 0.2 }}
class="w-full"
class="flex gap-2 w-full"
>
<input
class="w-full p-2 my-2 transition border-0 rounded-lg placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
class="p-2 my-2 w-full rounded-lg border-0 transition placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
type="text"
placeholder="URL eg. https://google.com"
bind:value={newURL}
/>
<input
bind:this={fileInput}
class="p-2 w-full rounded-lg border-0 transition placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
type="file"
accept=".svg"
onchange={handleIconChange}
hidden
/>
<button
type="button"
class="flex justify-between items-center p-2 my-2 text-left rounded-lg border border-dashed transition text-nowrap text-zinc-500 dark:text-zinc-400 bg-zinc-100 dark:bg-zinc-700/50 hover:bg-zinc-200/50 dark:hover:bg-zinc-700/30 focus:bg-zinc-200/50 dark:focus:bg-zinc-600/50 border-zinc-300 dark:border-zinc-600"
onclick={() => fileInput?.click()}
>
{#if newIcon}
<div class="flex overflow-hidden items-center">
<div class="flex-shrink-0 mr-2 w-6 h-6">
<img src={`data:image/svg+xml;base64,${btoa(newIcon)}`} alt="Selected Icon" class="object-contain w-full h-full" />
</div>
<span class="truncate">Selected Icon</span>
</div>
<span
class="p-1 ml-2 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600"
aria-label="Clear icon"
role="button"
tabindex="0"
onclick={(event) => { event.stopPropagation(); clearIcon(); }}
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
clearIcon();
}
}}
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</span>
{:else}
<span class="font-IconFamily">{ '\ued47' }</span>
<span class="ml-2">SVG icon <span class="text-xs italic text-zinc-400 dark:text-zinc-500">(Optional)</span></span>
{/if}
</button>
</MotionDiv>
</div>
{/if}
@@ -136,15 +196,21 @@
</MotionDiv>
</div>
{#each Object.entries($settingsState.shortcuts) as shortcut}
{@render Shortcuts(shortcut)}
{#each Object.entries(Shortcuts) as shortcut}
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<!-- Use DisplayName if it exists, otherwise use the key (shortcut[0]) as a fallback -->
<h2 class="text-sm">{shortcut[1].DisplayName || shortcut[0]}</h2>
</div>
<Switch state={$settingsState.shortcuts.find(s => s.name === shortcut[0])?.enabled ?? false} onChange={() => switchChange(shortcut[0])} />
</div>
{/each}
<!-- Custom Shortcuts Section -->
{#each $settingsState.customshortcuts as shortcut, index}
<div class="flex items-center justify-between px-4 py-3">
<div class="flex justify-between items-center px-4 py-3">
{shortcut.name}
<button onclick={() => deleteCustomShortcut(index)}>
<button aria-label="Delete Shortcut" onclick={() => deleteCustomShortcut(index)}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
+1 -1
View File
@@ -2,6 +2,6 @@ export interface SettingsList {
title: string;
id: number;
description: string;
Component: any; /* TODO: Give this a type */
Component: any /* TODO: Give this a type */;
props?: any;
}
+1 -1
View File
@@ -16,7 +16,7 @@ export class Standalone {
public setStandalone(value: boolean) {
this._standalone = value;
this.subscribers.forEach(subscriber => subscriber(value));
this.subscribers.forEach((subscriber) => subscriber(value));
}
public get standalone() {
+81 -11
View File
@@ -1,23 +1,49 @@
import type { LoadedCustomTheme } from '@/types/CustomThemes';
import type { LoadedCustomTheme } from "@/types/CustomThemes";
/**
* Generates a random 9-character alphanumeric string to be used as a unique ID for images.
* This helps in identifying and managing custom images within a theme.
*
* @returns {string} A randomly generated unique ID string.
*/
export function generateImageId(): string {
return Math.random().toString(36).substr(2, 9);
}
export function handleImageUpload(event: Event, theme: LoadedCustomTheme): Promise<LoadedCustomTheme> | LoadedCustomTheme {
/**
* Handles the upload of a new custom image from a file input event.
* If a file is selected, it reads the file using FileReader, converts it to a Blob,
* generates a unique ID and a default variable name for it, and then adds this new image
* to the `CustomImages` array within the provided `theme` object.
*
* @param {Event} event The file input change event, typically from an `<input type="file">` element.
* @param {LoadedCustomTheme} theme The current theme object to which the new image will be added.
* @returns {Promise<LoadedCustomTheme> | LoadedCustomTheme} A Promise that resolves with the updated theme object
* containing the new image if a file was processed.
* Returns the original theme object synchronously if no file was selected.
*/
export function handleImageUpload(
event: Event,
theme: LoadedCustomTheme,
): Promise<LoadedCustomTheme> | LoadedCustomTheme {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
input.value = "";
if (file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = async () => {
const imageBlob = await fetch(reader.result as string).then(res => res.blob());
const imageBlob = await fetch(reader.result as string).then((res) =>
res.blob(),
);
const imageId = generateImageId();
const variableName = `custom-image-${theme.CustomImages.length}`;
resolve({
...theme,
CustomImages: [...theme.CustomImages, { id: imageId, blob: imageBlob, variableName, url: null }],
CustomImages: [
...theme.CustomImages,
{ id: imageId, blob: imageBlob, variableName, url: null },
],
});
};
reader.readAsDataURL(file);
@@ -26,31 +52,75 @@ export function handleImageUpload(event: Event, theme: LoadedCustomTheme): Promi
return theme;
}
export function handleRemoveImage(imageId: string, theme: LoadedCustomTheme): LoadedCustomTheme {
/**
* Removes a custom image from the theme based on its ID.
* It filters out the image with the specified `imageId` from the `CustomImages` array
* in the `theme` object.
*
* @param {string} imageId The unique ID of the custom image to be removed.
* @param {LoadedCustomTheme} theme The current theme object from which the image will be removed.
* @returns {LoadedCustomTheme} A new theme object with the specified image removed from its `CustomImages` array.
* This function is synchronous.
*/
export function handleRemoveImage(
imageId: string,
theme: LoadedCustomTheme,
): LoadedCustomTheme {
return {
...theme,
CustomImages: theme.CustomImages.filter((image) => image.id !== imageId),
} as LoadedCustomTheme;
}
export function handleImageVariableChange(imageId: string, variableName: string, theme: LoadedCustomTheme): LoadedCustomTheme {
/**
* Updates the CSS variable name associated with a specific custom image in the theme.
* It finds the image by `imageId` in the `CustomImages` array of the `theme` object
* and updates its `variableName` property.
*
* @param {string} imageId The unique ID of the custom image whose variable name is to be updated.
* @param {string} variableName The new CSS variable name to assign to the image.
* @param {LoadedCustomTheme} theme The current theme object containing the image to be updated.
* @returns {LoadedCustomTheme} A new theme object with the updated image variable name.
* This function is synchronous.
*/
export function handleImageVariableChange(
imageId: string,
variableName: string,
theme: LoadedCustomTheme,
): LoadedCustomTheme {
return {
...theme,
CustomImages: theme.CustomImages.map((image) =>
image.id === imageId ? { ...image, variableName } : image
image.id === imageId ? { ...image, variableName } : image,
),
} as LoadedCustomTheme;
}
export function handleCoverImageUpload(event: Event, theme: LoadedCustomTheme): Promise<LoadedCustomTheme> {
/**
* Handles the upload of a cover image for the theme from a file input event.
* If a file is selected, it reads the file using FileReader, converts it to a Blob,
* and then updates the `coverImage` property of the provided `theme` object with this new blob.
*
* @param {Event} event The file input change event, typically from an `<input type="file">` element.
* @param {LoadedCustomTheme} theme The current theme object whose cover image will be updated.
* @returns {Promise<LoadedCustomTheme>} A Promise that resolves with the updated theme object
* containing the new cover image if a file was processed.
* Returns a Promise resolving with the original theme object if no file was selected.
*/
export function handleCoverImageUpload(
event: Event,
theme: LoadedCustomTheme,
): Promise<LoadedCustomTheme> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
input.value = "";
if (file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = async () => {
const imageBlob = await fetch(reader.result as string).then(res => res.blob());
const imageBlob = await fetch(reader.result as string).then((res) =>
res.blob(),
);
resolve({ ...theme, coverImage: imageBlob });
};
reader.readAsDataURL(file);
+8 -5
View File
@@ -1,9 +1,12 @@
import { createManifest } from '../../lib/createManifest'
import baseManifest from './manifest.json'
import pkg from '../../package.json'
import { createManifest } from "../../lib/createManifest";
import baseManifest from "./manifest.json";
import pkg from "../../package.json";
export const brave = createManifest({
export const brave = createManifest(
{
...baseManifest,
version: pkg.version,
description: pkg.description,
}, 'brave')
},
"brave",
);
+8 -5
View File
@@ -1,9 +1,12 @@
import { createManifest } from '../../lib/createManifest'
import baseManifest from './manifest.json'
import pkg from '../../package.json'
import { createManifest } from "../../lib/createManifest";
import baseManifest from "./manifest.json";
import pkg from "../../package.json";
export const chrome = createManifest({
export const chrome = createManifest(
{
...baseManifest,
version: pkg.version,
description: pkg.description,
}, 'chrome')
},
"chrome",
);
+8 -5
View File
@@ -1,9 +1,12 @@
import { createManifest } from '../../lib/createManifest'
import baseManifest from './manifest.json'
import pkg from '../../package.json'
import { createManifest } from "../../lib/createManifest";
import baseManifest from "./manifest.json";
import pkg from "../../package.json";
export const edge = createManifest({
export const edge = createManifest(
{
...baseManifest,
version: pkg.version,
description: pkg.description,
}, 'edge')
},
"edge",
);
+7 -7
View File
@@ -1,6 +1,6 @@
import { createManifest } from '../../lib/createManifest'
import baseManifest from './manifest.json'
import pkg from '../../package.json'
import { createManifest } from "../../lib/createManifest";
import baseManifest from "./manifest.json";
import pkg from "../../package.json";
const updatedFirefoxManifest = {
...baseManifest,
@@ -10,13 +10,13 @@ const updatedFirefoxManifest = {
scripts: [baseManifest.background.service_worker],
},
action: {
"default_popup": "interface/index.html#settings",
default_popup: "interface/index.html#settings",
},
browser_specific_settings: {
gecko: {
id: pkg.author.email,
},
}
}
},
};
export const firefox = createManifest(updatedFirefoxManifest, 'firefox')
export const firefox = createManifest(updatedFirefoxManifest, "firefox");
+1 -9
View File
@@ -32,15 +32,7 @@
],
"web_accessible_resources": [
{
"resources": ["*/*"],
"matches": ["*://*/*"]
},
{
"resources": ["resources/*"],
"matches": ["*://*/*"]
},
{
"resources": ["seqta/utils/migration/migrate.html"],
"resources": ["resources/icons/*", "resources/update-image.webp"],
"matches": ["*://*/*"]
}
]
+8 -5
View File
@@ -1,9 +1,12 @@
import { createManifest } from '../../lib/createManifest'
import baseManifest from './manifest.json'
import pkg from '../../package.json'
import { createManifest } from "../../lib/createManifest";
import baseManifest from "./manifest.json";
import pkg from "../../package.json";
export const opera = createManifest({
export const opera = createManifest(
{
...baseManifest,
version: pkg.version,
description: pkg.description,
}, 'opera')
},
"opera",
);
+7 -7
View File
@@ -1,6 +1,6 @@
import { createManifest } from '../../lib/createManifest'
import baseManifest from './manifest.json'
import pkg from '../../package.json'
import { createManifest } from "../../lib/createManifest";
import baseManifest from "./manifest.json";
import pkg from "../../package.json";
const updatedSafariManifest = {
...baseManifest,
@@ -8,12 +8,12 @@ const updatedSafariManifest = {
description: pkg.description,
browser_specific_settings: {
safari: {
strict_min_version: '15.4',
strict_max_version: '*',
strict_min_version: "15.4",
strict_max_version: "*",
},
// ^^^ https://developer.apple.com/documentation/safariservices/safari_web_extensions/optimizing_your_web_extension_for_safari#3743239
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_specific_settings#safari_properties
},
}
};
export const safari = createManifest(updatedSafariManifest, 'safari')
export const safari = createManifest(updatedSafariManifest, "safari");
+58 -36
View File
@@ -3,8 +3,8 @@ class ReactFiber {
this.selector = selector;
this.debug = options.debug || false;
this.nodes = [...document.querySelectorAll(selector)]; // Support multiple elements
this.fibers = this.nodes.map(node => this.getFiberNode(node));
this.components = this.fibers.map(fiber => this.getOwnerComponent(fiber));
this.fibers = this.nodes.map((node) => this.getFiberNode(node));
this.components = this.fibers.map((fiber) => this.getOwnerComponent(fiber));
if (this.debug) {
console.log("Selected Nodes:", this.nodes);
@@ -19,8 +19,10 @@ class ReactFiber {
getFiberNode(node) {
if (!node) return null;
const fiberKey = Object.getOwnPropertyNames(node).find(name =>
name.startsWith('__reactFiber') || name.startsWith('__reactInternalInstance')
const fiberKey = Object.getOwnPropertyNames(node).find(
(name) =>
name.startsWith("__reactFiber") ||
name.startsWith("__reactInternalInstance"),
);
return fiberKey ? node[fiberKey] : null;
}
@@ -28,7 +30,10 @@ class ReactFiber {
getOwnerComponent(fiberNode) {
let current = fiberNode;
while (current) {
if (current.stateNode && (current.stateNode.setState || current.stateNode.forceUpdate)) {
if (
current.stateNode &&
(current.stateNode.setState || current.stateNode.forceUpdate)
) {
return current.stateNode;
}
current = current.return;
@@ -42,7 +47,7 @@ class ReactFiber {
if (key === undefined) {
return state;
} else if (typeof key === 'string') {
} else if (typeof key === "string") {
return state?.[key];
} else if (Array.isArray(key)) {
const filteredState = {};
@@ -57,23 +62,25 @@ class ReactFiber {
}
setState(update) {
this.components.forEach(component => {
this.components.forEach((component) => {
if (component?.setState) {
if (typeof update === 'function') {
if (typeof update === "function") {
// Functional update
component.setState(prevState => {
component.setState((prevState) => {
const newState = update(prevState);
if (this.debug) console.log("✅ Updated State (Functional):", newState);
if (this.debug)
console.log("✅ Updated State (Functional):", newState);
return newState;
});
} else {
// Object update (merge with existing state)
component.setState(prevState => {
component.setState((prevState) => {
const newState = {
...prevState,
...update
...update,
};
if (this.debug) console.log("✅ Updated State (Object Merge):", newState);
if (this.debug)
console.log("✅ Updated State (Object Merge):", newState);
return newState;
});
}
@@ -93,7 +100,7 @@ class ReactFiber {
}
setProp(propName) {
this.fibers.forEach(fiber => {
this.fibers.forEach((fiber) => {
if (fiber?.memoizedProps) {
fiber.memoizedProps[propName] = value;
}
@@ -102,7 +109,7 @@ class ReactFiber {
}
forceUpdate() {
this.components.forEach(component => {
this.components.forEach((component) => {
if (component?.forceUpdate) {
component.forceUpdate();
if (this.debug) console.log("🔄 Forced React Re-render");
@@ -113,12 +120,12 @@ class ReactFiber {
}
function makeSerializable(obj) {
if (typeof obj !== 'object' || obj === null) {
if (typeof obj !== "object" || obj === null) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(item => makeSerializable(item));
return obj.map((item) => makeSerializable(item));
}
const serializableObj = {};
@@ -126,17 +133,17 @@ function makeSerializable(obj) {
if (Object.hasOwn(obj, key)) {
let value = obj[key];
if (typeof value === 'function') {
value = '[Function]';
if (typeof value === "function") {
value = "[Function]";
} else if (value instanceof HTMLElement) {
value = {
type: 'HTMLElement',
type: "HTMLElement",
id: value.id,
tagName: value.tagName
tagName: value.tagName,
}; // Replace DOM node with ID/tag info
} else if (typeof value === 'symbol') {
} else if (typeof value === "symbol") {
value = value.toString();
} else if (typeof value === 'object' && value !== null) {
} else if (typeof value === "object" && value !== null) {
value = makeSerializable(value);
}
@@ -146,17 +153,11 @@ function makeSerializable(obj) {
return serializableObj;
}
window.addEventListener('message', (event) => {
window.addEventListener("message", (event) => {
if (event.data.type === "reactFiberRequest") {
const {
selector,
action,
payload,
debug,
messageId
} = event.data;
const { selector, action, payload, debug, messageId } = event.data;
const fiberInstance = ReactFiber.find(selector, {
debug
debug,
});
let response;
@@ -167,7 +168,7 @@ window.addEventListener('message', (event) => {
case "setState":
// Handle both function and object updates
if (payload.updateFn) {
const updateFn = eval(`(${payload.updateFn})`);
const updateFn = new Function('return ' + payload.updateFn)();
fiberInstance.setState(updateFn);
} else {
fiberInstance.setState(payload.updateObject);
@@ -191,14 +192,35 @@ window.addEventListener('message', (event) => {
response = null;
}
if (response !== null && typeof response === 'object') {
if (response !== null && typeof response === "object") {
response = makeSerializable(response);
}
window.postMessage({
window.postMessage(
{
type: "reactFiberResponse",
response,
messageId,
}, "*");
},
"*",
);
} else if (event.data.type === "triggerKeyboardEvent") {
// Handle keyboard event triggering from content script
const { key, code, altKey, ctrlKey, metaKey, shiftKey, keyCode } = event.data;
const keyboardEvent = new KeyboardEvent('keydown', {
key,
code,
keyCode: keyCode || 0,
which: keyCode || 0,
altKey: altKey || false,
ctrlKey: ctrlKey || false,
metaKey: metaKey || false,
shiftKey: shiftKey || false,
bubbles: true,
cancelable: true
});
document.dispatchEvent(keyboardEvent);
}
});
@@ -1,7 +1,11 @@
import { BasePlugin } from '../../core/settings';
import { type Plugin } from '@/plugins/core/types';
import { defineSettings, numberSetting, Setting } from '@/plugins/core/settingsHelpers';
import styles from './styles.css?inline';
import { BasePlugin } from "../../core/settings";
import { type Plugin } from "@/plugins/core/types";
import {
defineSettings,
numberSetting,
Setting,
} from "@/plugins/core/settingsHelpers";
import styles from "./styles.css?inline";
const settings = defineSettings({
speed: numberSetting({
@@ -10,8 +14,8 @@ const settings = defineSettings({
description: "Controls how fast the background moves",
min: 0.1,
max: 2,
step: 0.05
})
step: 0.05,
}),
});
class AnimatedBackgroundPluginClass extends BasePlugin<typeof settings> {
@@ -22,10 +26,10 @@ class AnimatedBackgroundPluginClass extends BasePlugin<typeof settings> {
const instance = new AnimatedBackgroundPluginClass();
const animatedBackgroundPlugin: Plugin<typeof settings> = {
id: 'animated-background',
name: 'Animated Background',
description: 'Adds an animated background to BetterSEQTA+',
version: '1.0.0',
id: "animated-background",
name: "Animated Background",
description: "Adds an animated background to BetterSEQTA+",
version: "1.0.0",
disableToggle: true,
styles: styles,
settings: instance.settings,
@@ -42,12 +46,12 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
const backgrounds = [
{ classes: ["bg"] },
{ classes: ["bg", "bg2"] },
{ classes: ["bg", "bg3"] }
{ classes: ["bg", "bg3"] },
];
backgrounds.forEach(({ classes }) => {
const bk = document.createElement("div");
classes.forEach(cls => bk.classList.add(cls));
classes.forEach((cls) => bk.classList.add(cls));
container.insertBefore(bk, menu);
});
@@ -55,20 +59,23 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
updateAnimationSpeed(api.settings.speed);
// Listen for speed changes
const speedUnregister = api.settings.onChange('speed', updateAnimationSpeed);
const speedUnregister = api.settings.onChange(
"speed",
updateAnimationSpeed,
);
// Return cleanup function
return () => {
speedUnregister.unregister();
// Remove background elements
const backgrounds = document.getElementsByClassName('bg');
Array.from(backgrounds).forEach(element => element.remove());
const backgrounds = document.getElementsByClassName("bg");
Array.from(backgrounds).forEach((element) => element.remove());
};
}
},
};
function updateAnimationSpeed(speed: number) {
const bgElements = document.getElementsByClassName('bg');
const bgElements = document.getElementsByClassName("bg");
Array.from(bgElements).forEach((element, index) => {
const baseSpeed = index === 0 ? 3 : index === 1 ? 4 : 5;
(element as HTMLElement).style.animationDuration = `${baseSpeed / speed}s`;
@@ -13,12 +13,12 @@ export function CreateBackground() {
const backgrounds = [
{ classes: ["bg"] },
{ classes: ["bg", "bg2"] },
{ classes: ["bg", "bg3"] }
{ classes: ["bg", "bg3"] },
];
backgrounds.forEach(({ classes }) => {
const bk = document.createElement("div");
classes.forEach(cls => bk.classList.add(cls));
classes.forEach((cls) => bk.classList.add(cls));
container.insertBefore(bk, menu);
});
}
@@ -2,5 +2,5 @@ export function RemoveBackground() {
const backgrounds = document.getElementsByClassName("bg");
// Convert HTMLCollection to Array and remove each element
Array.from(backgrounds).forEach(element => element.remove());
Array.from(backgrounds).forEach((element) => element.remove());
}
@@ -1,5 +1,9 @@
import { BasePlugin } from "@/plugins/core/settings";
import { booleanSetting, defineSettings, Setting } from "@/plugins/core/settingsHelpers";
import {
booleanSetting,
defineSettings,
Setting,
} from "@/plugins/core/settingsHelpers";
import { type Plugin } from "@/plugins/core/types";
import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm";
@@ -8,7 +12,7 @@ const settings = defineSettings({
lettergrade: booleanSetting({
default: false,
title: "Letter Grades",
description: "Display the average as a letter instead of a percentage"
description: "Display the average as a letter instead of a percentage",
}),
});
@@ -34,62 +38,105 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
"#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']",
true,
10,
1000
1000,
);
// Helper function to find actual class names by their base pattern
const getClassByPattern = (element: Element | Document, basePattern: string): string => {
const getClassByPattern = (
element: Element | Document,
basePattern: string,
): string => {
// Find all classes on the element
const classes = Array.from(element.querySelectorAll('*'))
.flatMap(el => Array.from(el.classList))
.filter(className => className.startsWith(basePattern));
const classes = Array.from(element.querySelectorAll("*"))
.flatMap((el) => Array.from(el.classList))
.filter((className) => className.startsWith(basePattern));
return classes.length ? classes[0] : '';
return classes.length ? classes[0] : "";
};
// Find actual class names from the DOM
const sampleAssessmentItem = document.querySelector("[class*='AssessmentItem__AssessmentItem___']");
const sampleAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___']",
);
if (!sampleAssessmentItem) return;
// Extract all necessary class patterns from a sample assessment item
const assessmentItemClass = Array.from(sampleAssessmentItem.classList)
.find(c => c.startsWith('AssessmentItem__AssessmentItem___')) || '';
const assessmentItemClass =
Array.from(sampleAssessmentItem.classList).find((c) =>
c.startsWith("AssessmentItem__AssessmentItem___"),
) || "";
const metaContainerClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__metaContainer___');
const metaClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__meta___');
const simpleResultClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__simpleResult___');
const titleClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__title___');
const metaContainerClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__metaContainer___",
);
const metaClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__meta___",
);
const simpleResultClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__simpleResult___",
);
const titleClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__title___",
);
// Get Thermoscore classes
const thermoscoreElement = document.querySelector("[class*='Thermoscore__Thermoscore___']");
const thermoscoreElement = document.querySelector(
"[class*='Thermoscore__Thermoscore___']",
);
if (!thermoscoreElement) return;
const thermoscoreClass = Array.from(thermoscoreElement.classList)
.find(c => c.startsWith('Thermoscore__Thermoscore___')) || '';
const fillClass = getClassByPattern(thermoscoreElement, 'Thermoscore__fill___');
const textClass = getClassByPattern(thermoscoreElement, 'Thermoscore__text___');
const thermoscoreClass =
Array.from(thermoscoreElement.classList).find((c) =>
c.startsWith("Thermoscore__Thermoscore___"),
) || "";
const fillClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__fill___",
);
const textClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__text___",
);
// Find assessment list
const assessmentsList = document.querySelector("#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']");
const assessmentsList = document.querySelector(
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
);
if (!assessmentsList) return;
const gradeElements = document.querySelectorAll("[class*='Thermoscore__text___']");
const gradeElements = document.querySelectorAll(
"[class*='Thermoscore__text___']",
);
if (!gradeElements.length) return;
// Parse and average grades
const letterToNumber: Record<string, number> = {
"A+": 100, A: 95, "A-": 90,
"B+": 85, B: 80, "B-": 75,
"C+": 70, C: 65, "C-": 60,
"D+": 55, D: 50, "D-": 45,
"E+": 40, E: 35, "E-": 30,
"A+": 100,
A: 95,
"A-": 90,
"B+": 85,
B: 80,
"B-": 75,
"C+": 70,
C: 65,
"C-": 60,
"D+": 55,
D: 50,
"D-": 45,
"E+": 40,
E: 35,
"E-": 30,
F: 0,
};
function parseGrade(text: string): number {
const str = text.trim().toUpperCase();
if (str.includes("/")) {
const [raw, max] = str.split("/").map(n => parseFloat(n));
const [raw, max] = str.split("/").map((n) => parseFloat(n));
return (raw / max) * 100;
}
if (str.includes("%")) {
@@ -112,16 +159,23 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
const avg = total / count;
const rounded = Math.ceil(avg / 5) * 5;
const numberToLetter = Object.entries(letterToNumber).reduce((acc, [k, v]) => {
const numberToLetter = Object.entries(letterToNumber).reduce(
(acc, [k, v]) => {
acc[v] = k;
return acc;
}, {} as Record<number, string>);
},
{} as Record<number, string>,
);
const letterAvg = numberToLetter[rounded] ?? "N/A";
const display = api.settings.lettergrade ? letterAvg : `${avg.toFixed(2)}%`;
const display = api.settings.lettergrade
? letterAvg
: `${avg.toFixed(2)}%`;
// Prevent duplicate
const existing = assessmentsList.querySelector(`[class*='AssessmentItem__title___']`);
const existing = assessmentsList.querySelector(
`[class*='AssessmentItem__title___']`,
);
if (existing?.textContent === "Subject Average") return;
// Use the dynamic class names in the HTML template
@@ -144,7 +198,7 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
assessmentsList.insertBefore(averageElement!, assessmentsList.firstChild);
});
}
},
};
export default assessmentsAveragePlugin;
@@ -0,0 +1,384 @@
<script lang="ts">
import { determineStatus, formatDate, getGradeValue } from "./utils";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import confetti from "canvas-confetti";
export let data: any;
interface FilterOptions {
subject: string;
sortBy: "due" | "grade" | "subject" | "title";
}
function percentageToLetter(percentage: number): string {
const letterMap: Record<number, string> = {
100: "A+",
95: "A",
90: "A-",
85: "B+",
80: "B",
75: "B-",
70: "C+",
65: "C",
60: "C-",
55: "D+",
50: "D",
45: "D-",
40: "E+",
35: "E",
30: "E-",
0: "F",
};
const rounded = Math.ceil(percentage / 5) * 5;
return letterMap[rounded] || "F";
}
let currentFilters: FilterOptions = {
subject: "all",
sortBy: "due",
};
let filteredAssessments: any[] = [];
let statusGroups: Record<string, any[]> = {};
function updateAssessments() {
filteredAssessments = data.assessments.filter((a: any) => {
const subjectMatch =
currentFilters.subject === "all" || a.code === currentFilters.subject;
return subjectMatch;
});
filteredAssessments.sort((a: any, b: any) => {
switch (currentFilters.sortBy) {
case "due":
return new Date(a.due).getTime() - new Date(b.due).getTime();
case "grade":
const gradeA = getGradeValue(a);
const gradeB = getGradeValue(b);
if (gradeA === null && gradeB === null) return 0;
if (gradeA === null) return 1;
if (gradeB === null) return -1;
return gradeB - gradeA;
case "subject":
return a.code.localeCompare(b.code);
case "title":
return a.title.localeCompare(b.title);
default:
return 0;
}
});
statusGroups = {
UPCOMING: [],
DUE_SOON: [],
OVERDUE: [],
SUBMITTED: [],
MARKS_RELEASED: [],
};
filteredAssessments.forEach((assessment) => {
const status = determineStatus(assessment);
if (statusGroups[status]) {
statusGroups[status].push(assessment);
}
});
}
function getDueDateClass(assessment: any): string {
const status = determineStatus(assessment);
switch (status) {
case "OVERDUE":
return "overdue";
case "DUE_SOON":
return "due-soon";
case "UPCOMING":
return "upcoming";
default:
return "";
}
}
function markAssessmentCompleted(assessment: any) {
const completedKey = "betterseqta-completed-assessments";
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
if (!completed.includes(assessment.id)) {
completed.push(assessment.id);
localStorage.setItem(completedKey, JSON.stringify(completed));
updateAssessments();
checkForCelebration();
}
}
function unmarkAssessmentCompleted(assessment: any) {
const completedKey = "betterseqta-completed-assessments";
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
const index = completed.indexOf(assessment.id);
if (index > -1) {
completed.splice(index, 1);
localStorage.setItem(completedKey, JSON.stringify(completed));
updateAssessments();
}
}
function checkForCelebration() {
const overdueCount = statusGroups.OVERDUE?.length || 0;
const dueSoonCount = statusGroups.DUE_SOON?.length || 0;
if (overdueCount === 0 && dueSoonCount === 0) {
setTimeout(() => {
try {
const duration = 100;
const end = Date.now() + duration;
(function frame() {
confetti({
particleCount: 17,
angle: 60,
spread: 65,
drift: 0.8,
startVelocity: 40,
scalar: 2,
gravity: 2,
decay: 0.97,
ticks: 300,
origin: { x: 0, y: 1 },
disableForReducedMotion: true,
});
confetti({
particleCount: 17,
angle: 120,
spread: 65,
drift: -0.8,
startVelocity: 40,
scalar: 2,
decay: 0.97,
ticks: 300,
gravity: 2,
origin: { x: 1, y: 1 },
disableForReducedMotion: true,
});
if (Date.now() < end) {
requestAnimationFrame(frame);
}
}());
} catch (e) {
console.log("Confetti celebration failed:", e);
}
}, 500);
} else if (overdueCount === 0 || dueSoonCount === 0) {
setTimeout(() => {
try {
confetti({
particleCount: 100,
spread: 70,
origin: { y: 0.6 },
scalar: 0.9,
disableForReducedMotion: true,
});
} catch (e) {
console.log("Confetti celebration failed:", e);
}
}, 500);
}
}
function isManuallyCompleted(assessmentId: string): boolean {
const completedKey = "betterseqta-completed-assessments";
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
return completed.includes(assessmentId);
}
function handleCardClick(assessment: any, event: Event) {
if ((event.target as HTMLElement).closest(".card-menu")) {
return;
}
window.location.hash = `#?page=/assessments/${assessment.programmeID}:${assessment.metaclassID}&item=${assessment.id}`;
}
let openMenuId: string | null = null;
function toggleMenu(assessmentId: string, event: Event) {
event.stopPropagation();
openMenuId = openMenuId === assessmentId ? null : assessmentId;
}
function closeAllMenus() {
openMenuId = null;
}
$: {
if (data) {
updateAssessments();
}
}
const columns = [
{
key: "UPCOMING",
title: "Upcoming",
className: "column-upcoming",
icon: "📅",
},
{
key: "DUE_SOON",
title: "Due Soon",
className: "column-due-soon",
icon: "⏰",
},
{
key: "OVERDUE",
title: "Overdue",
className: "column-overdue",
icon: "🚨",
},
{
key: "SUBMITTED",
title: "Submitted",
className: "column-submitted",
icon: "📝",
},
{
key: "MARKS_RELEASED",
title: "Marked",
className: "column-marked",
icon: "✅",
},
];
</script>
<svelte:window on:click={closeAllMenus} />
<div id="grid-view-container">
<div class="grid-view-header">
<h1 class="grid-view-title">Assessments</h1>
<div class="grid-view-filters">
<select class="filter-select" bind:value={currentFilters.subject}>
<option value="all">All Subjects</option>
{#each data.subjects as subject}
<option value={subject.code}>{subject.code} - {subject.title}</option>
{/each}
</select>
<select class="filter-select" bind:value={currentFilters.sortBy}>
<option value="due">Sort by Due Date</option>
<option value="grade">Sort by Grade</option>
<option value="subject">Sort by Subject</option>
<option value="title">Sort by Title</option>
</select>
</div>
</div>
<div id="main-grid-content">
{#if filteredAssessments.length === 0}
<div class="empty-state">
<div class="empty-icon">📋</div>
<p>No assessments found matching your filters</p>
</div>
{:else}
<div class="kanban-board">
{#each columns as column}
{#if statusGroups[column.key]?.length > 0}
<div class="kanban-column-parent">
<div class="kanban-column {column.className}">
<div class="column-header">
<div class="column-title">
{column.icon} {column.title}
<span class="column-count">{statusGroups[column.key].length}</span>
</div>
</div>
<div class="column-cards" id="{column.key.toLowerCase()}-cards">
{#each statusGroups[column.key] as assessment}
{@const status = determineStatus(assessment)}
{@const dueDateClass = getDueDateClass(assessment)}
{@const isCompleted = isManuallyCompleted(assessment.id)}
{@const color = data.colors[assessment.code] || "#6366f1"}
<div
class="assessment-card"
data-subject={assessment.code}
data-status={status}
style="--subject-color: {color}"
on:click={(e) => handleCardClick(assessment, e)}
role="button"
tabindex="0"
on:keydown={(e) => e.key === 'Enter' && handleCardClick(assessment, e)}
>
<div class="card-labels">
<span class="card-label label-subject">{assessment.code}</span>
{#if assessment.submitted}
<span class="card-label label-submitted" style="background: #10b981; color: white;">Submitted</span>
{/if}
{#if isCompleted && status === "MARKS_RELEASED" && !assessment.results}
<span class="card-label label-completed" style="background: #059669; color: white;">Completed</span>
{/if}
</div>
{#if status !== "MARKS_RELEASED" || isCompleted}
<div class="card-menu">
<button
class="menu-button"
data-assessment-id={assessment.id}
on:click={(e) => toggleMenu(assessment.id, e)}
aria-label="Open menu"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2"/>
<circle cx="12" cy="12" r="2"/>
<circle cx="12" cy="19" r="2"/>
</svg>
</button>
<div class="menu-dropdown" style="display: {openMenuId === assessment.id ? 'block' : 'none'};">
{#if status !== "MARKS_RELEASED"}
<button class="menu-item mark-completed" on:click={() => markAssessmentCompleted(assessment)}>
Mark as Completed
</button>
{:else if isCompleted}
<button class="menu-item mark-not-completed" on:click={() => unmarkAssessmentCompleted(assessment)}>
Mark as Not Complete
</button>
{/if}
</div>
</div>
{/if}
<h3 class="assessment-title">{assessment.title}</h3>
{#if !assessment.results && !isCompleted}
<div class="assessment-meta">
<div class="due-date {dueDateClass}">
📅 {formatDate(assessment.due, assessment.submitted)}
</div>
</div>
{/if}
{#if assessment.results}
<div class="card-footer">
<div class="Thermoscore__Thermoscore___WFpL3" style="--fill-colour: {color}">
<div style="width: {assessment.results.percentage}%" class="Thermoscore__fill___ojxDI">
<div title="{assessment.results.percentage}%" class="Thermoscore__text___XSR_M">
{(() => {
const allSettings = settingsState.getAll() as unknown as any;
const letterGradeSetting = allSettings["plugin.assessments-average.settings"]?.lettergrade;
return letterGradeSetting
? percentageToLetter(assessment.results.percentage)
: `${assessment.results.percentage}%`;
})()}
</div>
</div>
</div>
</div>
{/if}
</div>
{/each}
</div>
</div>
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>
@@ -0,0 +1,8 @@
<script lang="ts">
export let error: string;
</script>
<div class="error-container">
<p class="error-text">Failed to load assessments</p>
<p style="color: #94a3b8; font-size: 0.875rem;">{error}</p>
</div>
@@ -0,0 +1,78 @@
<div id="grid-view-container">
<div class="grid-view-header">
<h1 class="grid-view-title">Assessments</h1>
<div class="grid-view-filters">
<select class="filter-select" disabled>
<option value="all">Loading subjects...</option>
</select>
<select class="filter-select" disabled>
<option value="due">Sort by Due Date</option>
</select>
</div>
</div>
<div id="main-grid-content">
<div class="kanban-board">
{#each columns as column}
<div class="kanban-column-parent">
<div class="kanban-column {column.className}">
<div class="column-header">
<div class="column-title">
{column.icon} {column.title}
<span class="column-count">...</span>
</div>
</div>
<div class="column-cards" id="{column.key.toLowerCase()}-cards">
{#each Array(column.skeletonCount) as _}
<div class="assessment-card">
<div class="skeleton-element skeleton-label"></div>
<div class="skeleton-element skeleton-title"></div>
<div class="skeleton-element skeleton-title-line2"></div>
<div class="skeleton-element skeleton-meta"></div>
{#if column.key === "MARKS_RELEASED"}
<div class="skeleton-footer">
<div class="skeleton-element" style="height: 16px; width: 100%;"></div>
</div>
{/if}
</div>
{/each}
</div>
</div>
</div>
{/each}
</div>
</div>
</div>
<script lang="ts">
const columns = [
{
key: "UPCOMING",
title: "Upcoming",
className: "column-upcoming",
icon: "📅",
skeletonCount: 3,
},
{
key: "DUE_SOON",
title: "Due Soon",
className: "column-due-soon",
icon: "⏰",
skeletonCount: 2,
},
{
key: "OVERDUE",
title: "Overdue",
className: "column-overdue",
icon: "🚨",
skeletonCount: 1,
},
{
key: "MARKS_RELEASED",
title: "Marked",
className: "column-marked",
icon: "✅",
skeletonCount: 4,
},
];
</script>
@@ -0,0 +1,138 @@
interface Subject {
code: string;
programme: number;
metaclass: number;
title: string;
}
interface PrefItem {
name: string;
value: string;
}
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { getMockAssessmentsData } from "@/seqta/ui/dev/hideSensitiveContent";
let cache: { time: number; data: any } | null = null;
const CACHE_MS = 10 * 60 * 1000;
const student = 69;
async function fetchJSON(url: string, body: any) {
const res = await fetch(`${location.origin}${url}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body),
});
return res.json();
}
async function loadSubjects() {
const res = await fetchJSON("/seqta/student/load/subjects?", {});
return res.payload
.filter((s: any) => s.active === 1)
.flatMap((s: any) => s.subjects);
}
async function loadPrefs(student: number) {
const res = await fetchJSON("/seqta/student/load/prefs?", {
request: "userPrefs",
asArray: true,
user: student,
});
const colors: Record<string, string> = {};
res.payload.forEach((p: PrefItem) => {
if (p.name.startsWith("timetable.subject.colour.")) {
const code = p.name.replace("timetable.subject.colour.", "");
colors[code] = p.value;
}
});
return colors;
}
async function loadUpcoming(student: number) {
const res = await fetchJSON("/seqta/student/assessment/list/upcoming?", {
student,
});
return res.payload;
}
async function loadPast(student: number, subjects: Subject[]) {
const map: Record<number, any> = {};
await Promise.all(
subjects.map(async (s) => {
const res = await fetchJSON("/seqta/student/assessment/list/past?", {
programme: s.programme,
metaclass: s.metaclass,
student,
});
if (res.payload.tasks) {
res.payload.tasks.forEach((t: any) => {
map[t.id] = t;
});
}
}),
);
return map;
}
async function loadSubmissions(student: number, assessments: any[]) {
const submissionMap: Record<number, boolean> = {};
await Promise.all(
assessments.map(async (assessment) => {
try {
const res = await fetchJSON(
"/seqta/student/assessment/submissions/get",
{
assessment: assessment.id,
metaclass: assessment.metaclassID,
student,
},
);
submissionMap[assessment.id] = res.payload && res.payload.length > 0;
} catch (error) {
console.warn(
`Failed to fetch submission for assessment ${assessment.id}:`,
error,
);
submissionMap[assessment.id] = false;
}
}),
);
return submissionMap;
}
export async function getAssessmentsData() {
if (settingsState.mockNotices) {
return getMockAssessmentsData();
}
if (cache && Date.now() - cache.time < CACHE_MS) return cache.data;
const [subjects, colors, upcoming] = await Promise.all([
loadSubjects(),
loadPrefs(student),
loadUpcoming(student),
]);
const pastMap = await loadPast(student, subjects);
const map: Record<number, any> = {};
upcoming.forEach((a: any) => {
map[a.id] = { ...a };
});
Object.values(pastMap).forEach((t: any) => {
if (map[t.id]) Object.assign(map[t.id], t);
else map[t.id] = t;
});
const allAssessments = Object.values(map);
const submissions = await loadSubmissions(student, allAssessments);
allAssessments.forEach((assessment: any) => {
assessment.submitted = submissions[assessment.id] || false;
});
const data = { assessments: allAssessments, subjects, colors };
cache = { time: Date.now(), data };
return data;
}
@@ -0,0 +1,86 @@
import type { Plugin } from "../../core/types";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { getAssessmentsData } from "./api";
import { renderSkeletonLoader, renderErrorState } from "./ui";
import styles from "./styles.css?inline";
import { delay } from "@/seqta/utils/delay";
const assessmentsOverviewPlugin: Plugin<{}> = {
id: "assessments-overview",
name: "Assessments Overview",
description:
"Adds an overview option to the assessments page that organizes assessments by status",
version: "1.0.0",
settings: {},
disableToggle: false,
styles,
run: async () => {
const menu = (await waitForElm(
'[data-key="assessments"] > .sub > ul',
true,
100,
60,
)) as HTMLElement;
const gridItem = document.createElement("li");
gridItem.className = "item";
const label = document.createElement("label");
label.textContent = "Overview";
gridItem.appendChild(label);
menu.insertBefore(gridItem, menu.children[1] || null);
if (window.location.hash.includes("/assessments/overview")) {
loadGridView();
}
const clickHandler = (e: Event) => {
e.preventDefault();
loadGridView();
};
gridItem.addEventListener("click", clickHandler);
async function loadGridView() {
await delay(1);
window.history.pushState({}, "", "/#?page=/assessments/overview");
document.title = "Overview ― SEQTA Learn";
const main = document.getElementById("main");
if (!main) return;
document
.querySelectorAll('[data-key="assessments"] .item')
.forEach((item) => {
item.classList.remove("active");
});
gridItem.classList.add("active");
document
.querySelector('[data-key="assessments"]')
?.classList.add("active");
main.innerHTML = '<div id="grid-view-container"></div>';
const container = document.getElementById(
"grid-view-container",
) as HTMLElement;
renderSkeletonLoader(container);
try {
const data = await getAssessmentsData();
const { renderGrid } = await import("./ui");
renderGrid(container, data);
} catch (err) {
console.error("Failed to load assessments:", err);
renderErrorState(
container,
err instanceof Error ? err.message : "Unknown error",
);
}
}
return () => {
gridItem.removeEventListener("click", clickHandler);
gridItem.remove();
};
},
};
export default assessmentsOverviewPlugin;
@@ -0,0 +1,798 @@
#grid-view-container {
background: transparent;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.grid-view-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
flex-shrink: 0;
}
.grid-view-title {
font-size: 1.875rem !important;
font-weight: 700;
color: #1a1a1a;
margin: 0;
}
/* Dark mode support */
.dark .grid-view-title {
color: #f8fafc;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.grid-view-filters {
display: flex;
gap: 0.75rem;
align-items: center;
}
.filter-select {
background: #ffffff !important;
border: 2px solid #e2e8f0;
border-radius: 8px;
color: #1a1a1a;
padding: 0.75rem 1rem;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s ease;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
min-width: 180px;
}
.filter-select:focus {
outline: none;
border-color: #d41e3a;
box-shadow: 0 0 0 3px rgba(212, 30, 58, 0.1);
}
.filter-select:hover {
border-color: #cbd5e1;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Dark mode dropdowns */
.dark .filter-select {
background: var(--background-primary) !important;
border-color: var(--background-secondary);
color: var(--text-primary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.dark .filter-select:focus {
border-color: #d41e3a;
box-shadow: 0 0 0 3px rgba(212, 30, 58, 0.2);
}
.dark .filter-select:hover {
border-color: var(--background-secondary);
background: var(--background-secondary);
}
.dark .filter-select option {
background: var(--background-primary);
color: var(--text-primary);
}
#main-grid-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* Kanban Board Layout */
.kanban-board {
display: flex;
gap: 1.5rem;
overflow-x: auto;
padding: 1rem;
flex: 0 1 auto;
}
.kanban-column-parent {
flex: 0 0 320px;
}
.kanban-column {
max-height: 100%;
background: #f8fafc;
border-radius: 12px;
box-shadow: 0 0 0 2px #e2e8f0;
display: flex;
flex-direction: column;
min-height: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* Dark mode columns */
.dark .kanban-column {
background: var(--background-primary);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
.column-header {
padding: 1rem 1.25rem;
border-bottom: 2px solid #e2e8f0;
background: #ffffff;
border-radius: 12px 12px 0 0;
position: sticky;
top: 0;
z-index: 10;
}
/* Dark mode column headers */
.dark .column-header {
background: var(--background-secondary);
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.column-title {
font-size: 1rem;
font-weight: 600;
color: #1a1a1a;
margin: 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.dark .column-title {
color: var(--text-primary);
}
.column-count {
background: #e2e8f0;
color: #64748b;
padding: 0.25rem 0.5rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
}
.dark .column-count {
background: var(--background-secondary);
color: var(--text-primary);
}
.column-cards {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 0;
overflow-y: auto;
}
/* Assessment Cards */
.assessment-card {
background: #ffffff;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
cursor: pointer;
position: relative;
border-left: 4px solid var(--subject-color, #d41e3a);
border: 1px solid #e2e8f0;
}
.assessment-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
border-color: #cbd5e1;
}
/* Dark mode cards */
.dark .assessment-card {
background: var(--background-secondary);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
.dark .assessment-card:hover {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.15);
}
.card-labels {
display: flex;
gap: 0.25rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.card-label {
padding: 0.25rem 0.75rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
color: #ffffff;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.label-subject {
background: var(--subject-color, #d41e3a);
}
.card-menu {
position: absolute;
top: 0.75rem;
right: 0.75rem;
z-index: 20;
}
.menu-button {
background: transparent !important;
border: none !important;
padding: 0.25rem !important;
cursor: pointer;
border-radius: 4px;
color: #64748b;
transition: all 0.2s ease;
display: flex !important;
align-items: center;
justify-content: center;
width: 24px !important;
height: 24px !important;
margin: 0 !important;
position: static !important;
transform: none !important;
box-shadow: none !important;
outline: none !important;
}
.menu-button:hover {
background: #f1f5f9 !important;
color: #1a1a1a;
}
.menu-button svg {
width: 16px !important;
height: 16px !important;
fill: currentColor !important;
display: block !important;
}
.dark .menu-button {
color: var(--text-primary);
opacity: 0.7;
}
.dark .menu-button:hover {
background: rgba(255, 255, 255, 0.1) !important;
opacity: 1;
}
.menu-dropdown {
position: absolute;
top: 100%;
right: 0;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 140px;
z-index: 30;
margin-top: 4px;
}
.dark .menu-dropdown {
background: var(--background-secondary);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.menu-item {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
background: transparent;
border: none;
text-align: left;
cursor: pointer;
font-size: 0.875rem;
color: #1a1a1a;
transition: background-color 0.2s ease;
white-space: nowrap;
}
.menu-item:hover {
background: #f8fafc;
}
.dark .menu-item {
color: var(--text-primary);
}
.dark .menu-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.menu-item.mark-completed {
color: #059669;
font-weight: 500;
}
.dark .menu-item.mark-completed {
color: #10b981;
}
.menu-item.mark-not-completed {
color: #dc2626;
font-weight: 500;
}
.dark .menu-item.mark-not-completed {
color: #ef4444;
}
.assessment-title {
font-size: 0.875rem;
font-weight: 600;
color: #1a1a1a;
margin: 0 0 0.75rem 0;
line-height: 1.4;
padding-right: 2rem; /* Make room for menu button */
}
.dark .assessment-title {
color: var(--text-primary);
}
.assessment-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 0.75rem;
font-size: 0.75rem;
color: #64748b;
}
.dark .assessment-meta {
color: var(--text-primary);
opacity: 0.7;
}
.due-date {
display: flex;
align-items: center;
gap: 0.25rem;
font-weight: 500;
}
.due-date.overdue {
color: #dc2626;
}
.due-date.due-soon {
color: #d97706;
}
.due-date.upcoming {
color: #059669;
}
.card-footer {
display: flex;
align-items: center;
justify-content: flex-start;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #e5e7eb;
}
.dark .card-footer {
border-top-color: rgba(255, 255, 255, 0.1);
}
.grade-display {
font-weight: 700;
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
border-radius: 6px;
}
.grade-good {
background: rgba(16, 185, 129, 0.1);
color: #059669;
border: 1px solid rgba(16, 185, 129, 0.2);
}
.grade-average {
background: rgba(245, 158, 11, 0.1);
color: #d97706;
border: 1px solid rgba(245, 158, 11, 0.2);
}
.grade-bad {
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
border: 1px solid rgba(239, 68, 68, 0.2);
}
.grade-empty {
color: #64748b;
font-style: italic;
font-weight: 500;
background: #f1f5f9;
border: 1px solid #e2e8f0;
}
.dark .grade-empty {
color: var(--text-primary);
opacity: 0.7;
background: var(--background-secondary);
border-color: var(--background-secondary);
}
/* Column-specific styling */
.column-upcoming .column-header {
background: linear-gradient(135deg, #ffffff 0%, #f0f9ff 100%);
}
.column-due-soon .column-header {
background: linear-gradient(135deg, #ffffff 0%, #fffbeb 100%);
}
.column-overdue .column-header {
background: linear-gradient(135deg, #ffffff 0%, #fef2f2 100%);
}
.column-submitted .column-header {
background: linear-gradient(135deg, #ffffff 0%, #fef3c7 100%);
}
.column-marked .column-header {
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%);
}
/* Dark mode column headers */
.dark .column-upcoming .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #1e3a8a 100%);
}
.dark .column-due-soon .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #92400e 100%);
}
.dark .column-overdue .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #991b1b 100%);
}
.dark .column-submitted .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #92400e 100%);
}
.dark .column-marked .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #065f46 100%);
}
/* Subject filter view */
.subject-section {
margin-bottom: 2rem;
}
.subject-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding: 1rem;
background: #ffffff;
border-radius: 8px;
border: 2px solid #e2e8f0;
border-left: 4px solid var(--subject-color, #d41e3a);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.dark .subject-header {
background: var(--background-secondary);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
.subject-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.subject-title {
font-size: 1.125rem;
font-weight: 600;
color: #1a1a1a;
margin: 0;
}
.dark .subject-title {
color: var(--text-primary);
}
.subject-code {
font-size: 0.875rem;
color: #64748b;
}
.dark .subject-code {
color: var(--text-primary);
opacity: 0.7;
}
/* Loading and error states */
.loading-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 4rem 0;
background: #ffffff;
border-radius: 12px;
border: 2px solid #e2e8f0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.dark .loading-container {
background: var(--background-primary);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.loading-spinner {
width: 2.5rem;
height: 2.5rem;
border: 3px solid #e2e8f0;
border-top: 3px solid #d41e3a;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.dark .loading-spinner {
border-color: rgba(255, 255, 255, 0.1);
border-top-color: #d41e3a;
}
.loading-text {
margin-top: 1rem;
color: #64748b;
font-size: 0.875rem;
font-weight: 500;
}
.dark .loading-text {
color: var(--text-primary);
opacity: 0.7;
}
.error-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 4rem 0;
background: #ffffff;
border-radius: 12px;
border: 2px solid #fecaca;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.dark .error-container {
background: var(--background-primary);
border-color: #991b1b;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.error-text {
color: #ef4444;
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.empty-state {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 2rem;
color: #64748b;
font-size: 0.875rem;
text-align: center;
}
.dark .empty-state {
color: var(--text-primary);
opacity: 0.7;
}
.empty-column {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
color: #64748b;
font-size: 0.875rem;
text-align: center;
min-height: 200px;
}
.dark .empty-column {
color: var(--text-primary);
opacity: 0.7;
}
.empty-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
opacity: 0.5;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
.skeleton-element {
background: linear-gradient(
90deg,
#f1f5f9 0%,
#e2e8f0 50%,
#f1f5f9 100%
);
background-size: 1000px 100%;
animation: shimmer 2s infinite linear;
border-radius: 4px;
}
.dark .skeleton-element {
background: linear-gradient(
90deg,
var(--background-primary) 0%,
var(--background-secondary) 50%,
var(--background-primary) 100%
);
background-size: 1000px 100%;
}
.skeleton-label {
height: 20px;
width: 60px;
margin-bottom: 0.75rem;
border-radius: 6px;
}
.skeleton-title {
height: 16px;
width: 80%;
margin-bottom: 0.5rem;
}
.skeleton-title-line2 {
height: 16px;
width: 60%;
margin-bottom: 0.75rem;
}
.skeleton-meta {
height: 12px;
width: 40%;
margin-top: 0.75rem;
}
.skeleton-footer {
height: 8px;
width: 100%;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #e5e7eb;
}
.dark .skeleton-footer {
border-top-color: rgba(255, 255, 255, 0.1);
}
/* Responsive design */
@media (max-width: 768px) {
#grid-view-container {
padding: 1rem;
}
.grid-view-header {
flex-direction: column;
gap: 1rem;
align-items: stretch;
}
.grid-view-filters {
justify-content: center;
flex-wrap: wrap;
}
.kanban-board {
flex-direction: column;
gap: 1rem;
}
.kanban-column {
flex: none;
max-height: none;
}
.filter-select {
min-width: 140px;
}
}
@media (max-width: 480px) {
.grid-view-title {
font-size: 1.5rem !important;
}
.filter-select {
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
min-width: 120px;
}
.assessment-card {
padding: 0.75rem;
}
.card-footer {
flex-direction: column;
gap: 0.5rem;
align-items: stretch;
}
}
/* Scrollbar styling for webkit browsers */
.column-cards::-webkit-scrollbar {
width: 6px;
}
.column-cards::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
.column-cards::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.column-cards::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Dark mode scrollbars */
.dark .column-cards::-webkit-scrollbar-track {
background: var(--background-secondary);
}
.dark .column-cards::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
.dark .column-cards::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
@@ -0,0 +1,45 @@
import renderSvelte from "@/interface/main";
import AssessmentsOverview from "./AssessmentsOverview.svelte";
import SkeletonLoader from "./SkeletonLoader.svelte";
import ErrorState from "./ErrorState.svelte";
import { unmount } from "svelte";
let currentApp: any = null;
export function renderGrid(container: HTMLElement, data: any) {
if (currentApp) {
unmount(currentApp);
}
container.innerHTML = "";
container.className = "";
currentApp = renderSvelte(AssessmentsOverview, container, { data });
}
export function renderSkeletonLoader(container: HTMLElement) {
if (currentApp) {
unmount(currentApp);
}
container.innerHTML = "";
container.className = "";
currentApp = renderSvelte(SkeletonLoader, container);
}
export function renderLoadingState(container: HTMLElement) {
renderSkeletonLoader(container);
}
export function renderErrorState(container: HTMLElement, error: string) {
if (currentApp) {
unmount(currentApp);
}
container.innerHTML = "";
container.className = "";
currentApp = renderSvelte(ErrorState, container, { error });
}
@@ -0,0 +1,101 @@
export function formatDate(dateStr: string, submitted?: boolean): string {
const d = new Date(dateStr);
const now = new Date();
const diffTime = d.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0 && !submitted) {
const overdueDays = Math.abs(diffDays);
if (overdueDays === 1) return "1 day overdue";
return `${overdueDays} days overdue`;
}
if (diffDays === 0) return "Today";
if (diffDays === 1) return "Tomorrow";
if (diffDays <= 7) {
const weekdayName = d.toLocaleDateString(undefined, { weekday: "long" });
return diffDays < 0 ? `Last ${weekdayName}` : weekdayName;
}
return d.toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
export function determineStatus(item: any): string {
if (
item.status === "MARKS_RELEASED" ||
item.grade ||
(item.percentage !== undefined && item.percentage !== null) ||
(item.achieved !== undefined && item.achieved !== null)
) {
return "MARKS_RELEASED";
}
const completedKey = "betterseqta-completed-assessments";
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
if (completed.includes(item.id)) {
return "MARKS_RELEASED";
}
if (item.submitted) {
return "SUBMITTED";
}
const now = new Date();
const due = new Date(item.due);
const diffTime = due.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return "OVERDUE";
}
if (diffDays <= 7) {
return "DUE_SOON";
}
return "UPCOMING";
}
export function getGradeValue(assessment: any): number | null {
if (
assessment.results?.percentage !== undefined &&
assessment.results.percentage !== null
) {
return assessment.results.percentage;
}
if (assessment.percentage !== undefined && assessment.percentage !== null) {
return assessment.percentage;
}
if (
assessment.achieved !== undefined &&
assessment.outOf !== undefined &&
assessment.achieved !== null &&
assessment.outOf !== null &&
assessment.outOf > 0
) {
return (assessment.achieved / assessment.outOf) * 100;
}
if (
assessment.results?.achieved !== undefined &&
assessment.results?.outOf !== undefined &&
assessment.results.achieved !== null &&
assessment.results.outOf !== null &&
assessment.results.outOf > 0
) {
return (assessment.results.achieved / assessment.results.outOf) * 100;
}
return null;
}
@@ -0,0 +1,93 @@
<script lang="ts">
import { createEventDispatcher, onDestroy } from 'svelte';
import { calculateExpression } from '../utils/calculator';
let { searchTerm = '', isSelected = false } = $props<{ searchTerm: string, isSelected: boolean }>();
const dispatch = createEventDispatcher<{
hasResult: string | null;
}>();
let result = $state<string | null>(null);
let isCalculating = $state(false);
let inputUnit = $state<string>('');
let outputUnit = $state<string>('');
let isPartial = $state(false);
const processInput = (input: string) => {
isCalculating = true;
try {
const calcResult = calculateExpression(input);
if (calcResult.isValid) {
result = calcResult.result;
inputUnit = calcResult.inputUnit;
outputUnit = calcResult.outputUnit;
isPartial = calcResult.isPartial;
dispatch('hasResult', calcResult.result);
} else {
result = null;
inputUnit = '';
outputUnit = '';
isPartial = false;
dispatch('hasResult', null);
}
} catch (e) {
result = null;
inputUnit = '';
outputUnit = '';
isPartial = false;
dispatch('hasResult', null);
} finally {
isCalculating = false;
}
}
$effect(() => {
processInput(searchTerm);
});
onDestroy(() => {
dispatch('hasResult', null);
});
</script>
{#if result !== null}
<div class="">
<p class="text-[0.85rem] p-1 pb-0.5 pt-0 font-semibold text-zinc-500 dark:text-zinc-400">Calculator</p>
<div class="flex items-center justify-between gap-8 rounded-lg border border-transparent {isSelected ? 'bg-zinc-900/5 dark:bg-white/10 border-zinc-900/5 dark:border-zinc-100/5' : ''}">
<div class="flex flex-col flex-1 items-center py-4 pl-4 min-w-0">
<div class="overflow-hidden py-2 w-full font-semibold text-center whitespace-nowrap text-zinc-900 dark:text-white text-ellipsis"
style="--char-count: {searchTerm?.length || 10}; font-size: min(2.5rem, max(1rem, calc(35vw / var(--char-count, 10))))">
{searchTerm}
</div>
<div class="px-3 py-1 mt-1 text-sm rounded-md text-zinc-900 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-100/10">
{inputUnit || 'Question'}
</div>
</div>
<div class="flex flex-col flex-shrink-0 justify-center items-center w-12">
<div class="h-8 w-[1px] bg-zinc-900/5 dark:bg-zinc-100/5"></div>
<div class="text-2xl text-zinc-900 dark:text-zinc-100">
</div>
<div class="h-8 w-[1px] bg-zinc-900/5 dark:bg-zinc-100/5"></div>
</div>
{#if !isCalculating}
<div class="flex flex-col flex-1 items-center py-4 pr-4 min-w-0">
<div class="overflow-hidden py-2 w-full font-semibold text-center whitespace-nowrap text-zinc-900 dark:text-white text-ellipsis"
style="--char-count: {result?.length || 10}; font-size: min(2.5rem, max(1rem, calc(30vw / var(--char-count, 10))))">
{result}
</div>
<div class="px-3 py-1 mt-1 text-sm rounded-md text-zinc-900 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-100/10">
{outputUnit || (isPartial ? 'Partial' : 'Result')}
</div>
</div>
{:else}
<div class="w-6 h-6 rounded-full border-2 animate-spin border-zinc-300 dark:border-zinc-700 border-t-zinc-600 dark:border-t-zinc-300"></div>
{/if}
</div>
</div>
{/if}
@@ -0,0 +1,422 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
import { fade, scale } from 'svelte/transition';
import { circOut, quintOut } from 'svelte/easing';
import { type StaticCommandItem } from '../core/commands';
import type { CombinedResult } from '../core/types';
import { createSearchIndexes, performSearch as doSearch } from '../search/searchUtils';
import Fuse from 'fuse.js';
import Calculator from './Calculator.svelte';
import { actionMap } from '../indexing/actions';
import type { IndexItem } from '../indexing/types';
import debounce from 'lodash/debounce';
import { renderComponentMap } from '../indexing/renderComponents';
import HighlightedText from '../utils/HighlightedText.svelte';
import { matchesHotkey } from '../utils/hotkeyUtils';
import browser from 'webextension-polyfill';
const {
transparencyEffects,
searchHotkey: initialSearchHotkey
} = $props<{
transparencyEffects: boolean,
searchHotkey: string
}>();
let currentSearchHotkey = $state(initialSearchHotkey);
let commandsFuse = $state<Fuse<StaticCommandItem>>();
let dynamicContentFuse = $state<Fuse<IndexItem>>();
const dynamicIdToItemMap = $state(new Map<string, IndexItem>());
const commandIdToItemMap = $state(new Map<string, StaticCommandItem>());
let isIndexing = $state(false);
let completedJobs = $state(0);
let totalJobs = $state(0);
let commandPalleteOpen = $state(false);
let searchTerm = $state('');
let selectedIndex = $state(0);
let combinedResults = $state<CombinedResult[]>([]);
let searchbar = $state<HTMLInputElement>();
let isLoading = $state(false);
let calculatorResult = $state<string | null>(null);
let resultsList = $state<HTMLUListElement>();
const updateCalculatorState = (hasResult: string | null) => {
calculatorResult = hasResult;
};
let keydownHandler: ((e: KeyboardEvent) => void) | null = null;
// Listen for setting changes
$effect(() => {
const loadSettings = async () => {
const settings = await browser.storage.local.get('plugin.global-search.settings');
const pluginSettings = settings['plugin.global-search.settings'] as { searchHotkey?: string } | undefined;
if (pluginSettings?.searchHotkey) {
currentSearchHotkey = pluginSettings.searchHotkey;
}
};
loadSettings();
// Listen for storage changes
const handleStorageChange = (changes: any, area: string) => {
if (area === 'local' && changes['plugin.global-search.settings']) {
const newSettings = changes['plugin.global-search.settings'].newValue as { searchHotkey?: string } | undefined;
if (newSettings?.searchHotkey) {
currentSearchHotkey = newSettings.searchHotkey;
}
}
};
browser.storage.onChanged.addListener(handleStorageChange);
return () => {
browser.storage.onChanged.removeListener(handleStorageChange);
};
});
// Update keydown handler when hotkey changes
$effect(() => {
if (keydownHandler) {
window.removeEventListener('keydown', keydownHandler);
}
keydownHandler = (e: KeyboardEvent) => {
if (matchesHotkey(e, currentSearchHotkey)) {
e.preventDefault();
commandPalleteOpen = true;
tick().then(() => searchbar?.focus());
}
if (e.key === 'Escape') {
commandPalleteOpen = false;
}
};
window.addEventListener('keydown', keydownHandler);
return () => {
if (keydownHandler) {
window.removeEventListener('keydown', keydownHandler);
keydownHandler = null;
}
};
});
onMount(() => {
const progressHandler = (event: CustomEvent) => {
const { completed, total, indexing } = event.detail;
completedJobs = completed;
totalJobs = total;
isIndexing = indexing;
};
window.addEventListener('indexing-progress', progressHandler as EventListener);
const itemsUpdatedHandler = () => {
setupSearchIndexes();
performSearch();
};
window.addEventListener('dynamic-items-updated', itemsUpdatedHandler);
setupSearchIndexes();
// @ts-ignore - Intentionally adding to window
window.setCommandPalleteOpen = (open: boolean) => {
commandPalleteOpen = open;
};
return () => {
window.removeEventListener('indexing-progress', progressHandler as EventListener);
window.removeEventListener('dynamic-items-updated', itemsUpdatedHandler);
};
});
function setupSearchIndexes() {
const { commandsFuse: cfuse, dynamicContentFuse: dfuse, commands, dynamicItems } = createSearchIndexes();
commandsFuse = cfuse;
dynamicContentFuse = dfuse;
dynamicIdToItemMap.clear();
commandIdToItemMap.clear();
dynamicItems.forEach(item => dynamicIdToItemMap.set(item.id, item));
commands.forEach(item => commandIdToItemMap.set(item.id, item));
console.debug(`[Global Search] Indexed ${commands.length} command items and ${dynamicItems.length} dynamic items.`);
}
const performSearch = async () => {
isLoading = true;
selectedIndex = 0;
tick().then(() => {
const selectedElement = resultsList?.querySelector(`li:nth-child(1)`);
selectedElement?.scrollIntoView({ block: 'nearest' });
});
const term = searchTerm.trim().toLowerCase();
if (commandsFuse && dynamicContentFuse) {
combinedResults = await doSearch(
term,
commandsFuse,
commandIdToItemMap,
);
} else {
combinedResults = [];
}
isLoading = false;
};
const debouncedPerformSearch = debounce(performSearch, 20);
$effect(() => {
if (commandPalleteOpen) {
if (searchTerm === '') {
performSearch();
} else {
debouncedPerformSearch();
}
tick().then(() => searchbar?.focus());
} else {
searchTerm = '';
selectedIndex = 0;
combinedResults = [];
}
});
$effect(() => {
if (combinedResults.length === 0 && calculatorResult && commandPalleteOpen) {
selectedIndex = 0;
}
});
const selectNext = () => {
const maxIndex = (calculatorResult ? 1 : 0) + combinedResults.length - 1;
if (selectedIndex < maxIndex) {
selectedIndex++;
tick().then(() => {
const selectedElement = resultsList?.querySelector(`li:nth-child(${selectedIndex + 1})`);
selectedElement?.scrollIntoView({ block: 'nearest' });
});
}
};
const selectPrev = () => {
if (selectedIndex > 0) {
selectedIndex--;
tick().then(() => {
const selectedElement = resultsList?.querySelector(`li:nth-child(${selectedIndex + 1})`);
selectedElement?.scrollIntoView({ block: 'nearest' });
});
}
};
function executeItemAction(item: StaticCommandItem | IndexItem) {
if ('action' in item && typeof item.action === 'function') {
(item as StaticCommandItem).action();
} else if ('actionId' in item && item.actionId && actionMap[item.actionId]) {
actionMap[item.actionId](item as IndexItem);
}
commandPalleteOpen = false;
}
const executeSelected = () => {
if (calculatorResult && selectedIndex === 0) {
navigator.clipboard.writeText(calculatorResult);
commandPalleteOpen = false;
} else {
const resultIndex = calculatorResult ? selectedIndex - 1 : selectedIndex;
const result = combinedResults[resultIndex];
if (result?.item) {
executeItemAction(result.item);
}
}
};
const handleKeyNav = (e: KeyboardEvent) => {
// Handle regular navigation
if (e.key === 'ArrowDown') {
e.preventDefault();
selectNext();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectPrev();
} else if (e.key === 'Enter') {
e.preventDefault();
executeSelected();
} else if (e.key === 'Escape') {
commandPalleteOpen = false;
}
};
</script>
{#if commandPalleteOpen}
<div role="dialog" aria-modal="true" class={settingsState.DarkMode ? 'dark' : ''}>
<div
class="fixed inset-0 z-[50000] bg-zinc-900/40 dark:bg-black/60"
transition:fade={{ duration: 150, easing: quintOut }}
></div>
<div class="fixed inset-0 z-[50000] flex justify-center place-items-start p-8 sm:p-6 md:p-8 select-none scale-120 origin-top"
onclick={() => commandPalleteOpen = false}
onkeydown={(e: KeyboardEvent) => e.key === 'Escape' && (commandPalleteOpen = false)}
role="button"
tabindex="0">
<div
class="w-full max-w-2xl overflow-clip rounded-xl ring-1 shadow-2xl ring-black/5 dark:ring-white/10 { transparencyEffects ? 'bg-white/80 dark:bg-zinc-900/80 backdrop-blur-xl' : 'bg-white dark:bg-zinc-900' }"
transition:scale={{ duration: 100, start: 0.95, opacity: 0, easing: circOut }}
onclick={(e: MouseEvent) => {
e.stopPropagation();
}}
onkeydown={(e: KeyboardEvent) => {
if (e.key === 'Escape') {
commandPalleteOpen = false;
}
}}
role="button"
tabindex="0">
<div class="relative p-2 border-b border-zinc-900/5 dark:border-zinc-100/5">
<div class="absolute top-1/2 translate-y-[calc(-50%-3px)] scale-105 left-5 w-6 h-6 text-[1.3rem] text-zinc-900 dark:text-zinc-400 text-opacity-40 pointer-events-none font-IconFamily">
{'\ueca5'}
</div>
<input
bind:this={searchbar}
bind:value={searchTerm}
onkeydown={handleKeyNav}
class="pr-4 pl-12 w-full h-10 text-lg bg-transparent border-0 outline-none placeholder-zinc-400 text-zinc-700 dark:placeholder-zinc-500 dark:text-white focus:ring-0 sm:text-xl"
placeholder="Search..."
/>
</div>
<ul
bind:this={resultsList}
class="overflow-y-auto max-h-[24rem] text-base scroll-py-2 p-2 gap-0.5 flex flex-col"
>
<Calculator
searchTerm={searchTerm}
isSelected={selectedIndex === 0}
on:hasResult={(e) => updateCalculatorState(e.detail)}
/>
{#if combinedResults.length > 0}
{#each combinedResults as result, i (result.id)}
{@const isSelected = selectedIndex === (calculatorResult ? i + 1 : i)}
{@const item = result.item}
<li>
{#if result.type === 'command'}
{@const staticItem = item as StaticCommandItem}
<button
class="w-full flex items-center px-2 py-1.5 rounded-lg select-none cursor-pointer group transition-colors duration-100 ring-0 dark:ring-zinc-600/50
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white dark:ring-[1px] dark:shadow' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
onclick={() => executeItemAction(staticItem)}
>
<div class="flex-none scale-90 w-8 h-8 text-xl font-IconFamily flex items-center justify-center bg-gradient-to-br {staticItem.category === 'navigation' ? 'from-[#FA5D5D] to-[#DC2F30]' : 'from-[#4FBBFE] to-[#2090F3]'} rounded-md text-white">{staticItem.icon}</div>
<span class="ml-4 text-lg truncate">
<HighlightedText text={staticItem.text} term={searchTerm} matches={result.matches} />
</span>
</button>
{:else if result.type === 'dynamic'}
{@const dynamicItem = item as IndexItem}
{@const RenderComponent = renderComponentMap[dynamicItem.renderComponentId]}
{#if RenderComponent}
<RenderComponent
item={dynamicItem}
isSelected={isSelected}
searchTerm={searchTerm}
matches={result.matches}
onclick={() => executeItemAction(dynamicItem)}
onkeydown={() => executeItemAction(dynamicItem)}
role="button"
tabindex="0"
/>
{:else}
<button
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white dark:ring-[1px]' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
onclick={() => executeItemAction(dynamicItem)}
>
<div class="flex items-center w-full">
<div class="flex-none w-8 h-8 text-xl font-IconFamily flex items-center justify-center {isSelected ? 'text-zinc-900 dark:text-white' : 'text-zinc-600 dark:text-zinc-400'}">{dynamicItem.metadata?.icon || '\ue924'}</div>
<span class="ml-4 text-lg truncate">
<HighlightedText text={dynamicItem.text} term={searchTerm} matches={result.matches} />
</span>
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
{dynamicItem.category}
</span>
</div>
{#if dynamicItem.content}
<div class="mt-1 ml-12 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2 text-start">
<HighlightedText text={dynamicItem.content} term={searchTerm} matches={result.matches} />
</div>
{/if}
</button>
{/if}
{/if}
</li>
{/each}
{:else if !calculatorResult}
<div class="px-8 py-16 text-center text-zinc-900 dark:text-zinc-200 sm:px-16">
{#if isLoading}
<div class="mx-auto w-8 h-8 rounded-full border-2 animate-spin border-zinc-300 dark:border-zinc-700 border-t-zinc-600 dark:border-t-zinc-300"></div>
<p class="mt-4 text-lg dark:text-zinc-300">Searching...</p>
{:else}
<svg class="mx-auto w-8 h-8 text-opacity-40 dark:text-opacity-60" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
<p class="mt-6 text-lg dark:text-zinc-300">No matches found. Try something else.</p>
{/if}
</div>
{/if}
</ul>
<div class="px-3 py-2 w-full border-t border-zinc-900/5 dark:border-zinc-100/5 bg-white/5">
{#if combinedResults.length > 0 || calculatorResult}
<div class="flex justify-between items-center h-7 text-sm text-zinc-500 dark:text-zinc-400">
<div class="flex gap-4 items-center">
{@render Shortcut({ text: 'Navigate', keybind: ['↑', '↓']})}
{#if calculatorResult && selectedIndex === 0}
{@render Shortcut({ text: 'Copy result', keybind: ['↵']})}
{:else}
{@render Shortcut({ text: 'Select', keybind: ['↵']})}
{/if}
</div>
{#if isIndexing}
<div class="inset-x-0 top-0">
<div class="absolute right-2 -bottom-4 text-[10px] text-zinc-500 dark:text-zinc-400">
Indexing
</div>
<div class="overflow-hidden h-0.5 bg-zinc-200 dark:bg-zinc-700">
<div
class="h-full bg-blue-500 transition-all duration-300 ease-out"
style="width: {(completedJobs / totalJobs) * 100}%"
></div>
</div>
</div>
{/if}
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
{#snippet Shortcut({ text, keybind }: { text: string, keybind: string[] }) }
<div class="flex gap-2 items-center">
<div class="flex gap-1 items-center">
{#each keybind as key}
<kbd class="size-6 text-[0.9rem] flex justify-center items-center rounded bg-zinc-100 dark:bg-zinc-100/10">{key}</kbd>
{/each}
</div>
<span>{text}</span>
</div>
{/snippet}
@@ -0,0 +1,34 @@
<script lang="ts">
import HighlightedText from '../../utils/HighlightedText.svelte';
import type { DynamicContentItem } from '../../utils/dynamicItems';
import type { FuseResultMatch } from '../../core/types';
const { item, isSelected, searchTerm, matches, onclick } = $props<{
item: DynamicContentItem;
isSelected: boolean;
searchTerm: string;
matches?: readonly FuseResultMatch[];
onclick: () => void;
}>();
</script>
<button
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group transition-colors duration-100 ring-0 dark:ring-zinc-600/50
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white dark:ring-[1px] dark:shadow' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
onclick={onclick}
>
<div class="flex items-center w-full">
<div class="flex-none scale-90 w-8 h-8 text-xl font-IconFamily flex items-center justify-center bg-gradient-to-br from-[#59F675] to-[#1BC636] rounded-md text-white">{item.metadata?.icon || '\uebee'}</div>
<span class="ml-4 text-lg truncate">
<HighlightedText text={item.text} term={searchTerm} matches={matches} />
</span>
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
{item.category}
</span>
</div>
{#if item.content}
<div class="mt-1 ml-12 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2 text-start">
<HighlightedText text={item.content} term={searchTerm} matches={matches} />
</div>
{/if}
</button>
@@ -0,0 +1,29 @@
<script lang="ts">
import HighlightedText from '../../utils/HighlightedText.svelte';
import type { DynamicContentItem } from '../../utils/dynamicItems';
import type { FuseResultMatch } from '../../core/types';
const { item, isSelected, searchTerm, matches, onclick } = $props<{
item: DynamicContentItem;
isSelected: boolean;
searchTerm: string;
matches?: readonly FuseResultMatch[];
onclick: () => void;
}>();
</script>
<button
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group transition-colors duration-100 ring-0 dark:ring-zinc-600/50
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white dark:ring-[1px] dark:shadow' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
onclick={onclick}
>
<div class="flex items-center w-full">
<div class="flex-none scale-90 w-8 h-8 text-xl font-IconFamily flex items-center justify-center text-white bg-gradient-to-br from-[#59aaf6] to-[#1b62c6] rounded-md">{item.metadata?.icon || '\uebe7'}</div>
<span class="ml-4 text-lg truncate {item.metadata?.closed ? 'line-through opacity-80' : ''}">
<HighlightedText text={item.text} term={searchTerm} matches={matches} />
</span>
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
{item.category}
</span>
</div>
</button>
@@ -0,0 +1,36 @@
<script lang="ts">
import HighlightedText from '../../utils/HighlightedText.svelte';
import type { IndexItem } from '../../indexing/types';
import type { FuseResultMatch } from '../../core/types';
export let item: IndexItem;
export let isSelected: boolean;
export let searchTerm: string;
export let matches: readonly FuseResultMatch[] | undefined;
export let onclick: (() => void) | undefined;
</script>
<button
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group transition-colors duration-100 ring-0 dark:ring-zinc-600/50
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white dark:ring-[1px] dark:shadow' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
onclick={onclick}
>
<div class="flex items-center w-full">
<div class="flex-none scale-90 w-8 h-8 text-xl font-IconFamily flex items-center justify-center text-white {item.metadata?.type === 'assessments' ? 'bg-gradient-to-br from-[#fa915d] to-[#dc6c2f] rounded-md' : 'bg-gradient-to-br from-[#4FBBFE] to-[#2090F3] rounded-md'} {item.metadata.isActive ? 'opacity-100' : 'opacity-80'}">
{item.metadata?.type === 'assessments' ? '\ueac3' : '\ueb4d'}
</div>
<span class="ml-4 text-lg truncate {item.metadata.isActive ? 'opacity-100' : 'opacity-70'}">
<HighlightedText text={item.text} term={searchTerm} matches={matches} />
</span>
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400 {item.metadata.isActive ? 'opacity-100' : 'opacity-70'}">
{item.metadata?.subjectCode}
</span>
</div>
{#if item.content}
<div class="mt-1 ml-12 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2 text-start">
<HighlightedText text={item.content} term={searchTerm} matches={matches} />
</div>
{/if}
</button>
@@ -0,0 +1,238 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
import { waitForElm } from "@/seqta/utils/waitForElm";
export interface BaseCommandItem {
id: string;
text: string;
category: string;
icon: string;
action: () => void;
keywords?: string[];
priority?: number;
}
export interface StaticCommandItem extends BaseCommandItem {
keybind?: string[];
keybindLabel?: string[];
}
// Function to get current lesson
async function getCurrentLesson() {
const date = new Date();
const todayFormatted = formatDate(date);
try {
const response = await fetch(`${location.origin}/seqta/student/load/timetable?`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
from: todayFormatted,
until: todayFormatted,
student: 69,
}),
});
const timetableData = await response.json();
if (!timetableData.payload.items.length) {
alert("No lessons today!");
return null;
}
const lessons = timetableData.payload.items.sort((a: any, b: any) =>
a.from.localeCompare(b.from)
);
const currentTime = new Date();
for (const lesson of lessons) {
const [startHour, startMinute] = lesson.from.split(":").map(Number);
const [endHour, endMinute] = lesson.until.split(":").map(Number);
const startDate = new Date(currentTime);
startDate.setHours(startHour, startMinute, 0);
const endDate = new Date(currentTime);
endDate.setHours(endHour, endMinute, 0);
if (startDate <= currentTime && endDate > currentTime) {
return lesson;
}
}
alert("There is no current lesson!");
return null;
} catch (error) {
console.error("Error fetching current lesson:", error);
alert("Error getting current lesson. Please try again.");
return null;
}
}
async function navigateToSpecificLesson(lesson: any) {
try {
await waitForElm(".course .navigator", true, 100, 100);
const today = new Date();
const todayDateString = today.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short'
});
const weeks = document.querySelectorAll(".course .navigator .week");
for (const week of weeks) {
// Look for lessons in this week
const lessons = week.querySelectorAll(".lesson");
for (const lessonElement of lessons) {
const metaElement = lessonElement.querySelector(".meta");
if (!metaElement) continue;
const dateElement = metaElement.querySelector(".date");
const periodElement = metaElement.querySelector(".period");
if (!dateElement || !periodElement) continue;
const lessonDate = dateElement.textContent?.trim();
const lessonPeriod = periodElement.textContent?.trim().match(/\d+/)?.[0];
// extract the number from the period
const normalizedLessonPeriod = lesson.period?.match(/\d+/)?.[0];
// Check if this lesson matches today's date and the current lesson's period
if (lessonDate === todayDateString && lessonPeriod === normalizedLessonPeriod) {
// Found the exact matching lesson, click it
(lessonElement as HTMLElement).click();
console.log(`Navigated to exact lesson: ${lessonDate} ${lessonPeriod}`);
return true;
}
}
}
const todayButton = Array.from(document.querySelectorAll('#toolbar .uiButton'))
.find(button => button.textContent?.trim() === 'Today') as HTMLElement;
if (todayButton) {
todayButton.click();
}
return true;
} catch (error) {
console.error("Error navigating to specific lesson:", error);
return false;
}
}
function formatDate(date: Date): string {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${year}-${month}-${day}`;
}
const staticCommands: StaticCommandItem[] = [
{
id: "home",
icon: "\ueb4c",
category: "navigation",
text: "Home",
action: () => {
window.location.hash = "?page=/home";
loadHomePage();
},
priority: 4,
},
{
id: "messages",
icon: "\uebfd",
category: "navigation",
text: "Direct Messages",
action: () => {
window.location.hash = "?page=/messages";
},
priority: 4,
},
{
id: "timetable",
icon: "\ue9cd",
category: "navigation",
text: "Timetable",
action: () => {
window.location.hash = "?page=/timetable";
},
priority: 4,
},
{
id: "Current Lesson",
icon: "\ue9a5",
category: "navigation",
text: "Current Lesson",
priority: 4,
action: async () => {
const currentLesson = await getCurrentLesson();
if (currentLesson && currentLesson.programmeID !== 0) {
// Navigate to course page first
window.location.hash = `?page=/courses/${currentLesson.programmeID}:${currentLesson.metaID}`;
await navigateToSpecificLesson(currentLesson);
}
},
},
{
id: "assessments",
icon: "\ueac3",
category: "navigation",
text: "Assessments",
keybind: ["alt+a"],
keybindLabel: ["Alt", "A"],
action: () => {
window.location.hash = "?page=/assessments/upcoming";
},
priority: 4,
},
{
id: "dashboard",
icon: "\ueb87",
category: "navigation",
text: "Dashboard",
priority: 4,
action: () => {
window.location.hash = "?page=/dashboard";
},
},
{
id: "compose-message",
icon: "\ue924",
category: "action",
text: "Compose Message",
action: () => {
window.postMessage({
type: "triggerKeyboardEvent",
key: 'm',
code: 'KeyM',
keyCode: 77,
altKey: true
}, "*");
},
keywords: ["compose", "message", "dm", "direct message", "new message"],
priority: 3,
},
{
id: "toggle-dark-mode",
icon: "\uecfe",
category: "action",
text: "Toggle Dark Mode",
action: () => (settingsState.DarkMode = !settingsState.DarkMode),
priority: 3,
keywords: ["theme", "appearance"],
},
];
/**
* Returns the predefined list of static commands.
*/
export const getStaticCommands = (): StaticCommandItem[] => {
return [...staticCommands];
};
@@ -0,0 +1,193 @@
import type { Plugin } from "@/plugins/core/types";
import { BasePlugin } from "@/plugins/core/settings";
import {
booleanSetting,
buttonSetting,
defineSettings,
Setting,
hotkeySetting,
} from "@/plugins/core/settingsHelpers";
import styles from "./styles.css?inline";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { runIndexing } from "../indexing/indexer";
import { initVectorSearch } from "../search/vector/vectorSearch";
import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar";
import { IndexedDbManager } from "embeddia";
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
// Platform-aware default hotkey
const getDefaultHotkey = () => {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
return isMac ? "cmd+k" : "ctrl+k";
};
const settings = defineSettings({
searchHotkey: hotkeySetting({
default: getDefaultHotkey(),
title: "Search Hotkey",
description: "Keyboard shortcut to open the search",
}),
showRecentFirst: booleanSetting({
default: true,
title: "Show Recent First",
description: "Sort dynamic content by most recent first",
}),
transparencyEffects: booleanSetting({
default: true,
title: "Transparency Effects",
description: "Enable transparency effects for the search bar",
}),
runIndexingOnLoad: booleanSetting({
default: true,
title: "Index on Page Load",
description: "Run content indexing when SEQTA loads",
}),
resetIndex: buttonSetting({
title: "Reset Index",
description: "Reset the search index and storage",
trigger: async () => {
const confirmed = confirm("Are you sure you want to reset the search index and storage?");
if (confirmed) {
try {
// Reset the vector worker first
const workerManager = VectorWorkerManager.getInstance();
await workerManager.resetWorker();
console.log("Vector worker reset successfully");
} catch (e) {
console.warn("Failed to reset vector worker:", e);
}
// Delete both 'embeddiaDB' and 'betterseqta-index' using native IndexedDB APIs
const deleteDb = (dbName: string) => {
return new Promise<void>((resolve, reject) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
req.onblocked = () => {
reject(new Error(`One database is open, failed to remove: ${dbName}`));
};
});
};
try {
await deleteDb("embeddiaDB");
await deleteDb("betterseqta-index");
alert("Search index and storage have been reset.");
} catch (e) {
alert("Failed to reset one or more databases: " + String(e));
}
}
},
}),
});
class GlobalSearchPlugin extends BasePlugin<typeof settings> {
@Setting(settings.searchHotkey)
searchHotkey!: string;
@Setting(settings.showRecentFirst)
showRecentFirst!: boolean;
@Setting(settings.transparencyEffects)
transparencyEffects!: boolean;
@Setting(settings.runIndexingOnLoad)
runIndexingOnLoad!: boolean;
@Setting(settings.resetIndex)
resetIndex!: () => void;
}
const settingsInstance = new GlobalSearchPlugin();
const globalSearchPlugin: Plugin<typeof settings> = {
id: "global-search",
name: "Global Search",
description: "Quick search for everything in SEQTA",
version: "1.0.0",
settings: settingsInstance.settings,
disableToggle: true,
defaultEnabled: false,
beta: true,
styles: styles,
run: async (api) => {
const appRef = { current: null };
try {
await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", {
primaryKey: "id",
autoIncrement: false,
});
} catch (error) {
console.error("Failed to create IndexedDB:", error);
// Continue execution - the search might still work without persistence
}
initVectorSearch();
// Warm up vector worker in background to improve initial response time
setTimeout(async () => {
try {
VectorWorkerManager.getInstance();
} catch (error) {
console.warn("[Global Search] Vector worker warm-up failed:", error);
}
}, 1000);
// Add debug helpers to window for troubleshooting
// @ts-ignore
window.globalSearchDebug = {
resetWorker: async () => {
const workerManager = VectorWorkerManager.getInstance();
await workerManager.resetWorker();
console.log("Vector worker reset via debug helper");
},
checkWorkerStatus: () => {
const workerManager = VectorWorkerManager.getInstance();
console.log("Streaming active:", workerManager.isStreamingActive());
},
checkIndexedDBSize: async () => {
try {
const estimate = await navigator.storage.estimate();
console.log("Storage estimate:", estimate);
// Check embeddiaDB size
const dbRequest = indexedDB.open("embeddiaDB");
dbRequest.onsuccess = () => {
const db = dbRequest.result;
const transaction = db.transaction(["embeddiaObjectStore"], "readonly");
const store = transaction.objectStore("embeddiaObjectStore");
const countRequest = store.count();
countRequest.onsuccess = () => {
console.log("embeddiaDB item count:", countRequest.result);
};
};
} catch (e) {
console.error("Error checking storage:", e);
}
}
};
if (api.settings.runIndexingOnLoad) {
setTimeout(async () => {
await runIndexing();
}, 2000);
}
const title = document.querySelector("#title");
if (title) {
mountSearchBar(title, api, appRef);
} else {
const titleElement = await waitForElm("#title", true, 100, 60);
mountSearchBar(titleElement, api, appRef);
}
return () => {
cleanupSearchBar(appRef);
};
},
};
export default globalSearchPlugin;
@@ -0,0 +1,104 @@
import renderSvelte from "@/interface/main";
import SearchBar from "../components/SearchBar.svelte";
import { unmount } from "svelte";
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
import { formatHotkeyForDisplay, isValidHotkey } from "../utils/hotkeyUtils";
import browser from "webextension-polyfill";
export function mountSearchBar(
titleElement: Element,
api: any,
appRef: { current: any; storageChangeHandler?: any },
) {
if (titleElement.querySelector(".search-trigger")) {
return;
}
// Fallback to default hotkey if the current one is invalid
let currentHotkey = isValidHotkey(api.settings.searchHotkey) ? api.settings.searchHotkey : "ctrl+k";
let hotkeyDisplay = formatHotkeyForDisplay(currentHotkey);
const searchButton = document.createElement("div");
searchButton.className = "search-trigger";
const updateSearchButtonDisplay = () => {
searchButton.innerHTML = /* html */ `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<p>Quick search...</p>
<span style="margin-left: auto; display: flex; align-items: center; color: #777; font-size: 12px;">${hotkeyDisplay}</span>
`;
};
updateSearchButtonDisplay();
titleElement.appendChild(searchButton);
// Listen for hotkey setting changes
const handleStorageChange = (changes: any, area: string) => {
if (area === 'local' && changes['plugin.global-search.settings']) {
const newSettings = changes['plugin.global-search.settings'].newValue as { searchHotkey?: string } | undefined;
if (newSettings?.searchHotkey && isValidHotkey(newSettings.searchHotkey)) {
currentHotkey = newSettings.searchHotkey;
hotkeyDisplay = formatHotkeyForDisplay(currentHotkey);
updateSearchButtonDisplay();
}
}
};
browser.storage.onChanged.addListener(handleStorageChange);
// Store reference to cleanup function for proper removal
appRef.storageChangeHandler = handleStorageChange;
const searchRoot = document.createElement("div");
document.body.appendChild(searchRoot);
const searchRootShadow = searchRoot.attachShadow({ mode: "open" });
searchButton.addEventListener("click", () => {
// @ts-ignore - Intentionally adding to window
window.setCommandPalleteOpen(true);
});
try {
appRef.current = renderSvelte(SearchBar, searchRootShadow, {
transparencyEffects: api.settings.transparencyEffects ? true : false,
showRecentFirst: api.settings.showRecentFirst,
searchHotkey: currentHotkey,
});
} catch (error) {
console.error("Error rendering Svelte component:", error);
}
}
export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?: any }) {
if (appRef.current) {
try {
unmount(appRef.current);
appRef.current = null;
} catch (error) {
console.error("Error unmounting Svelte component:", error);
}
}
// Remove search trigger button
const searchTrigger = document.querySelector(".search-trigger");
if (searchTrigger) {
searchTrigger.remove();
}
// Remove search root
const searchRoot = document.querySelector("div[data-search-root]");
if (searchRoot) {
searchRoot.remove();
}
// Clean up vector worker
VectorWorkerManager.getInstance().terminate();
if (appRef.storageChangeHandler) {
browser.storage.onChanged.removeListener(appRef.storageChangeHandler);
appRef.storageChangeHandler = null;
}
}
@@ -0,0 +1,71 @@
.search-trigger {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
margin-left: 10px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
margin-right: auto !important;
padding: 3px 12px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(4px);
user-select: none;
svg {
opacity: 0.8;
}
p {
font-size: 14px;
margin-left: 8px;
margin-right: 48px;
height: 100%;
margin-bottom: 0;
line-height: 32px;
font-weight: 400;
}
}
/* Light mode styles */
.search-trigger {
background-color: rgba(248, 250, 252, 0.05) !important;
border: 1px solid rgba(0, 0, 0, 0.1) !important;
color: #555 !important;
p {
color: #555 !important;
}
svg {
color: #555;
}
}
.dark .search-trigger {
background-color: rgba(0, 0, 0, 0.03) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
color: #aaa !important;
p {
color: #aaa !important;
}
svg {
color: #aaa;
}
}
.highlight {
background-color: rgba(255, 213, 0, 0.3);
font-weight: 500;
border-radius: 2px;
padding: 0 1px;
margin: 0 -1px;
}
.dark .highlight {
background-color: rgba(255, 230, 100, 0.4);
}
@@ -0,0 +1,28 @@
import type { StaticCommandItem } from "./commands";
import type { IndexItem } from "../indexing/types";
export interface MatchIndices {
readonly 0: number;
readonly 1: number;
}
export interface FuseResultMatch {
key?: string;
value?: string;
indices: readonly MatchIndices[];
}
export interface CombinedResult {
id: string;
type: "command" | "dynamic";
score: number;
item: StaticCommandItem | IndexItem;
matches?: readonly FuseResultMatch[];
}
export interface FuseResult<T> {
item: T;
refIndex: number;
score?: number;
matches?: readonly FuseResultMatch[];
}
@@ -0,0 +1,87 @@
import { waitForElm } from "@/seqta/utils/waitForElm";
import type { IndexItem } from "./types";
import ReactFiber from "@/seqta/utils/ReactFiber";
import { delay } from "@/seqta/utils/delay";
interface MessageMetadata {
messageId: number;
author: string;
senderId: number;
senderType: string;
timestamp: string;
hasAttachments: boolean;
attachmentCount: number;
read: boolean;
}
interface AssessmentMetadata {
assessmentId?: number;
messageId?: number;
subject?: string;
term?: string;
programmeId?: number;
metaclassId?: number;
timestamp: string;
isMessageBased?: boolean;
author?: string;
}
type ActionHandler<T = any> = (item: IndexItem & { metadata: T }) => void;
export const actionMap: Record<string, ActionHandler<any>> = {
message: (async (item: IndexItem & { metadata: MessageMetadata }) => {
window.location.hash = `#?page=/messages`;
await waitForElm('[class*="Viewer__Viewer___"] > div', true, 20);
// Select the specific direct message
ReactFiber.find('[class*="Viewer__Viewer___"] > div').setState({
selected: new Set([item.metadata.messageId]),
});
// send a network request to mark as read
fetch('/seqta/student/save/message', {
method: "POST",
credentials: "include",
body: JSON.stringify({
items: [item.metadata.messageId],
mode: 'x-read',
read: true,
}),
});
await delay(10);
const button = document.querySelector('[class*="MessageList__selected___"]');
if (button) {
(button as HTMLElement).click();
}
}) as ActionHandler<any>,
assessment: (async (item: IndexItem & { metadata: AssessmentMetadata }) => {
if (item.metadata.isMessageBased) {
window.location.hash = `#?page=/messages`;
await waitForElm('[class*="Viewer__Viewer___"] > div', true, 20);
// Select the specific direct message
ReactFiber.find('[class*="Viewer__Viewer___"] > div').setState({
selected: new Set([item.metadata.messageId]),
});
} else {
window.location.hash = `#?page=/assessments&id=${item.metadata.assessmentId}`;
}
}) as ActionHandler<any>,
subjectassessment: ((item: IndexItem) => {
window.location.href = `/#?page=/assessments/${item.metadata.programme}:${item.metadata.subjectId}`;
}) as ActionHandler<any>,
subjectcourse: ((item: IndexItem) => {
window.location.href = `/#?page=/courses/${item.metadata.programme}:${item.metadata.subjectId}`;
}) as ActionHandler<any>,
forum: ((item: IndexItem) => {
window.location.href = `/#?page=/forums/${item.metadata.forumId}`;
}) as ActionHandler<any>,
};
@@ -0,0 +1,237 @@
const DB_NAME = "betterseqta-index";
const META_STORE = "meta";
const VERSION_KEY = "betterseqta-index-version";
let dbPromise: Promise<IDBDatabase> | null = null;
let cachedDb: IDBDatabase | null = null;
function getCurrentVersion(): number {
const storedVersion = localStorage.getItem(VERSION_KEY);
return storedVersion ? parseInt(storedVersion, 10) : 1;
}
function updateVersion(version: number) {
localStorage.setItem(VERSION_KEY, version.toString());
}
function openDB(): Promise<IDBDatabase> {
if (cachedDb && cachedDb.version >= getCurrentVersion()) {
return Promise.resolve(cachedDb);
}
if (dbPromise) return dbPromise;
const currentVersion = getCurrentVersion();
dbPromise = new Promise((resolve, reject) => {
let request: IDBOpenDBRequest;
try {
request = indexedDB.open(DB_NAME, currentVersion);
} catch (e) {
console.warn("Database version conflict, recreating database...");
if (cachedDb) {
cachedDb.close();
cachedDb = null;
}
indexedDB.deleteDatabase(DB_NAME);
localStorage.removeItem(VERSION_KEY);
request = indexedDB.open(DB_NAME, 1);
updateVersion(1);
}
request.onupgradeneeded = (event) => {
const db = request.result;
const existingStores = Array.from(db.objectStoreNames);
if (!existingStores.includes(META_STORE)) {
db.createObjectStore(META_STORE);
}
updateVersion(event.newVersion || 1);
};
request.onsuccess = () => {
if (cachedDb && cachedDb !== request.result) {
cachedDb.close();
}
cachedDb = request.result;
cachedDb.onclose = () => {
cachedDb = null;
dbPromise = null;
};
resolve(request.result);
};
request.onerror = () => {
console.error("Error opening database:", request.error);
if (cachedDb) {
cachedDb.close();
cachedDb = null;
}
indexedDB.deleteDatabase(DB_NAME);
localStorage.removeItem(VERSION_KEY);
dbPromise = null;
reject(request.error);
};
});
return dbPromise;
}
async function getStore(store: string, mode: IDBTransactionMode = "readonly") {
const db = await openDB();
if (!db.objectStoreNames.contains(store)) {
await upgradeDB(store);
const upgradedDb = await openDB();
const tx = upgradedDb.transaction(store, mode);
return tx.objectStore(store);
}
const tx = db.transaction(store, mode);
return tx.objectStore(store);
}
function upgradeDB(newStore: string): Promise<void> {
return new Promise((resolve, reject) => {
const currentVersion = getCurrentVersion();
const newVersion = currentVersion + 1;
if (cachedDb) {
cachedDb.close();
cachedDb = null;
}
dbPromise = null;
const request = indexedDB.open(DB_NAME, newVersion);
request.onupgradeneeded = (event) => {
const db = request.result;
if (!db.objectStoreNames.contains(newStore)) {
db.createObjectStore(newStore);
}
updateVersion(event.newVersion || newVersion);
};
request.onsuccess = () => {
cachedDb = request.result;
cachedDb.onclose = () => {
cachedDb = null;
dbPromise = null;
};
dbPromise = Promise.resolve(request.result);
resolve();
};
request.onerror = () => {
console.error("Error upgrading database:", request.error);
reject(request.error);
};
});
}
export async function getAll(store: string): Promise<any[]> {
try {
const s = await getStore(store);
return new Promise((resolve, reject) => {
const req = s.getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
} catch (error) {
console.error(`Error in getAll for store ${store}:`, error);
return [];
}
}
export async function get(store: string, key: string): Promise<any> {
try {
const s = await getStore(store);
return new Promise((resolve, reject) => {
const req = s.get(key);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
} catch (error) {
console.error(`Error in get for store ${store}, key ${key}:`, error);
return null;
}
}
export async function put(
store: string,
value: any,
key?: string,
): Promise<void> {
try {
const s = await getStore(store, "readwrite");
return new Promise((resolve, reject) => {
const req = key ? s.put(value, key) : s.put(value);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
} catch (error) {
console.error(`Error in put for store ${store}:`, error);
throw error;
}
}
export async function remove(store: string, key: string): Promise<void> {
try {
const s = await getStore(store, "readwrite");
return new Promise((resolve, reject) => {
const req = s.delete(key);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
} catch (error) {
console.error(`Error in remove for store ${store}, key ${key}:`, error);
throw error;
}
}
export async function clear(store: string): Promise<void> {
try {
const s = await getStore(store, "readwrite");
return new Promise((resolve, reject) => {
const req = s.clear();
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
} catch (error) {
console.error(`Error in clear for store ${store}:`, error);
throw error;
}
}
export async function resetDatabase(): Promise<void> {
if (cachedDb) {
cachedDb.close();
cachedDb = null;
}
if (dbPromise) {
try {
const db = await dbPromise;
db.close();
} catch (e) {}
dbPromise = null;
}
return new Promise((resolve, reject) => {
const req = indexedDB.deleteDatabase(DB_NAME);
req.onsuccess = () => {
localStorage.removeItem(VERSION_KEY);
resolve();
};
req.onerror = () => reject(req.error);
});
}
@@ -0,0 +1,423 @@
import { clear, getAll, get, put, remove } from "./db";
import { jobs } from "./jobs";
import { renderComponentMap } from "./renderComponents";
import type { IndexItem, Job, JobContext } from "./types";
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
import { loadDynamicItems } from "../utils/dynamicItems";
import { getVectorizedItemIds } from "./utils";
const META_STORE = "meta";
const LOCK_KEY = "bsq-indexer-lock";
const HEARTBEAT_INTERVAL = 10000;
const LOCK_TIMEOUT = 20000;
const LOCK_ACQUIRE_TIMEOUT = 5000;
/* ─────────── Progressmeta helpers ─────────── */
async function loadProgress<T = any>(jobId: string): Promise<T | undefined> {
const rec = await get(META_STORE, `progress:${jobId}`);
return rec?.progress as T | undefined;
}
async function saveProgress<T = any>(jobId: string, progress: T): Promise<void> {
await put(META_STORE, { progress }, `progress:${jobId}`);
}
/* ───────────────────────────────────────────── */
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
let isIndexingActive = false;
function shouldRun(job: Job, lastRun?: number): boolean {
const now = Date.now();
if (job.frequency === "pageLoad") return true;
if (!lastRun) return true;
if (job.frequency.type === "interval") {
return now - lastRun >= job.frequency.ms;
}
if (job.frequency.type === "expiry") {
return now - lastRun >= job.frequency.afterMs;
}
return false;
}
function getLastRunMeta(jobId: string): Promise<number | undefined> {
return getAll(META_STORE).then((metaItems) => {
const match = metaItems.find((m: any) => m.jobId === jobId);
return match?.lastRun;
});
}
async function updateLastRunMeta(jobId: string): Promise<void> {
await put(META_STORE, { jobId, lastRun: Date.now() }, jobId);
}
async function acquireLock(): Promise<boolean> {
if (isIndexingActive) {
console.debug("[Indexer] Already indexing in this tab");
return false;
}
const lockId = `${Date.now()}-${Math.random()}`;
const startTime = Date.now();
while (Date.now() - startTime < LOCK_ACQUIRE_TIMEOUT) {
const currentLock = localStorage.getItem(LOCK_KEY);
const currentTime = Date.now();
if (!currentLock) {
localStorage.setItem(LOCK_KEY, lockId);
await new Promise(resolve => setTimeout(resolve, 50));
if (localStorage.getItem(LOCK_KEY) === lockId) {
isIndexingActive = true;
return true;
}
} else {
try {
const [timestamp] = currentLock.split('-');
const lockTime = parseInt(timestamp, 10);
if (isNaN(lockTime) || currentTime - lockTime > LOCK_TIMEOUT) {
localStorage.setItem(LOCK_KEY, lockId);
await new Promise(resolve => setTimeout(resolve, 50));
if (localStorage.getItem(LOCK_KEY) === lockId) {
isIndexingActive = true;
return true;
}
}
} catch (e) {
console.warn("[Indexer] Error parsing lock:", e);
}
}
await new Promise(resolve => setTimeout(resolve, 100));
}
return false;
}
function startHeartbeat() {
const lockId = localStorage.getItem(LOCK_KEY);
if (!lockId) return;
heartbeatTimer = setInterval(() => {
if (localStorage.getItem(LOCK_KEY)?.endsWith(lockId.split('-')[1])) {
const newLockId = `${Date.now()}-${lockId.split('-')[1]}`;
localStorage.setItem(LOCK_KEY, newLockId);
}
}, HEARTBEAT_INTERVAL);
}
function stopHeartbeat() {
if (heartbeatTimer) clearInterval(heartbeatTimer);
localStorage.removeItem(LOCK_KEY);
isIndexingActive = false;
}
function dispatchProgress(
completed: number,
total: number,
indexing: boolean,
status?: string,
detail?: string,
) {
const event = new CustomEvent("indexing-progress", {
detail: { completed, total, indexing, status, detail },
});
window.dispatchEvent(event);
}
export async function loadAllStoredItems(): Promise<IndexItem[]> {
const all: IndexItem[] = [];
const jobIds = Object.keys(jobs);
for (const jobId of jobIds) {
try {
const items = (await getAll(jobId)) as IndexItem[];
const job = jobs[jobId];
for (const item of items) {
if (
item &&
item.id &&
item.text &&
item.category &&
item.actionId &&
job.renderComponentId // job might not be defined if store exists but job was removed
) {
all.push(item);
} else {
console.warn(`Skipping invalid item from job store ${jobId}:`, item);
}
}
} catch (error) {
console.error(`Error loading items for job store ${jobId}:`, error);
}
}
console.debug(
`[Indexer] Loaded ${all.length} items from all primary stores.`,
);
return all;
}
export async function runIndexing(): Promise<void> {
if (!(await acquireLock())) {
console.debug(
"%c[Indexer] Could not acquire lock - another tab is indexing or this tab is already indexing",
"color: gray",
);
return;
}
startHeartbeat();
console.debug("%c[Indexer] Starting indexing...", "color: green");
const jobIds = Object.keys(jobs);
let completedJobs = 0;
const totalSteps = jobIds.length + 1;
dispatchProgress(completedJobs, totalSteps, true, "Starting jobs");
let hasStreamingJobs = false;
for (const jobId of jobIds) {
dispatchProgress(
completedJobs,
totalSteps,
true,
`Running job: ${jobs[jobId].label}`,
);
const job = jobs[jobId];
const lastRun = await getLastRunMeta(jobId);
if (!shouldRun(job, lastRun)) {
console.debug(
`%c[Indexer] Skipping job "${jobId}" (not due)`,
"color: gray",
);
completedJobs++;
dispatchProgress(
completedJobs,
totalSteps,
true,
`Skipped job: ${job.label}`,
);
continue;
}
const getStoredItems = async (storeId?: string) =>
await getAll(storeId ?? jobId);
const setStoredItems = async (items: IndexItem[], storeId?: string) => {
const targetStore = storeId ?? jobId;
await clear(targetStore);
const validItems = items.filter((i) => i && i.id);
if (validItems.length !== items.length) {
console.warn(
`[Indexer Job ${jobId} -> Store ${targetStore}] Filtered out ${items.length - validItems.length} invalid items before storing.`,
);
}
await Promise.all(validItems.map((i) => put(targetStore, i, i.id)));
};
const addItem = async (item: IndexItem, storeId?: string) => {
const targetStore = storeId ?? jobId;
if (item && item.id) {
await put(targetStore, item, item.id);
} else {
console.warn(
`[Indexer Job ${jobId} -> Store ${targetStore}] Attempted to add invalid item:`,
item,
);
}
};
const removeItem = async (id: string, storeId?: string) => {
const targetStore = storeId ?? jobId;
await remove(targetStore, id);
};
const ctx: JobContext = {
getStoredItems,
setStoredItems,
addItem,
removeItem,
getProgress: () => loadProgress(jobId),
setProgress: (p) => saveProgress(jobId, p),
};
console.debug(`%c[Indexer] Running job "${jobId}"...`, "color: #4ea1ff");
try {
const newItemsRaw = await job.run(ctx);
const stored = await getStoredItems();
let merged = mergeItems(stored, newItemsRaw);
if (job.purge) merged = job.purge(merged);
await setStoredItems(merged);
await updateLastRunMeta(jobId);
if (jobId === 'messages' || jobId === 'notifications') {
hasStreamingJobs = true;
}
console.debug(
`%c[Indexer] ${job.label}: ${newItemsRaw.length} new items reported by run, ${merged.length} total items now in '${jobId}' store.`,
"color: #00c46f",
);
} catch (err) {
console.debug(`%c[Indexer] Job ${job.label} failed:`, "color: red");
console.error(err);
}
completedJobs++;
dispatchProgress(
completedJobs,
totalSteps,
true,
`Finished job: ${job.label}`,
);
}
let allItemsInPrimaryStores = await loadAllStoredItems();
if (allItemsInPrimaryStores.length > 0) {
console.debug(
`%c[Indexer] Checking ${allItemsInPrimaryStores.length} items for vectorization...`,
"color: #4ea1ff",
);
// Pre-filter items to avoid initializing worker if nothing new
const vectorizedItemIds = await getVectorizedItemIds();
const newItemsToVectorize = allItemsInPrimaryStores.filter(item => !vectorizedItemIds.has(item.id));
if (newItemsToVectorize.length > 0) {
console.debug(
`%c[Indexer] Sending ${newItemsToVectorize.length} new items to worker for vectorization (${allItemsInPrimaryStores.length - newItemsToVectorize.length} already vectorized)`,
"color: #4ea1ff",
);
dispatchProgress(completedJobs, totalSteps, true, "Starting vectorization of new items");
try {
const workerManager = VectorWorkerManager.getInstance();
await workerManager.processItems(newItemsToVectorize, (progress) => {
let detailMessage = progress.message || "";
if (
progress.status === "processing" &&
progress.total &&
progress.processed !== undefined
) {
detailMessage = `Vectorizing: ${progress.processed} / ${progress.total}`;
} else if (progress.status === "complete") {
detailMessage = "Vectorization complete";
completedJobs++;
dispatchProgress(
completedJobs,
totalSteps,
false,
"Indexing finished",
detailMessage
);
} else if (progress.status === "error") {
detailMessage = `Vectorization error: ${progress.message}`;
dispatchProgress(
completedJobs,
totalSteps,
false,
"Vectorization failed",
detailMessage,
);
} else if (progress.status === "started") {
detailMessage = `Vectorization started for ${progress.total} items`;
} else if (progress.status === "cancelled") {
detailMessage = `Vectorization cancelled: ${progress.message}`;
dispatchProgress(
completedJobs,
totalSteps,
false,
"Vectorization cancelled",
detailMessage,
);
}
if (progress.status !== "complete" && progress.status !== "error" && progress.status !== "cancelled") {
dispatchProgress(
completedJobs,
totalSteps,
true,
"Vectorization in progress",
detailMessage,
);
}
});
console.debug(
"%c[Indexer] Vectorization task for stored items sent to worker.",
"color: green",
);
} catch (error) {
console.error(
`%c[Indexer] ❌ Failed to send items to vector worker:`,
"color: red",
error,
);
dispatchProgress(
completedJobs,
totalSteps,
false,
"Vectorization failed",
String(error),
);
}
} else {
console.debug(
`%c[Indexer] All ${allItemsInPrimaryStores.length} items are already vectorized, skipping worker initialization.`,
"color: gray",
);
completedJobs++;
dispatchProgress(
completedJobs,
totalSteps,
false,
"Indexing finished (all items already vectorized)",
);
}
} else {
console.debug(
"%c[Indexer] No items found in primary stores to send for vectorization.",
"color: gray",
);
completedJobs++;
dispatchProgress(
completedJobs,
totalSteps,
false,
"Indexing finished (no items for vectorization)",
);
}
stopHeartbeat();
allItemsInPrimaryStores = await loadAllStoredItems();
allItemsInPrimaryStores.forEach(item => {
const jobDef = jobs[item.category] || Object.values(jobs).find(j => j.id === item.category) || jobs[item.renderComponentId];
if (jobDef) {
const renderComponent = renderComponentMap[jobDef.renderComponentId];
if (renderComponent) {
item.renderComponent = renderComponent;
}
} else if (renderComponentMap[item.renderComponentId]) {
item.renderComponent = renderComponentMap[item.renderComponentId];
}
});
loadDynamicItems(allItemsInPrimaryStores);
window.dispatchEvent(new Event("dynamic-items-updated"));
}
function mergeItems(existing: IndexItem[], incoming: IndexItem[]): IndexItem[] {
const map = new Map<string, IndexItem>();
for (const item of existing) {
if (item && item.id) map.set(item.id, item);
}
for (const item of incoming) {
if (item && item.id) map.set(item.id, item);
}
return Array.from(map.values());
}
@@ -0,0 +1,12 @@
import type { Job } from "./types";
import { messagesJob } from "./jobs/messages";
import { notificationsJob } from "./jobs/notifications";
import { forumsJob } from "./jobs/forums";
import { subjectsJob } from "./jobs/subjects";
export const jobs: Record<string, Job> = {
messages: messagesJob,
notifications: notificationsJob,
forums: forumsJob,
subjects: subjectsJob,
};
@@ -0,0 +1,69 @@
import type { Job, IndexItem } from "../types";
const fetchForums = async () => {
const res = await fetch(`${location.origin}/seqta/student/load/forums`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ mode: "list" }),
});
return res.json() as Promise<{
payload: { forums: any[] };
status: string;
}>;
};
export const forumsJob: Job = {
id: "forums",
label: "Forums",
renderComponentId: "forum",
frequency: { type: "expiry", afterMs: 30 * 24 * 60 * 60 * 1000 }, // 30 days
run: async (ctx) => {
const existingIds = new Set(
(await ctx.getStoredItems("forums")).map((i) => i.id),
);
let list;
try {
list = await fetchForums();
} catch (e) {
console.error("[Forums job] list fetch failed:", e);
return [];
}
if (list.status !== "200") return [];
const items: IndexItem[] = [];
for (const forum of list.payload.forums) {
const id = forum.id.toString();
if (existingIds.has(id)) continue;
items.push({
id,
text: forum.title,
category: "forums",
content: `${forum.title}`,
dateAdded: Date.now(),
metadata: {
forumId: forum.id,
owner: forum.owner,
title: forum.title,
closed: forum.closed,
},
actionId: "forum",
renderComponentId: "forum",
});
}
return items;
},
/** Keep only forums from the lastyear. */
purge: (items) => {
const oneYearAgo = Date.now() - 365 * 24 * 60 * 60 * 1000;
return items.filter((i) => i.dateAdded >= oneYearAgo);
},
};

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