Compare commits

...

58 Commits

Author SHA1 Message Date
AdenMGB 690792fd62 chore: update chaneglog 2026-04-17 15:59:04 +09:30
AdenMGB f6ac112329 fix: fix the timetable edit plugin 2026-04-17 15:55:32 +09:30
AdenMGB ec68cec0ca feat: add smooth animation to notifications opening like settings 2026-04-17 15:51:25 +09:30
AdenMGB 44a029057a feat: auto download settings upon login 2026-04-13 19:42:11 +09:30
AdenMGB 249d1c1b4a feat: auto sync popoup 2026-04-13 19:39:57 +09:30
AdenMGB 6748b15024 feat: tweak engage popup 2026-04-13 19:33:28 +09:30
Aden Lindsay 8b26d07865 Merge pull request #426 from StroepWafel/main
update video (and edit notes)
2026-04-13 19:12:35 +09:30
Aden Lindsay 55d96a5e8f Update theme name in WhatsNewPopup 2026-04-13 19:12:18 +09:30
StroepWafel 133a5197aa Update update-video.webm 2026-04-13 19:10:32 +09:30
StroepWafel c0fa1576f3 Update update-video.webm 2026-04-12 23:28:30 +09:30
AdenMGB 48fbcde6ae fix: fix last two engage bugs 2026-04-12 20:14:52 +09:30
AdenMGB 89f50f774f feat: image full screen overlay for popoups 2026-04-12 20:06:03 +09:30
AdenMGB 1d9b8f3747 feat: queue popups and new engage popup 2026-04-12 19:54:43 +09:30
StroepWafel 0a5359df72 update video (and edit notes) 2026-04-12 19:19:19 +09:30
AdenMGB 2e9a643a8c fix: fix engage not always applying 2026-04-10 21:21:19 +09:30
AdenMGB 39d0b60024 feat: engage homepag 2026-04-08 11:27:38 +09:30
AdenMGB c7a6bf051c chore: bump ver and changelog 2026-04-08 08:49:37 +09:30
AdenMGB ea4a2c1ff0 feat: auto sync for cloud and fix some firefox weirdness 2026-04-08 08:29:25 +09:30
AdenMGB 71b7c9eb64 tweak: tweak cloud UI a lil bit 2026-04-08 07:40:20 +09:30
Aden Lindsay 0cac3022f5 Merge pull request #425 from StroepWafel/cloud-settings-backup
Cloud settings backup
2026-04-08 07:25:09 +09:30
StroepWafel 8b16a21d48 tweaks and fixes to UI 2026-04-07 22:39:09 +09:30
Aden Lindsay 2085ebe189 Upgrade project to Vite 8 + some cleanup (#424)
* Upgrade project to Vite 8

* Fix vite
2026-04-07 21:29:20 +09:30
AdenMGB 01c657d247 Merge branch 'main' of https://github.com/BetterSEQTA/BetterSEQTA-Plus 2026-04-07 21:25:15 +09:30
AdenMGB 1f26fb26d7 fix: somehwat fix overlap with vanilla seqta averages 2026-04-07 21:25:07 +09:30
Jones8683 dbd8e2be8e Merge branch 'main' of https://github.com/Jones8683/BetterSEQTA-Plus 2026-04-07 21:22:47 +09:30
Jones8683 6b5f00add0 Fix vite 2026-04-07 21:22:45 +09:30
Jones Jankovic 1f5eef2fb1 Merge branch 'BetterSEQTA:main' into main 2026-04-07 21:21:42 +09:30
StroepWafel 1e6e57ddcd feat: bs cloud header in settings, cloud pfp as local pfp option & doc updates
updates to docs and also profile
2026-04-07 21:15:29 +09:30
Aden Lindsay e18853ba3a Merge pull request #422 from StroepWafel/patch-2
Update README.md
2026-04-07 21:10:43 +09:30
Aden Lindsay 140cd66c9b chore: update old and non existant links 2026-04-07 21:09:55 +09:30
Jones8683 5684857456 Upgrade project to Vite 8 2026-04-07 20:13:47 +09:30
StroepWafel 24fee7a743 updates to docs and also profile 2026-04-07 20:02:30 +09:30
StroepWafel cef99b7278 Update README.md 2026-04-07 19:55:06 +09:30
StroepWafel aad5bcd97e Update README.md 2026-04-07 19:50:24 +09:30
StroepWafel 423aaa6b84 Update README.md 2026-04-07 19:41:45 +09:30
AdenMGB 97a1226eaf feat(engage): add icon-only sidebar support and Engage-specific titlebar styles
Made-with: Cursor
2026-04-07 15:11:26 +09:30
AdenMGB 72cab5905e fix(engage): disable assessments overview plugin on Engage
Made-with: Cursor
2026-04-07 15:11:15 +09:30
AdenMGB 088b745600 feat(engage): inject settings button, dark/light toggle, and user info with logout
Made-with: Cursor
2026-04-07 15:11:07 +09:30
AdenMGB 8801d5a435 feat(engage): fix loading screen hang and handle login/logout flow
Made-with: Cursor
2026-04-07 15:10:58 +09:30
Aden Lindsay b63a3d95f3 fix today's lessons tweaking out (#420) 2026-04-07 09:14:08 +09:30
StroepWafel eca8327420 fix today's lessons tweaking out 2026-04-07 09:05:13 +09:30
AdenMGB 05cf380e86 feat: some messed up stuff to fix some stuff 2026-04-07 08:46:28 +09:30
StroepWafel 73f005d645 Better theme displaying (#419)
* add more data

add more info for users about themes and also make related themes actually show related themes

* sorting and similar
2026-04-07 08:22:56 +09:30
StroepWafel f2fa9c39a9 Merge pull request #418 from StroepWafel/theme-updates
Theme updates
2026-04-07 08:13:27 +09:30
Aden Lindsay 783ff65fb5 Merge pull request #417 from StroepWafel/updated-sign-in
feat: Unified portaled sign-in overlay
2026-04-06 17:22:10 +09:30
Aden Lindsay 3e7ea3bc03 Adaptive custom themes (#416)
* feat: Smooth change in colour, no hard cut

Added option smoothing on colour change so there is no hard cut made when switching subjects

* feat: Themes can adapt to colour

* quick fix to forced mode as well

* [CodeFactor] Apply fixes

---------

Co-authored-by: codefactor-io <support@codefactor.io>
2026-04-06 17:21:38 +09:30
StroepWafel 398029eecd Merge branch 'main' into adaptive-custom-themes 2026-04-06 15:01:38 +09:30
StroepWafel a55cb84a69 feat: Smooth change in colour, no hard cut (#415)
Added option smoothing on colour change so there is no hard cut made when switching subjects
2026-04-06 14:58:09 +09:30
StroepWafel 94d54f65bf feat: Unified portaled sign-in overlay
Updated the sign-in overlay to be unified across the site, improving UX
2026-04-06 14:48:03 +09:30
codefactor-io 8123c5dd33 [CodeFactor] Apply fixes 2026-04-06 04:55:44 +00:00
StroepWafel ac1ee702ae quick fix to forced mode as well 2026-04-06 14:24:08 +09:30
StroepWafel e657152e3f feat: Themes can adapt to colour 2026-04-06 14:11:19 +09:30
StroepWafel f667ff9e9b feat: Smooth change in colour, no hard cut
Added option smoothing on colour change so there is no hard cut made when switching subjects
2026-04-06 13:39:25 +09:30
AdenMGB 3c613f4938 chore: bump ver 2026-04-03 10:55:41 +10:30
AdenMGB 04843a90fe fix: fix adaptive themeing to support current year subjects 2026-04-03 10:47:56 +10:30
AdenMGB 834d585ac7 chore: release ntoe 2026-03-29 20:26:19 +10:30
AdenMGB 343fa7ca9f feat: migrate pdfjs to local & bump ver 2026-03-29 20:25:06 +10:30
AdenMGB e049f34a5e feat: WIP Engage progress 2026-03-28 09:06:54 +10:30
80 changed files with 4346 additions and 504 deletions
+4
View File
@@ -6,6 +6,10 @@ pnpm-lock.yaml
yarn.lock
bun.lock
# PDF.js extension assets (copied by postinstall from pdfjs-dist)
src/public/resources/pdfjs/pdf.worker.min.mjs
src/public/resources/pdfjs/pdf.legacy.min.mjs
# Build
extension.zip
build/
+5 -5
View File
@@ -6,9 +6,9 @@ Hey there! 👋 Thanks for your interest in contributing to BetterSEQTA+! We're
**Never contributed to an open source project before?** No worries! We've made it super easy to get started:
- **📖 Read our [Getting Started Guide](./docs/GETTING_STARTED_CONTRIBUTING.md)** - This walks you through everything step-by-step, from setting up your development environment to making your first pull request.
- **🏗️ Understand the codebase** with our [Architecture Guide](./docs/ARCHITECTURE.md)
- **🔧 Having issues?** Check our [Troubleshooting Guide](./docs/TROUBLESHOOTING.md)
- **📖 Read our [contributing guide](https://docs.betterseqta.org/contributing/)** - This walks you through everything step-by-step, from setting up your development environment to making your first pull request.
- **🏗️ Understand the codebase** with the [architecture guide](https://docs.betterseqta.org/architecture/)
- **🔧 Having issues?** Check the [troubleshooting guide](https://docs.betterseqta.org/troubleshooting/)
We have lots of [`good first issue`](https://github.com/BetterSEQTA/BetterSEQTA-plus/labels/good%20first%20issue) labels that are perfect for beginners!
@@ -33,8 +33,8 @@ Join our community channels to discuss the project, get help, and connect with o
If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides:
- [Creating Your First Plugin](./docs/plugins/creating-plugins.md)
- [Plugin API Reference](./docs/advanced/plugin-api.md)
- [Plugin development](https://docs.betterseqta.org/plugin-development/)
- [Plugin API](https://docs.betterseqta.org/plugin-api/)
## Pull Request Process
+25 -56
View File
@@ -16,17 +16,15 @@
<img src="https://img.shields.io/chrome-web-store/rating/afdgaoaclhkhemfkkkonemoapeinchel" />
</div>
## Table of contents
## 📚 Documentation
All documentation has been moved to the [official docs site](https://docs.betterseqta.org):
- [Features](#features)
- [Creating Custom Themes](#creating-custom-themes)
- [Getting Started](#getting-started)
- [Running Development](#running-development)
- [Building for production](#building-for-production)
- [Folder Structure](#folder-structure)
- [Contributors](#contributors)
- [Credits](#credits)
- [Star History](#star-history)
Includes:
- Getting started
- Development setup
- Architecture
- Plugin system
- Theme creation
## Features
@@ -50,64 +48,32 @@
## Creating Custom Themes
If you are looking to create custom themes, I would recommend you start at the official documentation [here](https://betterseqta.gitbook.io/betterseqta-docs). You can see some premade examples along with a compilation script that can be used to allow for CSS frameworks and libraries such as SCSS to be used [here](https://github.com/BetterSEQTA/BetterSEQTA-Theme-Generator).
If you are looking to create custom themes, I would recommend you start at the official documentation [here](https://docs.betterseqta.org/theme-creation/). You can see some premade examples along with a compilation script that can be used to allow for CSS frameworks and libraries such as SCSS to be used [here](https://github.com/BetterSEQTA/BetterSEQTA-Theme-Generator).
Don't worry- if you get stuck feel free to ask around in the [discord](https://discord.gg/YzmbnCDkat). We're open and happy to help out! Happy creating :)
## 🚀 Want to Contribute?
## 🚀 Contributing
**New contributors welcome!**
- 📖 Start here: https://docs.betterseqta.org/install/
- 🧠 Architecture: https://docs.betterseqta.org/architecture/
- 🧩 Plugins: https://docs.betterseqta.org/plugins/
- 💬 Discord: https://discord.gg/YzmbnCDkat
**New contributors welcome!** 🎉 We've made it easy to get started:
- **👋 New to the project?** Start with our [Getting Started Guide](./docs/GETTING_STARTED_CONTRIBUTING.md)
- **🏗️ Want to understand the code?** Check out our [Architecture Guide](./docs/ARCHITECTURE.md)
- **🧩 Interested in plugins?** Read our [Plugin Development Guide](./docs/plugins/README.md)
- **🐛 Found a bug?** Open an [issue](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues) or fix it yourself!
- **💬 Need help?** Join our [Discord community](https://discord.gg/YzmbnCDkat)
## ⚡ Quick Start
We have lots of https://github.com/BetterSEQTA/BetterSEQTA-Plus/labels/good%20first%20issue labels perfect for beginners!
## Quick Development Setup
&nbsp;&nbsp;&nbsp; **1. Fork & Clone**
```bash
git clone https://github.com/YOUR_USERNAME/BetterSEQTA-Plus
git clone https://github.com/YOUR_USERNAME_FORKED_WITH/BetterSEQTA-Plus
cd BetterSEQTA-Plus
```
&nbsp;&nbsp;&nbsp; **2. Install & Run**
```bash
npm install --legacy-peer-deps
npm run dev
```
````
&nbsp;&nbsp;&nbsp; **3. Load in Browser**
1. Go to `chrome://extensions`
2. Enable "Developer mode"
3. Click "Load unpacked" → Select `dist` folder
4. Visit a SEQTA page to see it work! 🎉
> [!WARNING]
> Whenever you update the extension while not in dev mode, you will need to use the reload button on the extension page.
Then load `dist` in `chrome://extensions` (Developer Mode → Load unpacked).
📚 **Need more details?** Check our [detailed setup guide](./docs/GETTING_STARTED_CONTRIBUTING.md#your-first-30-minutes)
### Building for Production
```bash
npm run build # Build for all browsers
npm run zip # Package for distribution (requires 7-Zip)
```
## Folder Structure
The folder structure is as follows:
- The `src` folder contains source files that are compiled to the build directory.
- The `src/plugins` folder contains vital loaders required for BetterSEQTA+ functionality.
- The `src/interface` folder contains source React & Svelte files that are required for the Settings page.
- The `dist` folder is where the compiled code ends up, this is the folder what you need to load into chrome as an unpacked extension for development.
Full setup guide:
[https://betterseqta.github.io/BetterSEQTA-Docs/install/#for-developers-development-environment](https://betterseqta.github.io/BetterSEQTA-Docs/install/#for-developers-development-environment)
## Contributors
@@ -115,11 +81,14 @@ The folder structure is as follows:
<img src="https://contrib.rocks/image?repo=betterseqta/betterseqta-plus" />
</a>
Want to contribute? [Click Here!](https://github.com/BetterSEQTA/BetterSEQTA-Plus/blob/main/CONTRIBUTING.md)
Want to contribute? [Click Here!](https://docs.betterseqta.org/contributing/)
## Credits
This extension was initially developed by [Nulkem](https://github.com/Nulkem/betterseqta), was ported to manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) and is currently under active development from lead developers [SethBurkart123](https://github.com/SethBurkart123) and [Crazypersonalph](https://github.com/Crazypersonalph) with help from other volunteers.
Originally developed by [Nulkem](https://github.com/Nulkem/betterseqta)
Ported to Manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68)
Maintained by [SethBurkart123](https://github.com/SethBurkart123), [Crazypersonalph](https://github.com/Crazypersonalph) & the rest of the BetterSEQTA team!
## Star History
+3 -1
View File
@@ -1,5 +1,7 @@
# BetterSEQTA+ Architecture
**Published version:** [docs.betterseqta.org/architecture/](https://docs.betterseqta.org/architecture/)
Hey there! 👋 New to the codebase and feeling a bit lost? Don't worry - this guide will help you understand how everything fits together!
## Table of Contents
@@ -221,7 +223,7 @@ console.log(settingsState.[the setting name])
Ready to contribute? Here's what to do next:
1. **Read the code**: Start with `src/SEQTA.ts` and follow the flow
2. **Try creating a simple plugin**: Follow our [plugin guide](./plugins/README.md)
2. **Try creating a simple plugin**: Follow the [plugin documentation](https://docs.betterseqta.org/plugins/)
3. **Look at existing issues**: Check our [GitHub issues](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues) for "good first issue" labels
4. **Join our Discord**: Get help from the community!
+133
View File
@@ -0,0 +1,133 @@
# BetterSEQTA Cloud — settings sync (server specification)
This document describes the HTTP API the BetterSEQTA+ extension expects for **cloud backup of extension settings**. The client is implemented in the extension repo; the accounts service (`accounts.betterseqta.org`) must implement these endpoints.
## Purpose
- Store **one JSON document per authenticated BetterSEQTA Cloud user** representing a snapshot of the extensions `chrome.storage.local` data (theme, layout, plugin settings, `plugin.*` keys, etc.).
- The extension **does not upload OAuth tokens** (`bsplus_token`, `bsplus_refresh_token`, `bsplus_client_id`, `bsplus_user`). Those remain only on the client.
- **Download** replaces local storage with the stored snapshot, then the client reapplies the current devices session tokens so the user stays signed in.
## Base URL
All routes below are relative to:
`https://accounts.betterseqta.org`
## Authentication
Every request must include:
```http
Authorization: Bearer <access_token>
```
Use the same **access tokens** issued by the existing BetterSEQTA+ OAuth flows (`/api/bsplus/login`, `/api/bsplus/refresh`). Resolve the user from the token; the document is scoped to that user.
## Endpoints
### `PUT /api/bsplus/settings/sync`
Upserts the callers settings backup.
**Request body (JSON):**
```json
{
"schemaVersion": 1,
"data": {
"...": "flat key-value map mirroring extension storage (see Payload shape)"
}
}
```
- **`schemaVersion`**: integer. The extension currently sends `1`. The server may reject unknown major versions or store it for future migrations.
- **`data`**: object whose keys are storage keys (strings) and values are JSON-serializable values (same types as stored in `chrome.storage.local`).
**Success response:** HTTP `200` (or `201` if you prefer create semantics). Example:
```json
{
"updated_at": "2026-04-07T12:00:00.000Z"
}
```
`updated_at` should be an ISO 8601 timestamp of the save time. The extension displays success without requiring extra fields.
**Error responses:** Standard JSON error body if applicable, e.g. `{ "error": "message" }`, with appropriate HTTP status (`401`, `413`, `422`, etc.).
---
### `GET /api/bsplus/settings/sync`
Returns the callers latest settings backup.
**Success response:** HTTP `200` with body:
```json
{
"schemaVersion": 1,
"data": { },
"updated_at": "2026-04-07T12:00:00.000Z"
}
```
- **`data`**: required for restore; must be the same shape as accepted in `PUT` (flat map of storage keys).
- **`schemaVersion`**: optional but recommended; should match what was stored.
- **`updated_at`**: optional; included for UX if the client shows “last backup” time.
**No backup yet:** HTTP **`404`**. The extension treats this as “nothing in the cloud” and shows an error to the user.
**Error responses:** `401` if the token is invalid, etc.
---
## Suggested database shape
Example relational layout:
| Column | Type | Notes |
|---------------|-------------|--------|
| `user_id` | FK → users | Unique per backup row (one row per user). |
| `payload` | JSON / JSONB| Store `{ "schemaVersion", "data" }` or only `data` + separate `schema_version` column. |
| `updated_at` | timestamptz | Set on each successful `PUT`. |
Unique constraint on `user_id`.
## Semantics
- **Last write wins:** each `PUT` replaces the stored backup for that user.
- **Optional later:** `If-Unmodified-Since` or a `revision` field for conflict detection (not required for v1).
## Security and privacy
- **Encryption at rest** for `payload` is recommended.
- Payload may contain **school-related UI preferences** and plugin data; treat as **user data** under your privacy policy.
- **Do not require or store** refresh/access tokens in the payload; the extension already strips them on upload.
### Never included in the sync payload (`chrome.storage.local` only)
The backup is a flat JSON map of **`chrome.storage.local`** keys. It does **not** include:
- **IndexedDB** — e.g. the Global Search index (`betterseqta-index` and related DBs) lives outside extension storage and is never serialized here.
- **OAuth / session keys** — `bsplus_token`, `bsplus_refresh_token`, `bsplus_client_id`, `bsplus_user`, plus legacy `cloudAccessToken` / `cloudUsername`.
- **Assessment Averages caches** — `plugin.assessments-average.storage.assessments`, `plugin.assessments-average.storage.weightings` (school assessment data).
- **Keys under** `plugin.global-search.storage.*` — reserved so any future plugin storage cache there is not synced.
- **`bsplus_cloud_settings_known_remote_updated_at`** — client-only watermark for auto-sync (not part of the cloud backup blob).
On restore, those keys are **not** taken from the server; the device keeps its current local values.
## Related endpoint: `GET /api/user/cloud-summary`
The extension may call **`GET /api/user/cloud-summary`** (same host, `Authorization: Bearer`) for a **small** JSON summary (e.g. whether DesQTA / BetterSEQTA+ cloud settings exist and **`bsplus.updated_at`** / **`schemaVersion`**). It does **not** return the large settings `data` blob.
- **Auto-sync flow:** compare `bsplus.updated_at` to a **client-only** watermark stored in extension storage as **`bsplus_cloud_settings_known_remote_updated_at`** (never uploaded, never applied from the server payload; preserved on restore).
- If the server timestamp is newer (and `schemaVersion` is not ahead of the client), the client then calls **`GET /api/bsplus/settings/sync`** and applies the full envelope as usual.
This uses standard **WebExtension** APIs (`browser.alarms`, `runtime` messages, `storage`) and works on **Chromium and Firefox** builds (see `webextension-polyfill`).
## Client reference (extension)
- Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, and **`bsplus_cloud_settings_known_remote_updated_at`**).
- Download: `applyDownloadedEnvelope` after `GET`; local auth keys, sensitive device keys, and the client-only watermark key are merged back after `chrome.storage.local.clear()`.
- Auto sync (summary, debounced upload, alarms): `src/background/cloudSettingsAutoSync.ts`; content script triggers a poll on each verified SEQTA Learn/Engage page load (top frame) via `cloudSettingsPoll`.
+3 -1
View File
@@ -1,5 +1,7 @@
# Getting Started as a Contributor
**Published version:** [docs.betterseqta.org/contributing/](https://docs.betterseqta.org/contributing/)
Welcome to BetterSEQTA+! 🎉 This guide will walk you through making your first contribution, even if you're completely new to the project.
## Table of Contents
@@ -222,7 +224,7 @@ git push origin your-branch-name
### Stuck? Here's How to Get Unstuck
1. **Check the docs** - [Architecture guide](./ARCHITECTURE.md) explains everything
1. **Check the docs** - The [architecture guide](https://docs.betterseqta.org/architecture/) explains everything
2. **Search existing issues** - Someone might have had the same problem
3. **Ask in Discord** - Our community is super helpful
4. **Create an issue** - If you found a bug or need help
+21 -21
View File
@@ -1,30 +1,36 @@
# BetterSEQTA+ Documentation
🚧 DOCS UNDER CONSTRUCTION! 🚧
Welcome to the BetterSEQTA+ documentation! This documentation will help you understand how BetterSEQTA+ works and how to extend it with plugins and new features.
**Canonical documentation lives at [docs.betterseqta.org](https://docs.betterseqta.org/).** The pages below are the same topics; use the site for search, navigation, and the latest updates.
## Table of Contents
### Getting Started
- [Project Overview](./README.md) - This file
- [Installation Guide](./installation.md) - How to install and set up BetterSEQTA+
- [Getting Started Contributing](./GETTING_STARTED_CONTRIBUTING.md) - **Start here!** Complete beginner-friendly guide
- [Architecture Guide](./ARCHITECTURE.md) - How BetterSEQTA+ works under the hood
- [Contributing Guide](../CONTRIBUTING.md) - Official contribution guidelines
- [Troubleshooting](./TROUBLESHOOTING.md) - Common issues and solutions
- [Documentation home](https://docs.betterseqta.org/)
- [Installation](https://docs.betterseqta.org/install/)
- [Contributing](https://docs.betterseqta.org/contributing/)
- [Architecture](https://docs.betterseqta.org/architecture/)
- [Contribution guidelines (repository)](../CONTRIBUTING.md)
- [Troubleshooting](https://docs.betterseqta.org/troubleshooting/)
### Plugin System
### Features & customization
- [Creating Your First Plugin](./plugins/README.md) - A comprehensive, beginner-friendly guide to creating plugins
- [Plugin API Reference](./plugins/api-reference.md) - Detailed technical documentation of the plugin APIs
- [Features](https://docs.betterseqta.org/features/)
- [Themes & customization](https://docs.betterseqta.org/customization/)
- [Theme creation](https://docs.betterseqta.org/theme-creation/)
### Plugin system
- [Plugins overview](https://docs.betterseqta.org/plugins/)
- [Plugin development](https://docs.betterseqta.org/plugin-development/)
- [Plugin API](https://docs.betterseqta.org/plugin-api/)
- [Example plugin](https://docs.betterseqta.org/example-plugin/)
## Core Concepts
BetterSEQTA+ is built around several core concepts:
1. **Plugin System**: BetterSEQTA+ uses a plugin system to extend SEQTA with new features. Plugins are self-contained pieces of code that can be enabled or disabled by the user. Check out our [plugin guide](./plugins/README.md) to learn how to create your own!
1. **Plugin System**: BetterSEQTA+ uses a plugin system to extend SEQTA with new features. Plugins are self-contained pieces of code that can be enabled or disabled by the user. See the [plugins documentation](https://docs.betterseqta.org/plugins/).
2. **Type-Safe Settings**: Each plugin can define settings that are type-safe and automatically rendered in the settings UI. The settings system uses TypeScript decorators to make it easy to define settings with proper typing.
@@ -36,19 +42,13 @@ BetterSEQTA+ is built around several core concepts:
If you need help with BetterSEQTA+, you can:
- [Open an Issue](https://github.com/SeqtaLearning/betterseqta-plus/issues) - Report bugs or request features
- [Open an Issue](https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues) - Report bugs or request features
- [Join the Discord](https://discord.gg/YzmbnCDkat) - Chat with the community
- [Email the Maintainers](mailto:betterseqta.plus@gmail.com) - Contact the maintainers directly
## Contributing to the Documentation
We welcome contributions to the documentation! If you find something unclear or missing, please open an issue or submit a pull request.
To contribute to the documentation:
1. Fork the repository
2. Make your changes to the documentation files
3. Submit a pull request with a clear description of your changes
We welcome contributions to the documentation. The published site is built from the documentation repository; see [Contributing](https://docs.betterseqta.org/contributing/) for how to help.
## License
+2
View File
@@ -1,5 +1,7 @@
# Theme Creation Guide
**Published version:** [docs.betterseqta.org/theme-creation/](https://docs.betterseqta.org/theme-creation/)
This guide covers everything you need to know about creating custom themes for BetterSEQTA+.
## Table of Contents
+2
View File
@@ -1,5 +1,7 @@
# Troubleshooting Guide
**Published version:** [docs.betterseqta.org/troubleshooting/](https://docs.betterseqta.org/troubleshooting/)
Having issues with BetterSEQTA+ development? This guide covers the most common problems and their solutions.
## Table of Contents
+5 -3
View File
@@ -1,5 +1,7 @@
# Contributing to BetterSEQTA+
**Published version:** [docs.betterseqta.org/contributing/](https://docs.betterseqta.org/contributing/)
Thank you for your interest in contributing to BetterSEQTA+! This document provides guidelines and instructions for contributing to the project.
## Table of Contents
@@ -57,7 +59,7 @@ Key points:
5. **Install in Chrome/Firefox**
Follow the [installation instructions](./installation.md#development-installation) to load the development version into your browser.
Follow the [installation instructions](https://docs.betterseqta.org/install/) to load the development version into your browser.
### Project Structure
@@ -246,8 +248,8 @@ Join our community channels to discuss the project, get help, and connect with o
If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides:
- [Creating Your First Plugin](./plugins/creating-plugins.md)
- [Plugin API Reference](./advanced/plugin-api.md)
- [Plugin development](https://docs.betterseqta.org/plugin-development/)
- [Plugin API](https://docs.betterseqta.org/plugin-api/)
## Recognition
+4 -2
View File
@@ -1,5 +1,7 @@
# Installing BetterSEQTA+
**Published version:** [docs.betterseqta.org/install/](https://docs.betterseqta.org/install/)
This guide will walk you through the process of installing and setting up BetterSEQTA+ for development or usage.
## Prerequisites
@@ -178,5 +180,5 @@ bun run dev
Now that you have BetterSEQTA+ installed, you can:
- [Getting Started with Plugins](./plugins/getting-started.md)
- [Contribute to the project](../CONTRIBUTING.md)
- [Plugins](https://docs.betterseqta.org/plugins/)
- [Contribute to the project](https://docs.betterseqta.org/contributing/) · [Repository CONTRIBUTING.md](../CONTRIBUTING.md)
+4 -2
View File
@@ -1,5 +1,7 @@
# Example Plugin Template
**Published version:** [docs.betterseqta.org/example-plugin/](https://docs.betterseqta.org/example-plugin/)
This is a complete, working example of a simple BetterSEQTA+ plugin. You can copy this code and modify it to create your own plugin!
## What This Example Does
@@ -328,8 +330,8 @@ Once you've got this working:
## Need Help?
- 💬 Ask in our [Discord server](https://discord.gg/YzmbnCDkat)
- 📚 Read our [Plugin Development Guide](./README.md)
- 🐛 Check the [Troubleshooting Guide](../TROUBLESHOOTING.md)
- 📚 Read the [plugin documentation](https://docs.betterseqta.org/plugins/)
- 🐛 Check the [troubleshooting guide](https://docs.betterseqta.org/troubleshooting/)
- 📝 Open an issue on GitHub
Happy coding! 🎉
+3 -1
View File
@@ -1,5 +1,7 @@
# Creating Plugins for BetterSEQTA+
**Published version:** [docs.betterseqta.org/plugins/](https://docs.betterseqta.org/plugins/) · [Plugin development](https://docs.betterseqta.org/plugin-development/) · [Plugin API](https://docs.betterseqta.org/plugin-api/)
Hey there! 👋 So you want to create a plugin for BetterSEQTA+? That's awesome! This guide will walk you through everything you need to know, from the very basics to more advanced features. Don't worry if you're new to this - we'll explain everything step by step.
## What is a Plugin?
@@ -294,4 +296,4 @@ Got stuck? No worries! Here's where you can get help:
- Check out the built-in plugins in the `src/plugins/built-in` folder
- Open an issue on our [GitHub page](https://github.com/betterseqta/betterseqta-plus/issues)
Happy coding and feel free to checkout the api reference [here](./api-reference.md)
Happy coding and feel free to check out the [plugin API](https://docs.betterseqta.org/plugin-api/) on the documentation site.
+3 -1
View File
@@ -1,6 +1,8 @@
# Plugin API Reference
This document provides detailed technical information about BetterSEQTA+'s plugin APIs. For a beginner-friendly introduction, see [Creating Your First Plugin](./README.md).
**Published version:** [docs.betterseqta.org/plugin-api/](https://docs.betterseqta.org/plugin-api/)
This document provides detailed technical information about BetterSEQTA+'s plugin APIs. For a beginner-friendly introduction, see the [plugins section](https://docs.betterseqta.org/plugins/) at [docs.betterseqta.org](https://docs.betterseqta.org/).
## Plugin Structure
+43
View File
@@ -0,0 +1,43 @@
import type { Plugin } from "vite";
/**
* Firefox extension pages forbid eval / `Function` constructor. Some deps still emit:
* - `Function(\`return this\`)()` (lodash-style global)
* - `try { return Function(\`\`) / new Function("") … }` (feature probes, e.g. PDF.js / ORT)
*/
export function firefoxStripFunctionProbe(): Plugin {
return {
name: "firefox-strip-function-probe",
apply: "build",
enforce: "post",
generateBundle(_options, bundle) {
if ((process.env.MODE || "chrome").toLowerCase() !== "firefox") return;
const literalReplacements: [string, string][] = [
['try{return new Function(""),!0}catch{return!1}', "return!1"],
["try{return new Function(''),!0}catch{return!1}", "return!1"],
['try{return new Function(""),true}catch{return false}', "return false"],
["try{return new Function(''),true}catch{return false}", "return false"],
// Empty template literal probe (minifier output)
["try{return Function(``),!0}catch{return!1}", "return!1"],
];
for (const chunk of Object.values(bundle)) {
if (chunk.type !== "chunk" || typeof chunk.code !== "string") continue;
let { code } = chunk;
code = code.replace(/Function\(`return this`\)\(\)/g, "(globalThis)");
code = code.replace(/Function\("return this"\)\(\)/g, "(globalThis)");
code = code.replace(/Function\('return this'\)\(\)/g, "(globalThis)");
for (const [from, to] of literalReplacements) {
if (code.includes(from)) {
code = code.split(from).join(to);
}
}
chunk.code = code;
}
},
};
}
+10 -9
View File
@@ -1,10 +1,11 @@
{
"name": "betterseqtaplus",
"version": "3.5.1",
"version": "3.6.0",
"type": "module",
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development add add heaps more features!",
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!",
"browserslist": "> 0.5%, last 2 versions, not dead",
"scripts": {
"postinstall": "node scripts/copy-pdfjs-assets.mjs",
"autoaudit": "npm audit && npm audit fix && npm run build",
"dev": "cross-env MODE=chrome vite dev",
"dev:firefox": "cross-env MODE=firefox vite build --watch",
@@ -31,14 +32,14 @@
"author": {
"name": "SethBurkart123",
"email": "betterseqta.plus@gmail.com",
"url": "https://github.com/BetterSEQTA/BetterSEQTA-plus"
"url": "https://github.com/BetterSEQTA/BetterSEQTA-Plus"
},
"license": "MIT",
"devDependencies": {
"@babel/plugin-transform-runtime": "^7.26.9",
"@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.0.95",
"@crxjs/vite-plugin": "^2.2.0",
"@bedframe/cli": "^0.1.2",
"@crxjs/vite-plugin": "^2.4.0",
"@types/mime-types": "^3.0.1",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
@@ -57,7 +58,7 @@
"url": "^0.11.4"
},
"dependencies": {
"@bedframe/core": "^0.0.46",
"@bedframe/core": "^0.1.0",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.0",
"@codemirror/lang-css": "^6.3.1",
@@ -65,7 +66,7 @@
"@codemirror/search": "^6.5.10",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.4",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/forms": "^0.5.10",
"@tsconfig/svelte": "^5.0.4",
"@types/chrome": "^0.1.4",
@@ -104,10 +105,10 @@
"react-dom": "17",
"rss-parser": "^3.13.0",
"sortablejs": "^1.15.6",
"svelte": "^5.22.6",
"svelte": "^5.46.4",
"typescript": "^5.8.2",
"uuid": "^11.1.0",
"vite": "^6.2.1",
"vite": "^8.0.5",
"webextension-polyfill": "^0.12.0"
}
}
+17
View File
@@ -0,0 +1,17 @@
import { copyFileSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const pdfjsRoot = join(root, "node_modules", "pdfjs-dist");
const outDir = join(root, "src", "public", "resources", "pdfjs");
mkdirSync(outDir, { recursive: true });
copyFileSync(
join(pdfjsRoot, "build", "pdf.worker.min.mjs"),
join(outDir, "pdf.worker.min.mjs"),
);
copyFileSync(
join(pdfjsRoot, "legacy", "build", "pdf.min.mjs"),
join(outDir, "pdf.legacy.min.mjs"),
);
+10 -1
View File
@@ -50,10 +50,19 @@ if (document.childNodes[1]) {
}
async function init() {
if (hasSEQTAText && document.title.includes("SEQTA Learn") && !IsSEQTAPage) {
if (
hasSEQTAText &&
(document.title.includes("SEQTA Learn") ||
document.title.includes("SEQTA Engage")) &&
!IsSEQTAPage
) {
IsSEQTAPage = true;
console.info("[BetterSEQTA+] Verified SEQTA Page");
if (typeof window !== "undefined" && window === window.top) {
void browser.runtime.sendMessage({ type: "cloudSettingsPoll" }).catch(() => {});
}
registerFetchSeqtaAppLinkListener();
const documentLoadStyle = document.createElement("style");
+71 -1
View File
@@ -1,12 +1,21 @@
import browser from "webextension-polyfill";
import type { SettingsState } from "@/types/storage";
import { fetchNews } from "./background/news";
import {
initCloudSettingsAutoSync,
performCloudSettingsDownloadWithRetry,
performCloudSettingsUploadWithRetry,
runCloudSettingsPoll,
} from "./background/cloudSettingsAutoSync";
function reloadSeqtaPages() {
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") ||
tab.title?.includes("SEQTA Engage")
) {
browser.tabs.reload(tab.id);
}
}
@@ -147,6 +156,57 @@ function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean
return true;
}
function handleCloudSettingsUpload(request: any, sendResponse: MessageSender): boolean {
void (async () => {
try {
const token = request.token as string | undefined;
if (!token) {
sendResponse({ success: false, error: "Not authenticated" });
return;
}
const res = await performCloudSettingsUploadWithRetry(token);
sendResponse({
success: res.success,
error: res.error,
updated_at: res.updated_at,
});
} catch (err) {
console.error("[Background] cloudSettingsUpload error:", err);
sendResponse({
success: false,
error: err instanceof Error ? err.message : "Upload failed",
});
}
})();
return true;
}
function handleCloudSettingsDownload(request: any, sendResponse: MessageSender): boolean {
void (async () => {
try {
const token = request.token as string | undefined;
if (!token) {
sendResponse({ success: false, error: "Not authenticated" });
return;
}
const res = await performCloudSettingsDownloadWithRetry(token);
sendResponse({
success: res.success,
notFound: res.notFound,
error: res.error,
updated_at: res.updated_at,
});
} catch (err) {
console.error("[Background] cloudSettingsDownload error:", err);
sendResponse({
success: false,
error: err instanceof Error ? err.message : "Download failed",
});
}
})();
return true;
}
function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean {
const { themeId, token, action } = request;
if (!themeId || !token) {
@@ -211,6 +271,12 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
cloudLogin: handleCloudLogin,
cloudRefresh: handleCloudRefresh,
cloudFavorite: handleCloudFavorite,
cloudSettingsUpload: handleCloudSettingsUpload,
cloudSettingsDownload: handleCloudSettingsDownload,
cloudSettingsPoll: () => {
void runCloudSettingsPoll();
return false;
},
getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => {
(async () => {
try {
@@ -328,6 +394,8 @@ function getDefaultValues(): SettingsState {
iconOnlySidebar: false,
adaptiveThemeColour: false,
adaptiveThemeGradient: false,
adaptiveThemeColourTransition: true,
autoCloudSettingsSync: true,
};
}
@@ -345,3 +413,5 @@ browser.runtime.onInstalled.addListener(function (event) {
browser.storage.local.set({ justupdated: true });
}
});
initCloudSettingsAutoSync({ reloadSeqtaPages });
+406
View File
@@ -0,0 +1,406 @@
import browser from "webextension-polyfill";
import {
applyDownloadedEnvelope,
buildUploadPayload,
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
isKeyIncludedInCloudUploadPayload,
setKnownRemoteUpdatedAt,
} from "@/seqta/utils/cloudSettingsSync";
const ACCOUNTS_BASE = "https://accounts.betterseqta.org";
export const CLOUD_SUMMARY_URL = `${ACCOUNTS_BASE}/api/user/cloud-summary`;
const CLOUD_SETTINGS_SYNC_URL = `${ACCOUNTS_BASE}/api/bsplus/settings/sync`;
const REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/refresh`;
const ALARM_NAME = "bsplus_cloud_settings_auto_sync";
const PERIOD_MINUTES = 60;
const UPLOAD_DEBOUNCE_MS = 2000;
type CloudSummaryResponse = {
desqta?: unknown;
bsplus?: { updated_at: string; schemaVersion: number } | null;
};
let reloadSeqtaPagesFn: (() => void) | null = null;
let suppressAutoUploadDuringRestore = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let pollInFlight: Promise<void> | null = null;
function isAutoCloudSyncEnabled(all: Record<string, unknown>): boolean {
return all.autoCloudSettingsSync !== false;
}
async function parseJsonResponse(r: Response): Promise<any> {
const text = await r.text();
try {
return text ? JSON.parse(text) : {};
} catch {
return {};
}
}
async function getAccessToken(): Promise<string | null> {
const { bsplus_token } = await browser.storage.local.get("bsplus_token");
return typeof bsplus_token === "string" && bsplus_token.length > 0 ? bsplus_token : null;
}
async function tryRefreshTokens(): Promise<boolean> {
const result = await browser.storage.local.get([
"bsplus_refresh_token",
"bsplus_client_id",
"bsplus_user",
]);
const refresh_token = result.bsplus_refresh_token as string | undefined;
const client_id = result.bsplus_client_id as string | undefined;
if (!refresh_token || !client_id) return false;
try {
const r = await fetch(REFRESH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token, client_id }),
});
const data = await parseJsonResponse(r);
if (!r.ok || !data.access_token || !data.refresh_token) return false;
await browser.storage.local.set({
bsplus_token: data.access_token,
bsplus_refresh_token: data.refresh_token,
bsplus_user: data.user ?? result.bsplus_user,
});
return true;
} catch {
return false;
}
}
function isServerTimestampNewer(serverIso: string, localIso: string | undefined): boolean {
const a = Date.parse(serverIso);
if (Number.isNaN(a)) return false;
if (localIso === undefined || localIso === "") return true;
const b = Date.parse(localIso);
if (Number.isNaN(b)) return true;
return a > b;
}
async function fetchCloudSummaryOnce(
token: string,
): Promise<
| { ok: true; data: CloudSummaryResponse }
| { ok: false; unauthorized: boolean; error?: string }
> {
try {
const r = await fetch(CLOUD_SUMMARY_URL, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
const data = (await parseJsonResponse(r)) as CloudSummaryResponse;
if (r.status === 401) return { ok: false, unauthorized: true };
if (!r.ok) {
return {
ok: false,
unauthorized: false,
error: (data as { error?: string })?.error ?? `Summary failed (${r.status})`,
};
}
return { ok: true, data };
} catch (e) {
return {
ok: false,
unauthorized: false,
error: e instanceof Error ? e.message : "Network error",
};
}
}
async function fetchCloudSummaryWithAuthRetry(
token: string,
): Promise<CloudSummaryResponse | null> {
let t = token;
for (let attempt = 0; attempt < 2; attempt++) {
const res = await fetchCloudSummaryOnce(t);
if (res.ok) return res.data;
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) break;
const next = await getAccessToken();
if (!next) break;
t = next;
continue;
}
if (res.error) console.warn("[BS+ cloud sync] cloud-summary:", res.error);
break;
}
return null;
}
type PutResult =
| { ok: true; updated_at?: string }
| { ok: false; unauthorized: boolean; error?: string };
async function putSettingsOnce(token: string): Promise<PutResult> {
try {
const all = await browser.storage.local.get();
const payload = buildUploadPayload(all as Record<string, unknown>);
const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const data = await parseJsonResponse(r);
if (r.status === 401) return { ok: false, unauthorized: true };
if (!r.ok) {
return {
ok: false,
unauthorized: false,
error: data?.error ?? `Upload failed (${r.status})`,
};
}
const updated_at = data?.updated_at as string | undefined;
await setKnownRemoteUpdatedAt(updated_at);
return { ok: true, updated_at };
} catch (e) {
return {
ok: false,
unauthorized: false,
error: e instanceof Error ? e.message : "Upload failed",
};
}
}
export async function performCloudSettingsUploadWithRetry(
token: string,
): Promise<{ success: boolean; error?: string; updated_at?: string }> {
let t = token;
for (let attempt = 0; attempt < 2; attempt++) {
const res = await putSettingsOnce(t);
if (res.ok) return { success: true, updated_at: res.updated_at };
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) return { success: false, error: "Not authenticated" };
const next = await getAccessToken();
if (!next) return { success: false, error: "Not authenticated" };
t = next;
continue;
}
return { success: false, error: res.error ?? "Upload failed" };
}
return { success: false, error: "Upload failed" };
}
type GetResult =
| { ok: true; updated_at?: string }
| { ok: false; notFound?: boolean; unauthorized: boolean; error?: string };
async function getSettingsAndApplyOnce(token: string): Promise<GetResult> {
try {
const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
const data = await parseJsonResponse(r);
if (r.status === 401) return { ok: false, unauthorized: true };
if (r.status === 404) {
return {
ok: false,
notFound: true,
unauthorized: false,
error: "No settings backup found in the cloud",
};
}
if (!r.ok) {
return {
ok: false,
unauthorized: false,
error: data?.error ?? `Download failed (${r.status})`,
};
}
await applyDownloadedEnvelope(data);
reloadSeqtaPagesFn?.();
const updated_at = data?.updated_at as string | undefined;
await setKnownRemoteUpdatedAt(updated_at);
return { ok: true, updated_at };
} catch (e) {
return {
ok: false,
unauthorized: false,
error: e instanceof Error ? e.message : "Download failed",
};
}
}
export async function performCloudSettingsDownloadWithRetry(
token: string,
): Promise<{ success: boolean; notFound?: boolean; error?: string; updated_at?: string }> {
suppressAutoUploadDuringRestore = true;
try {
let t = token;
for (let attempt = 0; attempt < 2; attempt++) {
const res = await getSettingsAndApplyOnce(t);
if (res.ok) return { success: true, updated_at: res.updated_at };
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) return { success: false, error: "Not authenticated" };
const next = await getAccessToken();
if (!next) return { success: false, error: "Not authenticated" };
t = next;
continue;
}
return {
success: false,
notFound: res.notFound,
error: res.error ?? "Download failed",
};
}
return { success: false, error: "Download failed" };
} finally {
suppressAutoUploadDuringRestore = false;
}
}
async function maybeUploadBaseline(token: string): Promise<void> {
const res = await performCloudSettingsUploadWithRetry(token);
if (!res.success) {
console.warn("[BS+ cloud sync] Baseline upload failed:", res.error);
}
}
async function downloadIfNeeded(token: string): Promise<void> {
const res = await performCloudSettingsDownloadWithRetry(token);
if (!res.success && !res.notFound) {
console.warn("[BS+ cloud sync] Auto-download failed:", res.error);
}
}
async function runCloudSettingsPollInner(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return;
let token = await getAccessToken();
if (!token) return;
const summary = await fetchCloudSummaryWithAuthRetry(token);
if (!summary) return;
const bsplus = summary.bsplus;
const watermark = all[BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY] as string | undefined;
if (
bsplus &&
typeof bsplus.schemaVersion === "number" &&
bsplus.schemaVersion > CLOUD_SETTINGS_SYNC_SCHEMA_VERSION
) {
console.warn(
"[BS+ cloud sync] Server schemaVersion newer than client; skip auto-download",
);
return;
}
token = (await getAccessToken()) ?? token;
if (!watermark) {
if (!bsplus?.updated_at) {
await maybeUploadBaseline(token);
return;
}
await downloadIfNeeded(token);
return;
}
if (!bsplus?.updated_at) return;
if (isServerTimestampNewer(bsplus.updated_at, watermark)) {
await downloadIfNeeded(token);
}
}
export function runCloudSettingsPoll(): Promise<void> {
if (pollInFlight) return pollInFlight;
pollInFlight = (async () => {
try {
await runCloudSettingsPollInner();
} catch (e) {
console.error("[BS+ cloud sync] Poll error:", e);
} finally {
pollInFlight = null;
}
})();
return pollInFlight;
}
function clearUploadDebounce(): void {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
}
function scheduleDebouncedUpload(): void {
if (suppressAutoUploadDuringRestore) return;
clearUploadDebounce();
debounceTimer = setTimeout(() => {
debounceTimer = null;
void runDebouncedUploadJob();
}, UPLOAD_DEBOUNCE_MS);
}
async function runDebouncedUploadJob(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return;
const token = await getAccessToken();
if (!token) return;
const res = await performCloudSettingsUploadWithRetry(token);
if (!res.success) {
console.warn("[BS+ cloud sync] Auto-upload failed:", res.error);
}
}
async function syncAlarmWithStorage(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) {
await browser.alarms.clear(ALARM_NAME);
clearUploadDebounce();
return;
}
await browser.alarms.create(ALARM_NAME, { periodInMinutes: PERIOD_MINUTES });
}
function onStorageChanged(
changes: Record<string, browser.storage.StorageChange>,
area: string,
): void {
if (area !== "local") return;
if (Object.prototype.hasOwnProperty.call(changes, "autoCloudSettingsSync")) {
void syncAlarmWithStorage();
}
const keys = Object.keys(changes);
if (!keys.some((k) => isKeyIncludedInCloudUploadPayload(k))) return;
void (async () => {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return;
if (suppressAutoUploadDuringRestore) return;
if (!(await getAccessToken())) return;
scheduleDebouncedUpload();
})();
}
function onAlarm(alarm: browser.Alarms.Alarm): void {
if (alarm.name !== ALARM_NAME) return;
void runCloudSettingsPoll();
}
export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void {
reloadSeqtaPagesFn = deps.reloadSeqtaPages;
browser.alarms.onAlarm.addListener(onAlarm);
browser.storage.onChanged.addListener(onStorageChanged);
void syncAlarmWithStorage();
}
+266
View File
@@ -555,6 +555,31 @@ body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) {
flex-shrink: 0 !important;
}
/* Engage: hide text nodes in labels via font-size trick, restore SVG size */
#menu .logo-link li > label,
#menu .logo-link section > label {
font-size: 0 !important;
justify-content: center !important;
svg {
width: 24px !important;
height: 24px !important;
font-size: initial !important;
flex-shrink: 0 !important;
margin: 0 auto !important;
}
}
/* Engage: hide chevron arrows on hasChildren items */
#menu .logo-link li > svg {
display: none !important;
}
/* Engage: hide the name/details in #userActions */
#menu #userActions .details {
display: none !important;
}
}
[class*="notifications__items___"] {
-ms-overflow-style: none !important;
@@ -1628,6 +1653,13 @@ html.transparencyEffects
box-shadow: 0px 10px 15px -3px rgba(0, 0, 0, 0.4);
}
/* Smoothed by attachNotificationsPanelAnimation (matches #ExtensionPopup spring) */
.bsplus-notifications-panel {
transform-origin: top right;
will-change: opacity, transform;
filter: drop-shadow(0px 0px 20px rgba(0, 0, 0, 0.35));
}
#menu li.active {
color: #ffffff !important;
background: rgba(0, 0, 0, 0.35);
@@ -3296,6 +3328,88 @@ div.day-empty {
position: absolute;
right: 250px;
}
.engage-titlebar {
right: 250px;
top: 0;
}
/* Engage parent home: same timetable DOM as Learn; title+student replace the lone h2 — give the cluster Learns h2 margin/inset. */
.timetable-container .home-subtitle > .engage-timetable-title-cluster {
align-items: center;
box-sizing: border-box;
display: flex;
flex: 1;
flex-wrap: wrap;
gap: 0.75rem 1rem;
margin: 20px;
min-width: 0;
}
.timetable-container .engage-timetable-title-cluster > h2 {
font-size: 20px;
font-weight: 400;
margin: 0 !important;
}
#engage-home-root.home-root {
box-sizing: border-box;
min-height: 100%;
}
.engage-child-select {
background: var(--background-primary);
border: 1px solid var(--border-primary, rgba(128, 128, 128, 0.35));
border-radius: 0.5rem;
color: var(--text-primary);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25;
max-width: 16rem;
min-width: 10rem;
padding: 0.35rem 0.6rem;
transition: border-color 0.2s ease-in-out, color 0.2s ease-in-out;
}
.engage-child-select:focus {
outline: none;
box-shadow: 0 0 0 2px var(--background-primary), 0 0 0 4px rgba(59, 130, 246, 0.45);
}
#engage-day-container:has(> .day-empty) {
align-content: center;
display: flex;
grid-auto-columns: unset;
grid-auto-flow: unset;
justify-content: center;
min-height: 12rem;
padding: 1.5rem;
}
#engage-day-container .day-empty {
text-align: center;
}
#engage-logouttooltip {
width: 50px !important;
margin-left: -28px !important;
top: 105% !important;
.engage-logout {
background: none !important;
border: none;
cursor: pointer;
color: var(--background-primary) !important;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
svg {
fill: var(--background-primary) !important;
}
}
}
.pagename {
align-items: center;
display: flex;
@@ -3417,6 +3531,26 @@ div.day-empty {
font-size: 1em;
color: var(--text-primary);
}
.whatsnewHeader.engageParentsAnnouncementHeader {
height: auto;
min-height: unset;
}
.whatsnewHeader.engageParentsAnnouncementHeader h1 {
line-height: 1.2;
}
.whatsnewHeader.engageParentsAnnouncementHeader .engageParentsSubheading {
margin-top: 0.35rem;
font-size: 1.05rem;
font-weight: 600;
opacity: 0.92;
}
.seqtaEngageAccent {
color: #ea580c;
font-weight: 700;
}
.dark .seqtaEngageAccent {
color: #fb923c;
}
.whatsnewBackground {
width: 100%;
height: 100%;
@@ -3545,6 +3679,138 @@ div.day-empty {
object-fit: cover;
margin-bottom: 12px;
}
.whatsnewTextContainer .engageParentsPromoWrap {
width: 100%;
margin-bottom: 12px;
border-radius: 16px;
overflow: hidden;
aspect-ratio: 16 / 9;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.28);
background: color-mix(in srgb, var(--background-secondary) 88%, var(--text-primary) 12%);
}
.whatsnewTextContainer .engageParentsPromoWrap .engageParentsPromoImg {
display: block;
width: 100%;
height: 100%;
margin: 0;
border-radius: 0;
aspect-ratio: unset;
object-fit: contain;
object-position: center;
}
.whatsnewHeader.bsCloudAutoSyncAnnouncementHeader {
height: auto;
min-height: unset;
}
.whatsnewHeader.bsCloudAutoSyncAnnouncementHeader h1 {
line-height: 1.2;
}
.bsCloudAccent {
color: #059669;
font-weight: 700;
}
.dark .bsCloudAccent {
color: #34d399;
}
.whatsnewTextContainer .bsCloudAutoSyncSignupCallout {
margin: 1.5rem 0 0;
padding: 1.25rem 1rem 0;
border-top: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
font-size: clamp(1.35rem, 3.8vw, 1.85rem);
font-weight: 800;
line-height: 1.35;
letter-spacing: -0.02em;
text-align: center;
color: var(--text-primary);
}
.popup-media-fullscreenable {
cursor: pointer;
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
}
.popup-media-fullscreenable:hover {
opacity: 0.95;
}
.popup-media-fullscreenable:focus {
outline: none;
}
.popup-media-fullscreenable:focus-visible {
outline: 2px solid color-mix(in srgb, var(--text-primary) 70%, transparent);
outline-offset: 4px;
}
.bsplus-popup-media-overlay-backdrop {
position: fixed;
inset: 0;
z-index: 2147483646;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(20px, 4vw, 48px);
box-sizing: border-box;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
opacity: 0;
transition: opacity 0.28s cubic-bezier(0.22, 0.03, 0.26, 1);
}
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay-backdrop--visible {
opacity: 1;
}
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay--instant {
transition: none;
}
.bsplus-popup-media-overlay-inner {
position: relative;
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
max-width: min(96vw, 1320px);
max-height: calc(100vh - clamp(40px, 10vw, 96px));
border-radius: 20px;
overflow: hidden;
background: var(--background-primary);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35);
opacity: 0;
transform: scale(0.94) translateY(12px);
transition:
opacity 0.28s cubic-bezier(0.22, 0.03, 0.26, 1),
transform 0.28s cubic-bezier(0.22, 0.03, 0.26, 1);
}
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay-backdrop--visible
.bsplus-popup-media-overlay-inner {
opacity: 1;
transform: scale(1) translateY(0);
}
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay--instant
.bsplus-popup-media-overlay-inner {
transition: none;
}
.bsplus-popup-media-overlay-slot {
width: 100%;
max-height: inherit;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(16px, 3vw, 28px);
box-sizing: border-box;
}
.bsplus-popup-media-overlay-media {
max-width: 100%;
max-height: calc(100vh - clamp(120px, 22vh, 200px));
width: auto;
height: auto;
object-fit: contain;
border-radius: 12px;
}
@keyframes shimmer {
0% {
+15 -2
View File
@@ -1,7 +1,20 @@
<script lang="ts">
let { onClick, text } = $props<{ onClick: () => void, text: string, [key: string]: any }>();
let {
onClick,
text,
disabled = false,
} = $props<{
onClick: () => void;
text: string;
disabled?: boolean;
}>();
</script>
<button onclick={onClick} class='px-5 py-1.5 text-[0.75rem] shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg'>
<button
type="button"
onclick={onClick}
disabled={disabled}
class="px-5 py-1.5 text-[0.75rem] shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg disabled:cursor-not-allowed disabled:opacity-50"
>
{text}
</button>
@@ -0,0 +1,166 @@
<script lang="ts">
import browser from "webextension-polyfill";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import DisclaimerModal from "./DisclaimerModal.svelte";
import Button from "./Button.svelte";
import Switch from "./Switch.svelte";
let cloudState = $state(cloudAuth.state);
let busy = $state(false);
let statusMessage = $state<string | null>(null);
let statusError = $state<string | null>(null);
let lastUploadAt = $state<string | null>(null);
let lastDownloadAt = $state<string | null>(null);
let showRestoreConfirm = $state(false);
$effect(() => {
const unsub = cloudAuth.subscribe((s) => {
cloudState = s;
});
return unsub;
});
function formatNow(): string {
return new Date().toLocaleString(undefined, {
dateStyle: "short",
timeStyle: "short",
});
}
async function upload() {
const token = await cloudAuth.getStoredToken();
if (!token) return;
busy = true;
statusError = null;
statusMessage = null;
try {
const res = (await browser.runtime.sendMessage({
type: "cloudSettingsUpload",
token,
})) as { success?: boolean; error?: string };
if (res?.success) {
statusMessage = "Settings saved to the cloud.";
lastUploadAt = formatNow();
} else {
statusError = res?.error ?? "Upload failed";
}
} catch (e) {
statusError = e instanceof Error ? e.message : "Upload failed";
} finally {
busy = false;
}
}
function promptDownload() {
showRestoreConfirm = true;
}
async function confirmDownload() {
showRestoreConfirm = false;
const token = await cloudAuth.getStoredToken();
if (!token) return;
busy = true;
statusError = null;
statusMessage = null;
try {
const res = (await browser.runtime.sendMessage({
type: "cloudSettingsDownload",
token,
})) as { success?: boolean; error?: string; notFound?: boolean };
if (res?.success) {
statusMessage = "Settings restored from the cloud. SEQTA tabs were reloaded.";
lastDownloadAt = formatNow();
} else {
statusError = res?.error ?? "Download failed";
}
} catch (e) {
statusError = e instanceof Error ? e.message : "Download failed";
} finally {
busy = false;
}
}
</script>
<div
class="w-full rounded-xl border border-zinc-200/60 bg-zinc-50/80 px-4 py-2.5 dark:border-zinc-700/50 dark:bg-zinc-900/40"
>
<h3 class="text-xs font-bold text-zinc-800 dark:text-zinc-100">Cloud settings backup</h3>
<p class="mt-0.5 text-[11px] leading-snug text-zinc-500 dark:text-zinc-400">
Upload copies this browsers BetterSEQTA+ settings to your account. Download replaces local settings with the
cloud copy (your sign-in stays on this device).
</p>
<div
class="mt-2 flex flex-col gap-2 rounded-lg border border-zinc-200/50 bg-white/60 px-3 py-2.5 dark:border-zinc-600/40 dark:bg-zinc-800/40"
>
<div class="flex items-start justify-between gap-3">
<p class="min-w-0 flex-1 pt-0.5 text-[11px] font-semibold leading-tight text-zinc-800 dark:text-zinc-100">
Automatic sync
</p>
<div class="shrink-0">
<Switch
state={$settingsState.autoCloudSettingsSync !== false}
onChange={(isOn: boolean) => (settingsState.autoCloudSettingsSync = isOn)}
/>
</div>
</div>
<p class="text-[10px] leading-snug text-zinc-500 dark:text-zinc-400">
When signed in, each time SEQTA loads and also hourly, if the cloud backup is newer it will replace local
settings. Settings you change will upload shortly after you adjust them.
</p>
<p class="text-[10px] leading-snug text-zinc-500 dark:text-zinc-400">
Passwords, tokens, and other sensitive data are not included in the backup.
<a
href="https://betterseqta.org/privacy"
target="_blank"
rel="noopener noreferrer"
class="ml-0.5 inline font-medium text-emerald-600 underline decoration-emerald-600/50 underline-offset-2 transition-all duration-200 hover:text-emerald-700 hover:decoration-emerald-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-emerald-400 dark:decoration-emerald-400/50 dark:hover:text-emerald-300 dark:focus-visible:ring-offset-zinc-800 rounded-sm"
>
Privacy policy
</a>
</p>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<Button
text={busy ? "Please wait…" : "Upload to cloud"}
onClick={upload}
disabled={busy || !cloudState.isLoggedIn}
/>
<Button
text={busy ? "Please wait…" : "Download from cloud"}
onClick={promptDownload}
disabled={busy || !cloudState.isLoggedIn}
/>
</div>
{#if !cloudState.isLoggedIn}
<p class="mt-2 text-[11px] text-zinc-500 dark:text-zinc-400">
Sign in from the BetterSEQTA Cloud header above to sync settings.
</p>
{/if}
{#if statusMessage}
<p class="mt-2 text-[11px] text-emerald-600 dark:text-emerald-400">{statusMessage}</p>
{/if}
{#if statusError}
<p class="mt-2 text-[11px] text-red-600 dark:text-red-400">{statusError}</p>
{/if}
{#if lastUploadAt || lastDownloadAt}
<p class="mt-1 text-[10px] text-zinc-400 dark:text-zinc-500">
{#if lastUploadAt}<span>Last upload: {lastUploadAt}</span>{/if}
{#if lastUploadAt && lastDownloadAt}<span class="mx-1">·</span>{/if}
{#if lastDownloadAt}<span>Last download: {lastDownloadAt}</span>{/if}
</p>
{/if}
</div>
{#if showRestoreConfirm}
<DisclaimerModal
title="Restore from cloud?"
message="This will replace BetterSEQTA+ settings in this browser with your cloud backup. Your BetterSEQTA Cloud sign-in on this device will be kept. Continue?"
onConfirm={confirmDownload}
onCancel={() => (showRestoreConfirm = false)}
/>
{/if}
@@ -1,29 +1,32 @@
<script lang="ts">
import { fade } from "svelte/transition";
import { animate } from "motion";
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup";
import { onMount } from "svelte";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import CloudLoginForm from "@/interface/components/store/CloudLoginForm.svelte";
let { onClose } = $props<{ onClose: () => void }>();
let modalElement: HTMLElement;
$effect(() => {
if (modalElement) {
animate(modalElement, { scale: [0.9, 1], opacity: [0, 1] }, { type: "spring", stiffness: 300, damping: 25 });
}
onMount(() => {
return cloudAuth.subscribe((s) => {
if (s.isLoggedIn) onClose();
});
});
function handleSignIn() {
onClose();
if (document.getElementById("ExtensionPopup")) {
closeExtensionPopup();
} else {
window.close();
}
$effect(() => {
if (modalElement) {
animate(
modalElement,
{ scale: [0.9, 1], opacity: [0, 1] },
{ type: "spring", stiffness: 300, damping: 25 },
);
}
});
</script>
<div
class="flex fixed inset-0 z-[10000] justify-center items-center bg-black/50"
class="flex fixed inset-0 z-[99999] justify-center items-center bg-black/50"
onclick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
@@ -37,7 +40,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={modalElement}
class="p-4 mx-4 w-full max-w-md bg-white rounded-2xl shadow-2xl dark:bg-zinc-800 dark:text-white"
class="p-4 mx-4 w-full max-w-md max-h-[90vh] overflow-y-auto bg-white rounded-2xl shadow-2xl dark:bg-zinc-800 dark:text-white"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
@@ -45,32 +48,19 @@
Sign in to favorite themes
</h2>
<p class="mb-6 text-zinc-600 dark:text-zinc-400">
Sign in in the Theme Store to save favorites across devices, or create an account to get started.
<p class="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
Sign in to the Theme Store to save favorites across devices, or create an account to get started.
</p>
<div class="flex flex-wrap gap-2 justify-end">
<CloudLoginForm compact onSuccess={onClose} />
<div class="flex justify-end mt-4">
<button
type="button"
onclick={onClose}
class="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-700 dark:text-zinc-200 hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
>
OK
</button>
<a
href="https://accounts.betterseqta.org/register"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
>
Create account
</a>
<button
type="button"
onclick={handleSignIn}
class="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
Sign in
Close
</button>
</div>
</div>
@@ -1,11 +1,13 @@
<script lang="ts">
import { onMount } from "svelte";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import CloudLoginForm from "./CloudLoginForm.svelte";
let { alwaysShowUserName = false } = $props<{
/** When true (e.g. narrow extension popup), show display name below sm breakpoint */
alwaysShowUserName?: boolean;
}>();
let username = $state("");
let password = $state("");
let loading = $state(false);
let error = $state<string | null>(null);
let cloudState = $state(cloudAuth.state);
let open = $state(false);
let dropdownEl: HTMLElement;
@@ -35,27 +37,6 @@
}
});
async function handleLogin() {
if (loading) return;
error = null;
if (!username.trim() || !password) {
error = "Please enter username and password";
return;
}
loading = true;
try {
const result = await cloudAuth.login(username.trim(), password);
if (result.success) {
password = "";
open = false;
} else {
error = result.error ?? "Login failed";
}
} finally {
loading = false;
}
}
async function handleLogout() {
await cloudAuth.logout();
open = false;
@@ -89,7 +70,11 @@
{getInitials()}
</div>
{/if}
<span class="hidden max-w-24 truncate sm:inline text-base">
<span
class={alwaysShowUserName
? "inline max-w-[10rem] truncate text-sm"
: "hidden max-w-24 truncate sm:inline text-base"}
>
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
</span>
{:else}
@@ -142,58 +127,11 @@
</button>
</div>
{:else}
<p class="mb-4 text-base text-zinc-600 dark:text-zinc-400">
Sign in to favorite themes. Your favorites sync across devices when logged in.
</p>
<form
class="flex flex-col gap-3"
autocomplete="off"
onsubmit={(e) => {
e.preventDefault();
handleLogin();
<CloudLoginForm
onSuccess={() => {
open = false;
}}
>
<input
type="text"
name="betterseqta-cloud-username"
autocomplete="off"
placeholder="Email or username"
bind:value={username}
disabled={loading}
readonly
onfocus={(e) => e.currentTarget.removeAttribute('readonly')}
class="w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200"
/>
<input
type="password"
name="betterseqta-cloud-password"
autocomplete="new-password"
placeholder="Password"
bind:value={password}
disabled={loading}
readonly
onfocus={(e) => e.currentTarget.removeAttribute('readonly')}
class="w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200"
/>
{#if error}
<p class="text-base text-red-600 dark:text-red-400">{error}</p>
{/if}
<button
type="submit"
disabled={loading}
class="w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 disabled:opacity-50 transition-colors duration-200"
>
{loading ? "Signing in..." : "Sign in"}
</button>
<a
href="https://accounts.betterseqta.org/register"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center justify-center gap-2 px-4 py-3 text-base font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
>
Create account
</a>
</form>
{/if}
</div>
</div>
@@ -0,0 +1,111 @@
<script lang="ts">
import { cloudAuth } from "@/seqta/utils/CloudAuth";
let {
introText,
onSuccess,
compact = false,
} = $props<{
introText?: string;
onSuccess?: () => void;
/** Smaller padding/text for overlays (e.g. SignInToFavoriteModal) */
compact?: boolean;
}>();
let username = $state("");
let password = $state("");
let loading = $state(false);
let error = $state<string | null>(null);
const inputClass = $derived(
compact
? "w-full px-4 py-2 text-sm rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200"
: "w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200",
);
const btnClass = $derived(
compact
? "w-full px-4 py-2 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 disabled:opacity-50 transition-colors duration-200"
: "w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 disabled:opacity-50 transition-colors duration-200",
);
const linkClass = $derived(
compact
? "inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
: "inline-flex items-center justify-center gap-2 px-4 py-3 text-base font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200",
);
async function handleLogin() {
if (loading) return;
error = null;
if (!username.trim() || !password) {
error = "Please enter username and password";
return;
}
loading = true;
try {
const result = await cloudAuth.login(username.trim(), password);
if (result.success) {
password = "";
onSuccess?.();
} else {
error = result.error ?? "Login failed";
}
} finally {
loading = false;
}
}
</script>
{#if introText}
<p
class="mb-4 text-zinc-600 dark:text-zinc-400 {compact ? 'text-sm' : 'text-base'}"
>
{introText}
</p>
{/if}
<form
class="flex flex-col gap-3"
autocomplete="off"
onsubmit={(e) => {
e.preventDefault();
handleLogin();
}}
>
<input
type="text"
name="betterseqta-cloud-username"
autocomplete="off"
placeholder="Email or username"
bind:value={username}
disabled={loading}
readonly
onfocus={(e) => e.currentTarget.removeAttribute("readonly")}
class={inputClass}
/>
<input
type="password"
name="betterseqta-cloud-password"
autocomplete="new-password"
placeholder="Password"
bind:value={password}
disabled={loading}
readonly
onfocus={(e) => e.currentTarget.removeAttribute("readonly")}
class={inputClass}
/>
{#if error}
<p class="text-red-600 dark:text-red-400 {compact ? 'text-sm' : 'text-base'}">{error}</p>
{/if}
<button type="submit" disabled={loading} class={btnClass}>
{loading ? "Signing in..." : "Sign in"}
</button>
<a
href="https://accounts.betterseqta.org/register"
target="_blank"
rel="noopener noreferrer"
class={linkClass}
>
Create account
</a>
</form>
@@ -43,8 +43,24 @@
onclick={() => setDisplayTheme(theme)}
>
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-full" />
{#if theme.featured === true}
<div class="absolute top-4 left-4 z-[2] pointer-events-none">
<span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100 shadow-sm"
aria-label="Featured theme"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" />
</svg>
Featured
</span>
</div>
{/if}
<div class='absolute bottom-0 left-0 p-8 z-[1]'>
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2>
{#if theme.author}
<p class="text-sm text-white/90 mt-1 mb-1 line-clamp-1">By {theme.author}</p>
{/if}
<p class='text-lg text-white'>{theme.description}</p>
</div>
<div class='absolute bottom-0 left-0 w-full h-1/2 to-transparent bg-linear-to-t from-black/80'></div>
+30 -11
View File
@@ -2,16 +2,14 @@
import type { Theme } from '@/interface/types/Theme'
import { fade } from 'svelte/transition';
import { onMount } from 'svelte';
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte';
let { theme, onClick, toggleFavorite, isLoggedIn } = $props<{
let { theme, onClick, toggleFavorite, isLoggedIn, onRequestSignIn } = $props<{
theme: Theme;
onClick: () => void;
toggleFavorite: (theme: Theme) => void;
isLoggedIn: boolean;
onRequestSignIn?: () => void;
}>();
let menuOpen = $state(false);
let showSignInModal = $state(false);
let menuRef: HTMLDivElement;
onMount(() => {
@@ -34,14 +32,36 @@
if (isLoggedIn) {
toggleFavorite(theme);
} else {
showSignInModal = true;
onRequestSignIn?.();
}
menuOpen = false;
}
</script>
<div class="w-full cursor-pointer" role="button" tabindex="-1" onkeydown={onClick} onclick={handleCardClick}>
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] dark:hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border" transition:fade>
<div
class="relative z-0 hover:z-20 w-full cursor-pointer"
role="button"
tabindex="-1"
onkeydown={onClick}
onclick={handleCardClick}
>
<div
class="bg-gray-50 w-full transition-all duration-500 ease-out relative group flex flex-col rounded-xl overflow-clip border hover:scale-105 hover:shadow-2xl dark:hover:shadow-white/[0.1] dark:hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto"
transition:fade
>
{#if theme.featured === true}
<div class="absolute top-2 left-2 z-20 pointer-events-none">
<span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100 shadow-sm"
aria-label="Featured theme"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" />
</svg>
Featured
</span>
</div>
{/if}
<!-- Menu dropdown -->
<div class="absolute top-2 right-2 z-20" data-theme-menu bind:this={menuRef}>
<button
@@ -83,6 +103,9 @@
</div>
<div class="absolute bottom-1 left-3 right-3 z-10 mb-1 flex flex-col gap-0.5">
<span class="text-xl font-bold text-white drop-shadow-md">{theme.name}</span>
{#if theme.author}
<span class="text-xs text-white/85 drop-shadow-md line-clamp-1">By {theme.author}</span>
{/if}
<div class="flex gap-3 text-xs font-medium text-white/90 drop-shadow-sm">
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3.5 h-3.5">
@@ -104,7 +127,3 @@
</div>
</div>
</div>
{#if showSignInModal}
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
{/if}
@@ -2,12 +2,13 @@
import type { Theme } from '@/interface/types/Theme'
import ThemeCard from './ThemeCard.svelte';
let { themes, searchTerm, setDisplayTheme, toggleFavorite, isLoggedIn } = $props<{
let { themes, searchTerm, setDisplayTheme, toggleFavorite, isLoggedIn, onRequestSignIn } = $props<{
themes: Theme[];
searchTerm: string;
setDisplayTheme: (theme: Theme) => void;
toggleFavorite: (theme: Theme) => void;
isLoggedIn: boolean;
onRequestSignIn?: () => void;
}>();
let filteredThemes = $derived(themes.filter((theme: Theme) =>
@@ -23,12 +24,13 @@
onClick={() => setDisplayTheme(theme)}
{toggleFavorite}
{isLoggedIn}
{onRequestSignIn}
/>
{/each}
{#if filteredThemes.length !== 0}
<a href="https://betterseqta.gitbook.io/betterseqta-docs" class='w-full cursor-pointer'>
<div class="bg-zinc-50 h-48 w-full transition-all hover:scale-105 duration-500 relative justify-center items-center group group/card flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] rounded-xl overflow-clip border">
<a href="https://docs.betterseqta.org/theme-creation/" class="block relative z-0 hover:z-20 w-full cursor-pointer">
<div class="bg-zinc-50 h-48 w-full transition-all duration-500 ease-out relative overflow-clip rounded-xl border group group/card flex flex-col justify-center items-center hover:scale-105 hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1]">
<div class="text-2xl font-IconFamily">{'\uecb3'}</div>
<div class="text-xl font-bold text-center transition-all duration-500 dark:text-white">
Got a Theme Idea?
@@ -43,7 +45,7 @@
<div class="absolute top-0 flex flex-col items-center justify-center w-full text-center h-96">
<h1 class="mt-4 text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-5xl">That doesn't exist! 😭😭😭</h1>
<p class="mt-6 text-lg leading-7 text-zinc-600 dark:text-zinc-300">Sorry, we couldn't find the theme you're looking for. Maybe... you could create it?</p>
<a href="https://betterseqta.gitbook.io/betterseqta-docs" class='p-2 px-3 mt-4 transition rounded-md cursor-pointer dark:text-white bg-zinc-500/10 hover:scale-105'>
<a href="https://docs.betterseqta.org/theme-creation/" class='p-2 px-3 mt-4 transition rounded-md cursor-pointer dark:text-white bg-zinc-500/10 hover:scale-105'>
Show me how!
</a>
</div>
@@ -2,9 +2,7 @@
import type { Theme } from '@/interface/types/Theme'
import { fade } from 'svelte/transition';
import { animate } from 'motion';
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte';
let { theme, currentThemes, setDisplayTheme, onInstall, onRemove, allThemes, displayTheme, toggleFavorite, isLoggedIn } = $props<{
let { theme, currentThemes, setDisplayTheme, onInstall, onRemove, allThemes, displayTheme, toggleFavorite, isLoggedIn, onRequestSignIn } = $props<{
theme: Theme | null;
currentThemes: string[];
setDisplayTheme: (theme: Theme | null) => void;
@@ -14,31 +12,40 @@
displayTheme: Theme | null;
toggleFavorite?: (theme: Theme) => void;
isLoggedIn?: boolean;
onRequestSignIn?: () => void;
}>();
let installing = $state(false);
let showSignInModal = $state(false);
let modalElement: HTMLElement;
function handleFavoriteClick() {
if (isLoggedIn && toggleFavorite && theme) {
toggleFavorite(theme);
} else {
showSignInModal = true;
onRequestSignIn?.();
}
}
// Function to get related themes
function getRelatedThemes() {
if (!theme) return [];
return allThemes
.filter((t: Theme) => !!t && t.id !== theme.id)
.sort(
(a: Theme, b: Theme) =>
a.name.localeCompare(theme.name) - b.name.localeCompare(theme.name),
)
.slice(0, 4);
function tagsOverlap(a: string[] | undefined, b: string[] | undefined): boolean {
const lowerB = new Set((b ?? []).map((t) => t.toLowerCase()));
return (a ?? []).some((t) => lowerB.has(t.toLowerCase()));
}
const relatedThemes = $derived.by(() => {
const t = theme;
if (!t) return [] as Theme[];
if ((t.tags ?? []).length === 0) return [];
return allThemes
.filter((x: Theme) => !!x && x.id !== t.id && tagsOverlap(t.tags, x.tags))
.sort((a: Theme, b: Theme) => {
const diff = (b.download_count ?? 0) - (a.download_count ?? 0);
if (diff !== 0) return diff;
const byName = a.name.localeCompare(b.name);
if (byName !== 0) return byName;
return a.id.localeCompare(b.id);
})
.slice(0, 4);
});
$effect(() => {
if (displayTheme) {
animate(
@@ -95,9 +102,27 @@
{'\ued8a'}
</button>
</div>
<h2 class="mb-2 text-2xl font-bold">
<div class="flex flex-wrap items-center gap-2 pr-12 mb-2">
<h2 class="text-2xl font-bold">
{theme.name}
</h2>
{#if theme.featured === true}
<span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100"
aria-label="Featured theme"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" />
</svg>
Featured
</span>
{/if}
</div>
{#if theme.author}
<p class="mb-2 text-sm text-zinc-600 dark:text-zinc-400">
By {theme.author}
</p>
{/if}
<div class="flex gap-4 mb-4 text-sm text-zinc-600 dark:text-zinc-400">
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
@@ -152,15 +177,16 @@
{/if}
</div>
{#if relatedThemes.length > 0}
<div class="my-8 border-b border-zinc-200 dark:border-zinc-700"></div>
<h3 class="mb-4 text-lg font-bold">
Similar Themes
Related themes
</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
{#each getRelatedThemes() as relatedTheme (relatedTheme.id)}
<button onclick={() => { hideModal(relatedTheme) }} class="w-full cursor-pointer">
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group group/card flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border">
{#each relatedThemes as relatedTheme (relatedTheme.id)}
<button onclick={() => { hideModal(relatedTheme) }} class="relative z-0 hover:z-20 w-full cursor-pointer">
<div class="bg-gray-50 w-full transition-all duration-500 ease-out relative group group/card flex flex-col hover:scale-105 hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border">
<div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white transition-all duration-500 group-hover:-translate-y-0.5">
{relatedTheme.name}
</div>
@@ -170,6 +196,7 @@
</button>
{/each}
</div>
{/if}
</div>
{:else}
<div class="flex justify-center items-center h-full text-zinc-600 dark:text-zinc-300">
@@ -180,7 +207,3 @@
{/if}
</div>
</div>
{#if showSignInModal}
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
{/if}
@@ -85,7 +85,7 @@
try {
const result = JSON.parse(event.target?.result as string);
tempTheme = result;
await themeManager.installTheme(result);
await themeManager.installTheme(result, { fromStore: false });
await fetchThemes();
} catch (error) {
console.error('Error parsing file:', error);
+13
View File
@@ -16,6 +16,7 @@
import ColourPicker from "../components/ColourPicker.svelte";
import DisclaimerModal from "../components/DisclaimerModal.svelte";
import CloudHeader from "@/interface/components/store/CloudHeader.svelte";
import { settingsPopup } from "../hooks/SettingsPopup";
let devModeSequence = "";
@@ -276,6 +277,18 @@
{/if}
</div>
<div
class="flex shrink-0 items-center justify-between gap-2 px-4 py-2.5 border-b border-zinc-200/40 dark:border-zinc-700/40"
>
<div class="min-w-0 flex-1">
<h2 class="text-sm font-bold text-zinc-900 dark:text-white">BetterSEQTA Cloud</h2>
<p class="text-xs text-zinc-500 dark:text-zinc-400">Account & sync</p>
</div>
<div class="shrink-0">
<CloudHeader alwaysShowUserName />
</div>
</div>
<TabbedContainer
bind:activeTab={settingsActiveTab}
tabs={[
+52 -11
View File
@@ -11,8 +11,10 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte"
import CloudSettingsSync from "@/interface/components/CloudSettingsSync.svelte"
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
import { getAllPluginSettings } from "@/plugins"
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
@@ -97,6 +99,19 @@
showColourPicker: () => void;
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
}>();
async function exportCloudSettingsJsonToFile() {
const payload = await getSnapshotForUpload();
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `betterseqta-plus-settings-export-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
a.click();
URL.revokeObjectURL(url);
}
</script>
{#snippet Setting({ title, description, Component, props }: SettingsList) }
@@ -181,22 +196,23 @@
},
{
title: "Default Page",
description: "The page to load when SEQTA Learn is opened",
description:
"The page to load when SEQTA Learn or SEQTA Engage opens (uses the same #?page=/… URL as SEQTA). BetterSEQTA home on Engage only applies when Home is selected.",
id: 10,
Component: Select,
props: {
state: $settingsState.defaultPage,
onChange: (value: string) => settingsState.defaultPage = value,
onChange: (value: string) => (settingsState.defaultPage = value),
options: [
{ value: 'home', label: 'Home' },
{ value: 'dashboard', label: 'Dashboard' },
{ value: 'timetable', label: 'Timetable' },
{ value: 'welcome', label: 'Welcome' },
{ value: 'messages', label: 'Messages' },
{ value: 'documents', label: 'Documents' },
{ value: 'reports', label: 'Reports' },
]
}
{ value: "home", label: "Home" },
{ value: "dashboard", label: "Dashboard" },
{ value: "timetable", label: "Timetable" },
{ value: "welcome", label: "Welcome" },
{ value: "messages", label: "Messages" },
{ value: "documents", label: "Documents" },
{ value: "reports", label: "Reports" },
],
},
},
{
title: "News Feed Source",
@@ -252,6 +268,18 @@
/>
</div>
</div>
<div class="flex justify-between items-center px-4 py-3 pl-6 border-t border-zinc-100 dark:border-zinc-700/50">
<div class="pr-4">
<h2 class="text-sm font-bold">Smooth colour transition</h2>
<p class="text-xs">Ease between class/subject colours when navigating instead of switching instantly</p>
</div>
<div>
<Switch
state={$settingsState.adaptiveThemeColourTransition ?? true}
onChange={(isOn: boolean) => settingsState.adaptiveThemeColourTransition = isOn}
/>
</div>
</div>
{/if}
</div>
</div>
@@ -373,6 +401,10 @@
}
})}
<div class="border-none py-3">
<CloudSettingsSync />
</div>
{#if $settingsState.devMode}
<div class="flex-col p-1 my-1 bg-gradient-to-br from-white rounded-xl border shadow-sm to-zinc-100 border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
<div class="flex justify-between items-center px-4 py-3">
@@ -427,6 +459,15 @@
/>
</div>
</div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Export cloud settings JSON</h2>
<p class="text-xs">Download the same payload as cloud sync (OAuth tokens stripped). For debugging and server testing.</p>
</div>
<div>
<Button onClick={exportCloudSettingsJsonToFile} text="Export to file" />
</div>
</div>
</div>
{/if}
</div>
+28 -9
View File
@@ -16,6 +16,7 @@
import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
import Backgrounds from '../components/store/Backgrounds.svelte'
import { cloudAuth } from '@/seqta/utils/CloudAuth'
import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte'
const themeManager = ThemeManager.getInstance();
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
@@ -34,6 +35,7 @@
let error = $state<string | null>(null);
let selectedBackground = $state<string | null>(null);
let showSignInOverlay = $state(false);
const fetchCurrentThemes = async () => {
const themes = await themeManager.getAvailableThemes();
@@ -52,6 +54,17 @@
activeTab = tab;
};
/** Featured themes first; within each group, newest by `created_at` (API: Unix seconds). */
function compareStoreThemes(a: Theme, b: Theme): number {
const fa = a.featured === true ? 1 : 0;
const fb = b.featured === true ? 1 : 0;
if (fa !== fb) return fb - fa;
const ca = a.created_at ?? 0;
const cb = b.created_at ?? 0;
if (ca !== cb) return cb - ca;
return a.name.localeCompare(b.name);
}
const toggleFavorite = async (theme: Theme) => {
const token = await cloudAuth.getStoredToken();
if (!token) return;
@@ -94,11 +107,8 @@
if (!data?.success || !data?.data?.themes) {
throw new Error(data?.error || 'Failed to fetch themes');
}
themes = data.data.themes;
// Shuffle for cover themes
const shuffled = [...themes].sort(() => 0.5 - Math.random());
coverThemes = shuffled.slice(0, 3);
themes = [...data.data.themes].sort(compareStoreThemes);
coverThemes = themes.slice(0, 3);
loading = false;
} catch (err) {
@@ -116,11 +126,14 @@
darkMode = $settingsState.DarkMode;
});
// Filter themes based on search term
let filteredThemes = $derived(themes.filter(theme =>
// Filter themes (list is already featured-first, then newest; filter preserves order)
let filteredThemes = $derived(
themes.filter(
(theme) =>
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
theme.description.toLowerCase().includes(searchTerm.toLowerCase())
));
theme.description.toLowerCase().includes(searchTerm.toLowerCase()),
),
);
$effect(() => {
loadBackground();
@@ -169,6 +182,7 @@
{setDisplayTheme}
{toggleFavorite}
isLoggedIn={cloudLoggedIn}
onRequestSignIn={() => (showSignInOverlay = true)}
/>
{#if displayTheme}
@@ -180,6 +194,7 @@
{setDisplayTheme}
{toggleFavorite}
isLoggedIn={cloudLoggedIn}
onRequestSignIn={() => (showSignInOverlay = true)}
onInstall={async () => {
if (displayTheme) {
await themeManager.downloadTheme(displayTheme);
@@ -204,4 +219,8 @@
{/if}
</div>
</div>
{#if showSignInOverlay}
<SignInToFavoriteModal onClose={() => (showSignInOverlay = false)} />
{/if}
</div>
+57 -10
View File
@@ -4,7 +4,10 @@
import { slide } from 'svelte/transition';
import { fade } from 'svelte/transition';
import { type LoadedCustomTheme } from '@/types/CustomThemes'
import {
type LoadedCustomTheme,
shouldForceThemeAppearance,
} from '@/types/CustomThemes'
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
@@ -21,9 +24,9 @@
handleImageVariableChange,
handleCoverImageUpload
} from '../utils/themeImageHandlers';
import { CloseThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
import { themeUpdates } from '../hooks/ThemeUpdates'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
import { themeUpdates } from '../hooks/ThemeUpdates'
import { CloseThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
const { themeID } = $props<{ themeID: string }>()
const themeManager = ThemeManager.getInstance();
@@ -40,7 +43,9 @@
coverImage: null,
isEditable: true,
hideThemeName: false,
forceDark: undefined
forceTheme: undefined,
forceDark: undefined,
adaptiveCssVariables: [],
})
let closedAccordions = $state<string[]>([])
let themeLoaded = $state(false);
@@ -80,7 +85,13 @@
}))
}
theme = loadedTheme
theme = {
...loadedTheme,
adaptiveCssVariables: loadedTheme.adaptiveCssVariables ?? [],
forceTheme:
loadedTheme.forceTheme ??
(loadedTheme.forceDark !== undefined ? true : undefined),
}
themeLoaded = true
} else {
themeLoaded = true
@@ -114,6 +125,14 @@
blob: image.blob
}))
themeClone.coverImage = theme.coverImage
themeClone.userEdited = true
if (shouldForceThemeAppearance(themeClone)) {
themeClone.forceTheme = true;
} else {
themeClone.forceTheme = false;
themeClone.forceDark = undefined;
}
themeManager.clearPreview();
await themeManager.saveTheme(themeClone);
@@ -270,7 +289,7 @@
<h1 class='text-xl font-semibold'>Theme Creator</h1>
<a href='https://betterseqta.gitbook.io/betterseqta-docs' target='_blank' class='text-sm font-light text-zinc-500 dark:text-zinc-400'>
<a href='https://docs.betterseqta.org/theme-creation/' target='_blank' rel='noopener noreferrer' class='text-sm font-light text-zinc-500 dark:text-zinc-400'>
<span class='pr-0.5 no-underline font-IconFamily'>{'\ueb44'}</span>
<span class='underline'>
Need help? Check out the docs!
@@ -317,6 +336,27 @@
<Divider />
<div class="py-3">
<h2 class="text-sm font-bold">Adaptive CSS variables</h2>
<p class="text-xs text-zinc-600 dark:text-zinc-400">
One per line, each must start with <code class="text-xs">--</code>. These receive the same colour as the adaptive accent when &quot;Adaptive theme colour&quot; is enabled in general settings. Use them in Custom CSS, e.g. <code class="text-xs">border-color: var(--my-accent);</code>
</p>
<textarea
placeholder="--my-accent&#10;--class-banner"
value={theme.adaptiveCssVariables?.join('\n') ?? ''}
oninput={(e) => {
const lines = e.currentTarget.value
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
theme = { ...theme, adaptiveCssVariables: lines };
}}
class="p-2 mt-2 w-full min-h-[5rem] font-mono text-sm rounded-lg border-0 transition dark:placeholder-zinc-400 bg-zinc-200 dark:bg-zinc-700 focus:outline-none focus:ring-1 focus:ring-zinc-100 dark:focus:ring-zinc-700 focus:bg-zinc-300/50 dark:focus:bg-zinc-600"
></textarea>
</div>
<Divider />
{#each [
{
type: 'switch',
@@ -332,21 +372,28 @@
title: 'Force Theme',
description: 'Force users to use either dark or light mode',
props: {
state: theme.forceDark !== undefined,
onChange: (value: boolean) => theme = { ...theme, forceDark: value ? false : undefined }
state: shouldForceThemeAppearance(theme),
onChange: (value: boolean) => {
if (value) {
theme = { ...theme, forceTheme: true, forceDark: false };
} else {
theme = { ...theme, forceTheme: false, forceDark: undefined };
}
}
}
},
{
type: 'conditional',
props: {
condition: theme.forceDark !== undefined,
condition: shouldForceThemeAppearance(theme),
children: {
type: 'lightDarkToggle',
title: 'Mode',
description: 'Choose whether to force light or dark mode',
props: {
state: theme.forceDark === true,
onChange: (value: boolean) => theme = { ...theme, forceDark: value }
onChange: (value: boolean) =>
(theme = { ...theme, forceDark: value, forceTheme: true })
}
}
}
+7
View File
@@ -8,4 +8,11 @@ export type Theme = {
is_favorited?: boolean;
favorite_count?: number;
download_count?: number;
author?: string;
featured?: boolean;
tags?: string[];
/** Unix time in seconds (API list/detail). */
created_at?: number;
/** Unix seconds — last server update (GET /api/themes). */
updated_at?: number;
};
+27
View File
@@ -0,0 +1,27 @@
import * as pdfjs from "pdfjs-dist";
import browser from "webextension-polyfill";
/** Static copies in `src/public` (see `scripts/copy-pdfjs-assets.mjs`, manifest web_accessible_resources). */
const PDF_WORKER_RESOURCE = "resources/pdfjs/pdf.worker.min.mjs";
const PDF_LEGACY_RESOURCE = "resources/pdfjs/pdf.legacy.min.mjs";
function extensionAssetUrl(relativePath: string): string {
return browser.runtime.getURL(relativePath.replace(/^\/+/, ""));
}
let workerConfigured = false;
/** Required before pdfjs spawns a worker (content-script / extension isolate). */
export function ensurePdfjsWorker(): void {
if (workerConfigured) return;
pdfjs.GlobalWorkerOptions.workerSrc = extensionAssetUrl(PDF_WORKER_RESOURCE);
workerConfigured = true;
}
/** Page-context script on Firefox must load these chrome-extension:// URLs (see web_accessible_resources). */
export function getPdfjsPageContextUrls(): { lib: string; worker: string } {
return {
lib: extensionAssetUrl(PDF_LEGACY_RESOURCE),
worker: extensionAssetUrl(PDF_WORKER_RESOURCE),
};
}
+7 -2
View File
@@ -15,7 +15,7 @@
"64": "resources/icons/icon-64.png"
}
},
"permissions": ["tabs", "notifications", "storage"],
"permissions": ["tabs", "notifications", "storage", "alarms"],
"host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"],
"background": {
"service_worker": "background.ts"
@@ -32,7 +32,12 @@
],
"web_accessible_resources": [
{
"resources": ["resources/icons/*", "resources/update-image.webp"],
"resources": [
"resources/icons/*",
"resources/update-image.webp",
"resources/pdfjs/pdf.worker.min.mjs",
"resources/pdfjs/pdf.legacy.min.mjs"
],
"matches": ["*://*/*"]
}
]
@@ -1,8 +1,12 @@
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts";
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
import {
ensurePdfjsWorker,
getPdfjsPageContextUrls,
} from "@/lib/pdfjsExtension.ts";
import * as pdfjs from "pdfjs-dist";
pdfjs.GlobalWorkerOptions.workerSrc =
`https://cdn.jsdelivr.net/npm/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
ensurePdfjsWorker();
export async function initStorage(api: any) {
await api.storage.loaded;
@@ -110,11 +114,16 @@ function createWeightLabel(
: "N/A";
}
statsContainer.style.position = "relative";
weightLabel.style.position = "absolute";
weightLabel.style.right = "0";
weightLabel.style.top = "50%";
weightLabel.style.transform = "translateY(-50%)";
// Stack weight under Max/native stats — absolute right:0 overlapped the max column (#414).
statsContainer.style.display = "flex";
statsContainer.style.flexDirection = "column";
statsContainer.style.alignItems = "flex-end";
statsContainer.style.gap = "2px";
statsContainer.style.justifyContent = "center";
weightLabel.style.position = "relative";
weightLabel.style.inset = "unset";
weightLabel.style.transform = "none";
statsContainer.appendChild(weightLabel);
}
@@ -219,6 +228,12 @@ async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
export async function extractPDFText(url: string): Promise<string> {
try {
if (isFirefox) {
const { lib: pdfLibUrl, worker: pdfWorkerUrl } = getPdfjsPageContextUrls();
const escJsSingleQuoted = (s: string) =>
s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
const pdfWorkerInj = escJsSingleQuoted(pdfWorkerUrl);
return new Promise((resolve, reject) => {
const script = document.createElement("script");
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
@@ -232,13 +247,15 @@ export async function extractPDFText(url: string): Promise<string> {
(function() {
const requestId = '${requestId}';
const url = '${escapedUrl}';
const pdfLibSrc = '${pdfLibInj}';
const pdfWorkerSrc = '${pdfWorkerInj}';
if (window.pdfjsLib) {
extractPDF();
} else {
const pdfjsScript = document.createElement('script');
pdfjsScript.src = 'https://cdn.jsdelivr.net/npm/pdfjs-dist/build/pdf.min.js';
pdfjsScript.type = 'text/javascript';
pdfjsScript.src = pdfLibSrc;
pdfjsScript.type = 'module';
pdfjsScript.onload = function() {
extractPDF();
@@ -256,7 +273,7 @@ export async function extractPDFText(url: string): Promise<string> {
function extractPDF() {
try {
window.pdfjsLib.GlobalWorkerOptions.workerSrc = '';
window.pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc;
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
@@ -4,6 +4,7 @@ import { getAssessmentsData } from "./api";
import { renderErrorState, renderSkeletonLoader } from "./ui";
import styles from "./styles.css?inline";
import { delay } from "@/seqta/utils/delay";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
const assessmentsOverviewPlugin: Plugin<{}> = {
id: "assessments-overview",
@@ -16,6 +17,8 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
styles,
run: async () => {
if (isSeqtaEngageExperience()) return;
const menu = (await waitForElm(
'[data-key="assessments"] > .sub > ul',
true,
@@ -120,7 +120,6 @@ export default defineLazyPlugin({
settings,
disableToggle: true,
defaultEnabled: false,
beta: true,
styles: styles,
// Lazy loader - only imports the heavy plugin when actually needed
@@ -145,7 +145,6 @@ const globalSearchPlugin: Plugin<typeof settings> = {
settings: settingsInstance.settings,
disableToggle: true,
defaultEnabled: false,
beta: true,
styles: styles,
run: async (api) => {
@@ -1,4 +1,5 @@
import type { Plugin } from "../../core/types";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
interface NotificationCollectorStorage {
lastNotificationCount: number;
@@ -15,6 +16,10 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
disableToggle: true,
run: async (api) => {
if (isSeqtaEngageExperience()) {
return () => {};
}
let pollInterval: number | null = null;
let isVisible = !document.hidden;
let baseInterval = 30000; // 30 seconds
+43 -11
View File
@@ -1,11 +1,22 @@
import type { Plugin } from "@/plugins/core/types";
import { componentSetting, defineSettings } from "@/plugins/core/settingsHelpers";
import {
booleanSetting,
componentSetting,
defineSettings,
} from "@/plugins/core/settingsHelpers";
import ProfilePictureSetting from "./ProfilePictureSetting.svelte";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import styles from "./styles.css?inline";
import localforage from "localforage";
const settings = defineSettings({
useCloudPfp: booleanSetting({
default: false,
title: "Use BetterSEQTA Cloud profile picture",
description:
"When enabled, uses the avatar from your BetterSEQTA Cloud account (sign in from the extension store). Otherwise uses the uploaded image below.",
}),
picture: componentSetting({
title: "Profile Picture",
description: "Upload or remove your custom profile image",
@@ -17,7 +28,7 @@ const profilePicturePlugin: Plugin<typeof settings> = {
id: "profile-picture",
name: "Custom Profile Picture",
description: "Use your own image in place of the profile icon",
version: "1.1.0",
version: "1.2.0",
settings: settings,
disableToggle: true,
defaultEnabled: false,
@@ -36,14 +47,12 @@ const profilePicturePlugin: Plugin<typeof settings> = {
let img: HTMLImageElement | null = null;
let currentBlobUrl: string | undefined;
// Setup localforage instance
const store = localforage.createInstance({
name: "profile-picture-store",
storeName: "profilePicture",
});
async function updateImageFromStore() {
// Remove old image if present
async function applyProfileImage() {
if (img) {
img.remove();
img = null;
@@ -52,6 +61,19 @@ const profilePicturePlugin: Plugin<typeof settings> = {
URL.revokeObjectURL(currentBlobUrl);
currentBlobUrl = undefined;
}
const useCloud = api.settings.useCloudPfp;
const pfpUrl = cloudAuth.state.user?.pfpUrl;
if (useCloud && pfpUrl) {
img = document.createElement("img");
img.className = "userInfoImg";
img.src = pfpUrl;
if (svg) svg.style.display = "none";
container.appendChild(img);
return;
}
const blob = await store.getItem<Blob>("profile-picture");
if (blob && blob instanceof Blob) {
currentBlobUrl = URL.createObjectURL(blob);
@@ -65,15 +87,25 @@ const profilePicturePlugin: Plugin<typeof settings> = {
}
}
// Initial load
await updateImageFromStore();
await applyProfileImage();
// Listen for profile picture updates
const handler = () => { updateImageFromStore(); };
window.addEventListener('profile-picture-updated', handler);
const onLocalPictureUpdated = () => {
void applyProfileImage();
};
window.addEventListener("profile-picture-updated", onLocalPictureUpdated);
const cloudUnsub = cloudAuth.subscribe(() => {
void applyProfileImage();
});
const useCloudUnreg = api.settings.onChange("useCloudPfp", () => {
void applyProfileImage();
});
return () => {
window.removeEventListener("profile-picture-updated", handler);
useCloudUnreg.unregister();
cloudUnsub();
window.removeEventListener("profile-picture-updated", onLocalPictureUpdated);
if (img) img.remove();
if (svg) svg.style.display = "";
if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl);
+3 -4
View File
@@ -1,11 +1,10 @@
import renderSvelte from "@/interface/main";
import themeCreator from "@/interface/pages/themeCreator.svelte";
import { unmount } from "svelte";
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
import { unmount } from "svelte";
import themeCreator from "@/interface/pages/themeCreator.svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
let themeCreatorSvelteApp: any = null;
const themeManager = ThemeManager.getInstance();
/**
* Open the Theme Creator sidebar, it is an embedded page loaded similar to the extension popup
@@ -41,7 +40,7 @@ export function OpenThemeCreator(themeID: string = "") {
closeButton.textContent = "×";
closeButton.addEventListener("click", () => {
CloseThemeCreator();
themeManager.clearPreview();
ThemeManager.getInstance().clearPreview();
});
document.body.appendChild(closeButton);
+236 -40
View File
@@ -1,8 +1,20 @@
import localforage from "localforage";
import browser from "webextension-polyfill";
import type { CustomTheme, LoadedCustomTheme } from "@/types/CustomThemes";
import {
type CustomTheme,
getForcedDarkMode,
type LoadedCustomTheme,
shouldForceThemeAppearance,
} from "@/types/CustomThemes";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import debounce from "@/seqta/utils/debounce";
import { themeUpdates } from "@/interface/hooks/ThemeUpdates";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { updateAllColors } from "@/seqta/ui/colors/Manager";
import {
clearCustomThemeAdaptiveCssVariables,
setCustomThemeAdaptiveCssVariables,
} from "@/seqta/ui/colors/customThemeAdaptiveBindings";
type ThemeContent = {
id: string;
@@ -14,6 +26,15 @@ type ThemeContent = {
CustomCSS?: string;
hideThemeName?: boolean;
forceDark?: boolean;
images?: { id: string; variableName: string; data: string }[]; // data: base64
};
export type InstallThemeMeta = {
fromStore: boolean;
/** Server list `updated_at` (Unix seconds); set when installing from store. */
serverUpdatedAtSec?: number;
forceTheme?: boolean;
adaptiveCssVariables?: string[];
images: { id: string; variableName: string; data: string }[]; // data: base64
};
@@ -27,6 +48,7 @@ export class ThemeManager {
private originalPreviewTheme: boolean | null = null;
private imageUrlCache: Map<string, string> = new Map();
private lastTransitionPoint: { x: number; y: number } = { x: 0, y: 0 };
private storeUpdateCheckRunning = false;
private constructor() {
console.debug("[ThemeManager] Initializing...");
@@ -175,6 +197,8 @@ export class ThemeManager {
}
} catch (error) {
console.error("[ThemeManager] Error during initialization:", error);
} finally {
void this.checkStoreThemeUpdates();
}
}
@@ -209,7 +233,7 @@ export class ThemeManager {
console.debug("[ThemeManager] Storing original settings");
settingsState.originalSelectedColor = settingsState.selectedColor;
if (theme.forceDark) {
if (shouldForceThemeAppearance(theme)) {
settingsState.originalDarkMode = settingsState.DarkMode;
}
}
@@ -240,6 +264,7 @@ export class ThemeManager {
this.currentTheme = theme;
settingsState.selectedTheme = themeId;
}
void updateAllColors();
} catch (error) {
console.error("[ThemeManager] Error setting theme:", error);
}
@@ -270,9 +295,10 @@ export class ThemeManager {
}
// Apply theme settings
if (theme.forceDark !== undefined) {
console.debug("[ThemeManager] Setting dark mode:", theme.forceDark);
settingsState.DarkMode = theme.forceDark;
if (shouldForceThemeAppearance(theme)) {
const dark = getForcedDarkMode(theme);
console.debug("[ThemeManager] Setting dark mode:", dark);
settingsState.DarkMode = dark;
}
// Use the stored selected color if available, otherwise use the default
@@ -289,6 +315,8 @@ export class ThemeManager {
);
settingsState.selectedColor = theme.defaultColour;
}
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
} catch (error) {
console.error("[ThemeManager] Error applying theme:", error);
}
@@ -345,9 +373,18 @@ export class ThemeManager {
if (this.currentTheme) {
// Store the current color with the theme before removing it
const selectedColor = settingsState.selectedColor;
const markUserEditedForColor =
this.currentTheme.userEdited !== true &&
this.currentTheme.installedFromStore !== false &&
selectedColor &&
this.currentTheme.defaultColour &&
selectedColor.trim().toLowerCase() !==
this.currentTheme.defaultColour.trim().toLowerCase();
await localforage.setItem(this.currentTheme.id, {
...this.currentTheme,
selectedColor: settingsState.selectedColor,
selectedColor,
...(markUserEditedForColor ? { userEdited: true } : {}),
});
}
@@ -373,6 +410,7 @@ export class ThemeManager {
if (clearSelectedTheme) {
settingsState.selectedTheme = "";
}
clearCustomThemeAdaptiveCssVariables();
} catch (error) {
console.error("[ThemeManager] Error removing theme:", error);
}
@@ -427,18 +465,24 @@ export class ThemeManager {
public async saveTheme(theme: LoadedCustomTheme): Promise<void> {
console.debug("[ThemeManager] Saving theme:", theme.name);
try {
await localforage.setItem(theme.id, theme);
const existing = (await localforage.getItem(theme.id)) as CustomTheme | null;
let toSave = theme;
if (existing?.userEdited === true && theme.userEdited !== false) {
toSave = { ...theme, userEdited: true };
}
await localforage.setItem(toSave.id, toSave);
const themeIds = (await localforage.getItem("customThemes")) as
| string[]
| null;
if (themeIds) {
if (!themeIds.includes(theme.id)) {
themeIds.push(theme.id);
if (!themeIds.includes(toSave.id)) {
themeIds.push(toSave.id);
await localforage.setItem("customThemes", themeIds);
}
} else {
await localforage.setItem("customThemes", [theme.id]);
await localforage.setItem("customThemes", [toSave.id]);
}
} catch (error) {
console.error("[ThemeManager] Error saving theme:", error);
@@ -496,39 +540,62 @@ export class ThemeManager {
description?: string;
coverImage?: string;
theme_json_url?: string;
updated_at?: number;
}): Promise<void> {
console.debug("[ThemeManager] Downloading theme:", themeContent.name);
try {
if (!themeContent.id) return;
let themeData: ThemeContent;
try {
// Try API first (increments download_count)
const downloadData = (await this.fetchFromUrl(
`${this.THEME_API_BASE}/themes/${themeContent.id}/download`
)) as { success?: boolean; data?: { theme_json_url: string } };
if (!downloadData?.success || !downloadData?.data?.theme_json_url) {
throw new Error("Failed to get theme download URL");
}
themeData = (await this.fetchFromUrl(downloadData.data.theme_json_url)) as ThemeContent;
} catch (apiError) {
// Fallback to GitHub if API is unreachable
console.warn("[ThemeManager] API failed, trying GitHub fallback:", apiError);
const githubUrl = `${this.GITHUB_THEMES_BASE}/${themeContent.id}/theme.json`;
themeData = (await this.fetchFromUrl(githubUrl)) as ThemeContent;
}
await this.installTheme(themeData);
await this.downloadAndInstallStoreTheme(themeContent);
} catch (error) {
console.error("[ThemeManager] Error downloading theme:", error);
}
}
/**
* Fetch theme.json from the store and install (throws on failure).
*/
private async downloadAndInstallStoreTheme(themeContent: {
id: string;
name: string;
description?: string;
coverImage?: string;
theme_json_url?: string;
updated_at?: number;
}): Promise<void> {
console.debug("[ThemeManager] Downloading theme:", themeContent.name);
if (!themeContent.id) {
throw new Error("Missing theme id");
}
let themeData: ThemeContent;
try {
const downloadData = (await this.fetchFromUrl(
`${this.THEME_API_BASE}/themes/${themeContent.id}/download`,
)) as { success?: boolean; data?: { theme_json_url: string } };
if (!downloadData?.success || !downloadData?.data?.theme_json_url) {
throw new Error("Failed to get theme download URL");
}
themeData = (await this.fetchFromUrl(
downloadData.data.theme_json_url,
)) as ThemeContent;
} catch (apiError) {
console.warn("[ThemeManager] API failed, trying GitHub fallback:", apiError);
const githubUrl = `${this.GITHUB_THEMES_BASE}/${themeContent.id}/theme.json`;
themeData = (await this.fetchFromUrl(githubUrl)) as ThemeContent;
}
await this.installTheme(themeData, {
fromStore: true,
serverUpdatedAtSec: themeContent.updated_at,
});
}
/**
* Install a theme from theme data
*/
public async installTheme(themeData: ThemeContent): Promise<void> {
public async installTheme(
themeData: ThemeContent,
meta?: InstallThemeMeta,
): Promise<void> {
console.debug("[ThemeManager] Installing theme:", themeData.name);
try {
// Validate required fields
@@ -536,6 +603,9 @@ export class ThemeManager {
throw new Error("Theme is missing required fields (id or name)");
}
const fromStore = meta?.fromStore ?? false;
const serverUpdatedAtSec = meta?.serverUpdatedAtSec;
// Handle cover image (optional)
let coverImageBlob = null;
if (themeData.coverImage) {
@@ -570,7 +640,6 @@ export class ThemeManager {
})
.filter((img) => img !== null) ?? [];
// Create theme with defaults for optional fields
const theme: LoadedCustomTheme = {
id: themeData.id,
name: themeData.name,
@@ -585,6 +654,19 @@ export class ThemeManager {
isEditable: false,
hideThemeName: themeData.hideThemeName ?? false,
forceDark: themeData.forceDark,
installedFromStore: fromStore,
userEdited: fromStore ? false : undefined,
storeSyncedAtSec:
fromStore && serverUpdatedAtSec != null
? serverUpdatedAtSec
: undefined,
forceTheme:
themeData.forceTheme !== undefined
? themeData.forceTheme
: themeData.forceDark !== undefined
? true
: undefined,
adaptiveCssVariables: themeData.adaptiveCssVariables,
};
await this.saveTheme(theme);
@@ -594,6 +676,107 @@ export class ThemeManager {
}
}
/**
* Compare installed store themes to GET /api/themes and refresh when the server is newer.
* Skips themes with userEdited: true (theme creator / popup save, or custom accent vs default).
*/
public async checkStoreThemeUpdates(): Promise<void> {
if (this.storeUpdateCheckRunning) return;
this.storeUpdateCheckRunning = true;
try {
const token = await cloudAuth.getStoredToken();
const res = (await browser.runtime.sendMessage({
type: "fetchThemes",
token: token ?? undefined,
})) as {
success?: boolean;
data?: { themes?: Array<{ id: string; updated_at?: number }> };
};
if (!res?.success || !res.data?.themes?.length) return;
const serverById = new Map<string, number>();
for (const t of res.data.themes) {
if (
typeof t.id === "string" &&
typeof t.updated_at === "number"
) {
serverById.set(t.id, t.updated_at);
}
}
if (serverById.size === 0) return;
const themeIds = (await localforage.getItem("customThemes")) as
| string[]
| null;
if (!themeIds?.length) return;
for (const id of themeIds) {
const theme = (await localforage.getItem(id)) as CustomTheme | null;
if (!theme || theme.userEdited === true) {
continue;
}
// File imports explicitly set installedFromStore: false — never auto-sync.
if (theme.installedFromStore === false) {
continue;
}
const serverUpdated = serverById.get(id);
if (serverUpdated == null) {
continue;
}
if (
theme.installedFromStore === true &&
theme.storeSyncedAtSec != null
) {
if (serverUpdated <= theme.storeSyncedAtSec) {
continue;
}
}
// Else: legacy installs from before store metadata (installedFromStore/storeSyncedAtSec
// unset) or incomplete rows — still listed on the server, so sync to latest once.
const wasSelected = settingsState.selectedTheme === id;
try {
if (wasSelected) {
await this.disableTheme();
}
await this.downloadAndInstallStoreTheme({
id: theme.id,
name: theme.name,
updated_at: serverUpdated,
});
console.log(
"[ThemeManager] Theme auto-updated from store:",
theme.name,
);
if (wasSelected) {
await this.setTheme(id, false);
}
themeUpdates.triggerUpdate();
} catch (err) {
console.error("[ThemeManager] Store theme auto-update failed:", id, err);
if (wasSelected) {
try {
await this.setTheme(id, false);
} catch (restoreErr) {
console.error(
"[ThemeManager] Failed to restore theme after update error:",
restoreErr,
);
}
}
}
}
} catch (e) {
console.error("[ThemeManager] checkStoreThemeUpdates error:", e);
} finally {
this.storeUpdateCheckRunning = false;
}
}
/**
* Share a theme by exporting it
*/
@@ -614,6 +797,9 @@ export class ThemeManager {
isEditable,
selectedColor,
allowBackgrounds,
installedFromStore,
storeSyncedAtSec,
userEdited,
...themeBasics
} = theme;
@@ -651,7 +837,7 @@ export class ThemeManager {
public async previewTheme(theme: LoadedCustomTheme): Promise<void> {
console.debug("[ThemeManager] Previewing theme:", theme.name);
try {
const { CustomCSS, CustomImages, defaultColour, forceDark } = theme;
const { CustomCSS, CustomImages, defaultColour } = theme;
// Store original settings only if this is a new theme
if (!theme.webURL) {
@@ -697,13 +883,16 @@ export class ThemeManager {
this.previousImageVariableNames = newImageVariableNames;
// Apply theme settings
if (forceDark !== undefined) {
settingsState.DarkMode = forceDark;
if (shouldForceThemeAppearance(theme)) {
settingsState.DarkMode = getForcedDarkMode(theme);
}
if (defaultColour) {
settingsState.selectedColor = defaultColour;
}
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
void updateAllColors();
} catch (error) {
console.error("[ThemeManager] Error previewing theme:", error);
}
@@ -769,15 +958,18 @@ export class ThemeManager {
this.previousImageVariableNames = newImageVariableNames;
}
// Always apply dark mode setting
if (theme.forceDark !== undefined) {
settingsState.DarkMode = theme.forceDark;
// Always apply dark mode setting when theme forces appearance
if (shouldForceThemeAppearance(theme as CustomTheme)) {
settingsState.DarkMode = getForcedDarkMode(theme as CustomTheme);
}
// Only apply color if this is a new theme
if (!theme.webURL && theme.defaultColour) {
settingsState.selectedColor = theme.defaultColour;
}
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
void updateAllColors();
} catch (error) {
console.error("[ThemeManager] Error updating theme preview:", error);
}
@@ -815,6 +1007,8 @@ export class ThemeManager {
this.previewStyleElement = null;
}
clearCustomThemeAdaptiveCssVariables();
// Restore original settings
const storedColor = localStorage.getItem("originalPreviewColor");
@@ -844,6 +1038,8 @@ export class ThemeManager {
settingsState.DarkMode = this.originalPreviewTheme;
this.originalPreviewTheme = null;
}
void updateAllColors();
} catch (error) {
console.error("[ThemeManager] Error clearing preview:", error);
}
+6 -1
View File
@@ -63,7 +63,12 @@ function resetTimetableStyles(): void {
}
async function handleTimetable(): Promise<void> {
await waitForElm(".time", true, 10);
// SEQTA uses `.times` blocks on entries, not necessarily `.time`; avoid infinite polling on a missing selector.
try {
await waitForElm(".timetablepage .times, .timetablepage .entry.class", true, 50, 200);
} catch {
/* timetable body may render after the shell */
}
// Convert time format if needed
if (settingsState.timeFormat == "12") {
+13 -3
View File
@@ -271,7 +271,9 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
};
const syncQuickbarFromDOM = () => {
const quickbar = document.querySelector(".timetablepage .quickbar.visible");
const quickbar = document.querySelector(
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible",
);
if (quickbar && quickbar.getAttribute("data-type") === "class") {
const titleEl = quickbar.querySelector(".title");
const roomEl = quickbar.querySelector(".meta .room");
@@ -287,7 +289,9 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
if (!timetablePage || quickbarObserver) return;
quickbarObserver = new MutationObserver(() => {
const quickbar = document.querySelector(".timetablepage .quickbar.visible");
const quickbar = document.querySelector(
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible",
);
if (quickbar?.getAttribute("data-type") === "class") {
addEditButtonToQuickbar(quickbar as HTMLElement);
}
@@ -302,7 +306,13 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
};
const handleTimetable = async () => {
await waitForElm(".timetablepage .entry", true, 10, 100);
// Class entries (`div.entry.class`) load after the page shell; don't fail the whole
// setup if they are slow or briefly absent (e.g. navigation). Observers still catch them.
try {
await waitForElm(".timetablepage .entry.class", true, 50, 300);
} catch {
/* entries may appear later */
}
processAllEntries();
setupQuickbarObserver();
syncQuickbarFromDOM();
+164 -27
View File
@@ -17,14 +17,19 @@ import { StorageChangeHandler } from "@/seqta/utils/listeners/StorageChanges";
import { eventManager } from "@/seqta/utils/listeners/EventManager";
// UI and theme management
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import RegisterClickListeners from "@/seqta/utils/listeners/ClickListeners";
import { AddBetterSEQTAElements } from "@/seqta/ui/AddBetterSEQTAElements";
import { updateAllColors } from "@/seqta/ui/colors/Manager";
import loading from "@/seqta/ui/Loading";
import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
import { getEngageRoutePage } from "@/seqta/utils/engageRoute";
import {
loadEngageHomePage,
updateEngageHomeMenuActive,
} from "@/seqta/utils/Loaders/LoadEngageHomePage";
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup";
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification";
import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
@@ -82,7 +87,13 @@ export function hideSideBar() {
}
}
let betterSeqtaFinishLoadDone = false;
let engageHashListenerAttached = false;
export async function finishLoad() {
if (betterSeqtaFinishLoadDone) return;
betterSeqtaFinishLoadDone = true;
try {
document.querySelector(".legacy-root")?.classList.remove("hidden");
@@ -94,14 +105,7 @@ export async function finishLoad() {
console.error("Error during loading cleanup:", err);
}
// Check and show privacy statement notification (before what's new)
if (!document.getElementById("privacy-notification")) {
await showPrivacyNotification();
}
if (settingsState.justupdated && !document.getElementById("whatsnewbk") && !document.getElementById("privacy-notification")) {
OpenWhatsNewPopup();
}
runStartupPopupQueue();
}
export function GetCSSElement(file: string) {
@@ -115,19 +119,19 @@ export function GetCSSElement(file: string) {
}
function removeThemeTagsFromNotices() {
// Grabs an array of the notice iFrames
const userHTMLArray = document.getElementsByClassName("userHTML");
// Iterates through the array, applying the iFrame css
for (const item of userHTMLArray) {
// Grabs the HTML of the body tag
const item1 = item as HTMLIFrameElement;
const body = item1.contentWindow!.document.querySelectorAll("body")[0];
if (body) {
// Replaces the theme tag with nothing
const iframe = item as HTMLIFrameElement;
try {
const doc = iframe.contentDocument;
if (!doc?.body) continue;
const body = doc.body;
const bodyText = body.innerHTML;
body.innerHTML = bodyText
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/ +/, " ");
} catch {
// Cross-origin or otherwise inaccessible iframe (common during Engage load / filter frames)
}
}
}
@@ -202,7 +206,20 @@ function SortMessagePageItems(messagesParentElement: any) {
async function LoadPageElements(): Promise<void> {
await AddBetterSEQTAElements();
const sublink: string | undefined = window.location.href.split("/")[4];
const sublink: string | undefined = isSeqtaEngageExperience()
? getEngageRoutePage()
: window.location.href.split("/")[4];
if (isSeqtaEngageExperience() && !engageHashListenerAttached) {
engageHashListenerAttached = true;
window.addEventListener("hashchange", () => {
if (getEngageRoutePage() === "home") {
void loadEngageHomePage();
} else {
updateEngageHomeMenuActive(false);
}
});
}
eventManager.register(
"messagesAdded",
@@ -296,6 +313,28 @@ async function handleNotices(node: Element): Promise<void> {
}
async function handleSublink(sublink: string | undefined): Promise<void> {
if (isSeqtaEngageExperience()) {
switch (sublink) {
case undefined:
window.location.replace(
`${location.origin}/#?page=/${settingsState.defaultPage}`,
);
if (settingsState.defaultPage === "home") void loadEngageHomePage();
finishLoad();
break;
case "home":
window.location.replace(`${location.origin}/#?page=/home`);
console.info("[BetterSEQTA+] Started Init (SEQTA Engage home)");
if (settingsState.onoff) void loadEngageHomePage();
finishLoad();
break;
default:
finishLoad();
break;
}
return;
}
switch (sublink) {
case "news":
await handleNewsPage();
@@ -382,8 +421,11 @@ async function handleDashboard(node: Element): Promise<void> {
document.head.append(style);
await waitForElm(".dashlet", true, 10);
try {
const children = document.querySelectorAll(".dashboard > *");
if (children.length) {
animate(
".dashboard > *",
children,
{ opacity: [0, 1], y: [10, 0] },
{
delay: stagger(0.1),
@@ -391,6 +433,10 @@ async function handleDashboard(node: Element): Promise<void> {
ease: [0.22, 0.03, 0.26, 1],
},
);
}
} catch {
// Avoid uncaught errors if motion hits an unexpected DOM state during load.
}
document.head.querySelector("style.dashboardHider")?.remove();
}
@@ -400,8 +446,11 @@ async function handleDocuments(node: Element): Promise<void> {
if (!settingsState.animations) return;
await waitForElm(".document", true, 10);
try {
const rows = document.querySelectorAll(".documents tbody tr.document");
if (rows.length) {
animate(
".documents tbody tr.document",
rows,
{ opacity: [0, 1], y: [10, 0] },
{
delay: stagger(0.05),
@@ -409,6 +458,10 @@ async function handleDocuments(node: Element): Promise<void> {
ease: [0.22, 0.03, 0.26, 1],
},
);
}
} catch {
// ignore
}
}
async function handleReports(node: Element): Promise<void> {
@@ -416,8 +469,11 @@ async function handleReports(node: Element): Promise<void> {
if (!settingsState.animations) return;
await waitForElm(".report", true, 10);
try {
const items = document.querySelectorAll(".reports .item");
if (items.length) {
animate(
".reports .item",
items,
{ opacity: [0, 1], y: [10, 0] },
{
delay: stagger(0.05, { startDelay: 0.2 }),
@@ -425,6 +481,10 @@ async function handleReports(node: Element): Promise<void> {
ease: [0.22, 0.03, 0.26, 1],
},
);
}
} catch {
// ignore
}
}
function CheckNoticeTextColour(notice: any) {
@@ -448,7 +508,86 @@ function CheckNoticeTextColour(notice: any) {
);
}
function watchForEngageLogin() {
if (!document.querySelector(".login")) {
return;
}
const observer = new MutationObserver(() => {
if (!document.querySelector(".login")) {
observer.disconnect();
location.reload();
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
/** Wait until Engage shows either the login shell or the main app (`#content`), so we never call `LoadPageElements` while still on login (which would hang on `waitForElm("#content")`). */
function waitForEngageLoginOrContent(): Promise<"login" | "app" | "timeout"> {
if (document.querySelector(".login")) {
return Promise.resolve("login");
}
if (document.getElementById("content")) {
return Promise.resolve("app");
}
return new Promise((resolve) => {
let settled = false;
const finish = (mode: "login" | "app") => {
if (settled) return;
settled = true;
mo.disconnect();
window.clearTimeout(tid);
resolve(mode);
};
const check = () => {
if (document.querySelector(".login")) finish("login");
else if (document.getElementById("content")) finish("app");
};
const mo = new MutationObserver(check);
mo.observe(document.documentElement, { subtree: true, childList: true });
const tid = window.setTimeout(() => {
if (settled) return;
mo.disconnect();
settled = true;
if (document.querySelector(".login")) resolve("login");
else if (document.getElementById("content")) resolve("app");
else {
console.warn(
"[BetterSEQTA+] Engage: timed out waiting for .login or #content; unblocking load UI.",
);
resolve("timeout");
}
}, 120_000);
});
}
export function tryLoad() {
if (isSeqtaEngageExperience()) {
updateIframesWithDarkMode();
window.addEventListener("load", () => removeThemeTagsFromNotices(), { once: true });
const runEngageLoad = async () => {
const mode = await waitForEngageLoginOrContent();
if (mode === "login") {
finishLoad();
watchForEngageLogin();
return;
}
if (mode === "timeout") {
finishLoad();
void waitForElm("#content").then(() => void LoadPageElements());
return;
}
await LoadPageElements();
};
if (document.readyState === "complete") {
void runEngageLoad();
} else {
window.addEventListener("load", () => void runEngageLoad(), { once: true });
}
return;
}
waitForElm(".login").then(() => {
finishLoad();
});
@@ -466,13 +605,10 @@ export function tryLoad() {
});
updateIframesWithDarkMode();
// Waits for page to call on load, run scripts
document.addEventListener(
window.addEventListener(
"load",
function () {
removeThemeTagsFromNotices();
},
true,
() => removeThemeTagsFromNotices(),
{ once: true },
);
}
@@ -489,6 +625,7 @@ function ReplaceMenuSVG(element: HTMLElement, svg: string) {
const processedSymbol = Symbol("processed");
export async function ObserveMenuItemPosition() {
if (isSeqtaEngageExperience()) return;
await waitForElm("#menu > ul > li");
eventManager.register(
Binary file not shown.

After

Width:  |  Height:  |  Size: 653 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+169 -4
View File
@@ -1,7 +1,11 @@
import { addExtensionSettings } from "@/seqta/utils/Adders/AddExtensionSettings";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { loadEngageHomePage } from "@/seqta/utils/Loaders/LoadEngageHomePage";
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
import { attachNotificationsPanelAnimation } from "@/seqta/utils/attachNotificationsPanelAnimation";
import { setupSettingsButton } from "@/seqta/utils/setupSettingsButton";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
import { appendBackgroundToUI } from "./ImageBackgrounds";
@@ -42,6 +46,17 @@ export async function getUserInfo() {
}
export async function AddBetterSEQTAElements() {
if (isSeqtaEngageExperience()) {
await waitForElm("#content");
addExtensionSettings();
if (settingsState.onoff) {
await injectEngageHomeButton();
}
void setupEngageSettingsButton();
void addEngageUserInfo();
return;
}
if (settingsState.onoff) {
if (settingsState.DarkMode) {
document.documentElement.classList.add("dark");
@@ -75,6 +90,7 @@ export async function AddBetterSEQTAElements() {
addExtensionSettings();
await createSettingsButton();
setupSettingsButton();
attachNotificationsPanelAnimation();
}
function createHomeButton(fragment: DocumentFragment, _: HTMLElement) {
@@ -257,8 +273,9 @@ function setupEventListeners() {
});
}
async function createSettingsButton() {
document.getElementById("content")!.append(
async function createSettingsButton(parent?: Element) {
const target = parent ?? document.getElementById("content")!;
target.append(
stringToHTML(/* html */ `
<button class="addedButton tooltip" id="AddedSettings">
<svg width="24" height="24" viewBox="0 0 24 24">
@@ -270,17 +287,165 @@ async function createSettingsButton() {
);
}
/** Engage mounts the sidebar inside batched React trees; EventManager-based waitForElm can miss `#menu`. Polling `waitForElm` matches the real DOM reliably. */
async function waitForEngageMenuList(): Promise<HTMLElement | null> {
const poll = true as const;
const interval = 100;
const trySelectors: { selector: string; maxIterations: number }[] = [
{ selector: "#menu > ul > li", maxIterations: 500 },
{ selector: "#menu ul", maxIterations: 350 },
{ selector: "#menu", maxIterations: 350 },
];
for (const { selector, maxIterations } of trySelectors) {
try {
await waitForElm(selector, poll, interval, maxIterations);
} catch {
continue;
}
if (selector === "#menu > ul > li") {
const ul = document.querySelector("#menu > ul") as HTMLElement | null;
if (ul) return ul;
} else if (selector === "#menu ul") {
const ul = document.querySelector("#menu ul") as HTMLElement | null;
if (ul) return ul;
} else {
const menu = document.getElementById("menu");
const ul =
(menu?.querySelector("ul") as HTMLElement | null) ??
(menu?.firstElementChild as HTMLElement | null);
if (ul) return ul;
}
}
console.warn(
"[BetterSEQTA+] Engage: could not find a menu list to inject the home button",
);
return null;
}
async function injectEngageHomeButton() {
if (document.getElementById("homebutton")) return;
const menuList = await waitForEngageMenuList();
if (!menuList || document.getElementById("homebutton")) return;
const li = stringToHTML(
/* html */ `<li class="item" data-key="home" id="homebutton" data-path="/home" data-betterseqta="true"><label><svg style="width:24px;height:24px" viewBox="0 0 24 24"><path fill="currentColor" d="M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z" /></svg><span>Home</span></label></li>`,
).firstChild as HTMLElement;
menuList.insertBefore(li, menuList.firstElementChild);
document.getElementById("homebutton")?.addEventListener("click", () => {
const btn = document.getElementById("homebutton") as HTMLElement;
if (
btn.classList.contains("draggable") ||
btn.classList.contains("active")
) {
return;
}
window.location.replace(`${location.origin}/#?page=/home`);
void loadEngageHomePage();
});
}
async function getEngageUserInfo() {
const response = await fetch(`${location.origin}/seqta/parent/login`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({
mode: "normal",
query: null,
redirect_url: location.origin + "/",
}),
});
return (await response.json()).payload as {
userDesc: string | null;
userName: string | null;
userCode: string | null;
email: string | null;
type: string | null;
};
}
async function addEngageUserInfo() {
const content = await waitForElm("#content") as HTMLElement;
let info: Awaited<ReturnType<typeof getEngageUserInfo>>;
try {
info = await getEngageUserInfo();
} catch (error) {
console.error("[BetterSEQTA+] Failed to get Engage user info:", error);
return;
}
const displayName = info.userDesc || info.userName || "";
const subText = info.userCode || info.email || "";
const titlebar = document.createElement("div");
titlebar.classList.add("titlebar", "engage-titlebar");
titlebar.append(
stringToHTML(/* html */ `
<div class="userInfo">
<div class="userInfoText">
${displayName ? `<p class="userInfoName">${displayName}</p>` : ""}
${subText ? `<p class="userInfoCode">${subText}</p>` : ""}
</div>
</div>
`).firstChild!,
);
const iconNode = stringToHTML(/* html */ `
<div class="userInfosvgdiv tooltip" id="engage-logouttooltip-wrap">
<svg class="userInfosvg" viewBox="0 0 24 24"><path fill="var(--text-primary)" d="M12,19.2C9.5,19.2 7.29,17.92 6,16C6.03,14 10,12.9 12,12.9C14,12.9 17.97,14 18,16C16.71,17.92 14.5,19.2 12,19.2M12,5A3,3 0 0,1 15,8A3,3 0 0,1 12,11A3,3 0 0,1 9,8A3,3 0 0,1 12,5M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2Z"></path></svg>
<div class="tooltiptext topmenutooltip" id="engage-logouttooltip">
<button class="logout engage-logout">
<svg style="width:20px;height:20px;vertical-align:middle;" viewBox="0 0 24 24"><path fill="currentColor" d="M17 7L15.59 8.41L18.17 11H8V13H18.17L15.59 15.58L17 17L22 12M4 5H12V3H4C2.9 3 2 3.9 2 5V19C2 20.1 2.9 21 4 21H12V19H4V5Z"/></svg>
</button>
</div>
</div>
`).firstChild!;
titlebar.append(iconNode);
content.appendChild(titlebar);
titlebar.querySelector<HTMLElement>(".engage-logout")?.addEventListener("click", async () => {
await fetch(`${location.origin}/seqta/parent/logout`, { method: "POST" });
location.reload();
});
}
async function setupEngageSettingsButton() {
try {
const notificationsWrapper = await waitForElm(
"#content > div.connectedNotificationsWrapper > div",
);
const parent = notificationsWrapper.parentElement!;
await addDarkLightToggle(parent);
await createSettingsButton(parent);
setupSettingsButton();
attachNotificationsPanelAnimation();
} catch {
await addDarkLightToggle();
await createSettingsButton();
setupSettingsButton();
attachNotificationsPanelAnimation();
}
}
function GetLightDarkModeString() {
return settingsState.DarkMode
? "Switch to light theme"
: "Switch to dark theme";
}
async function addDarkLightToggle() {
async function addDarkLightToggle(parent?: Element) {
const SUN_ICON_SVG = /* html */ `<defs><clipPath id="__lottie_element_80"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_80)"><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -4,-2.2100000381469727 -4,0 C-4,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>`;
const MOON_ICON_SVG = /* html */ `<defs><clipPath id="__lottie_element_263"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_263)"><g style="display: block;" transform="matrix(1.5,0,0,1.5,7,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -1.2920000553131104,-2.2100000381469727 -1.2920000553131104,0 C-1.2920000553131104,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(-1,0,0,-1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>`;
document.getElementById("content")!.append(
const toggleTarget = parent ?? document.getElementById("content")!;
toggleTarget.append(
stringToHTML(/* html */ `
<button class="addedButton DarkLightButton tooltip" id="LightDarkModeButton">
<svg xmlns="http://www.w3.org/2000/svg">${settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG}</svg>
+2 -5
View File
@@ -18,12 +18,9 @@ export class SettingsResizer {
if (!iframePopup) return;
const viewportHeight = window.innerHeight;
const idealHeight = viewportHeight - 80 - 15; // -80px for the top of the popup
const rawIdeal = viewportHeight - 80 - 15; // room below top chrome
const idealHeight = Math.min(Math.max(rawIdeal, 280), 600);
if (idealHeight > 600) {
iframePopup.style.height = "600px";
} else {
iframePopup.style.height = `${idealHeight}px`;
}
}
}
+133 -2
View File
@@ -5,16 +5,85 @@ import { lightenAndPaleColor } from "./lightenAndPaleColor";
import ColorLuminance from "./ColorLuminance";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour";
import { getCustomThemeAdaptiveCssVariables } from "@/seqta/ui/colors/customThemeAdaptiveBindings";
import darkLogo from "@/resources/icons/betterseqta-light-full.png";
import lightLogo from "@/resources/icons/betterseqta-dark-full.png";
const ADAPTIVE_THEME_TRANSITION_MS = 400;
let colorTransitionRafId: number | null = null;
let lastInterpolatedHex: string | null = null;
// Helper functions
const setCSSVar = (varName: any, value: any) =>
document.documentElement.style.setProperty(varName, value);
const applyProperties = (props: any) =>
Object.entries(props).forEach(([key, value]) => setCSSVar(key, value));
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
/** Best-effort parse of a single sRGB hex from a colour string (hex, rgb, or gradient). */
function parseRepresentativeHex(s: string): string | null {
if (!s || !s.trim()) return null;
const trimmed = s.trim();
try {
return Color(trimmed).hex();
} catch {
// continue
}
if (trimmed.includes("gradient")) {
const regex =
/#[0-9a-fA-F]{6}|rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)|rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)/gi;
const stops = trimmed.match(regex);
if (stops?.length) {
try {
return Color(stops[0]).hex();
} catch {
// continue
}
}
}
const hexMatch = trimmed.match(/#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})\b/);
if (hexMatch) {
try {
return Color(hexMatch[0]).hex();
} catch {
// continue
}
}
const rgbaMatch = trimmed.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
if (rgbaMatch) {
try {
return Color.rgb(
Number(rgbaMatch[1]),
Number(rgbaMatch[2]),
Number(rgbaMatch[3]),
).hex();
} catch {
// continue
}
}
return null;
}
function getFromHex(): string | null {
const fromComputed = parseRepresentativeHex(
getComputedStyle(document.documentElement).getPropertyValue("--better-main").trim(),
);
if (fromComputed) return fromComputed;
return lastInterpolatedHex;
}
function cancelColorTransition() {
if (colorTransitionRafId !== null) {
cancelAnimationFrame(colorTransitionRafId);
colorTransitionRafId = null;
}
}
function applyColorsWith(selectedColor: string) {
if (settingsState.transparencyEffects) {
document.documentElement.classList.add("transparencyEffects");
@@ -59,6 +128,12 @@ function applyColorsWith(selectedColor: string) {
// Apply all the properties
applyProperties({ ...commonProps, ...modeProps, ...dynamicProps });
if (settingsState.selectedTheme) {
for (const name of getCustomThemeAdaptiveCssVariables()) {
setCSSVar(name, selectedColor);
}
}
let alliframes = document.getElementsByTagName("iframe");
for (let i = 0; i < alliframes.length; i++) {
@@ -89,15 +164,71 @@ export async function updateAllColors() {
? settingsState.selectedColor
: "#007bff";
let adaptiveHex: string | null = null;
if (settingsState.adaptiveThemeColour) {
const adaptiveColor = await getAdaptiveColour();
if (adaptiveColor) {
effectiveColor =
settingsState.adaptiveThemeGradient
adaptiveHex = adaptiveColor;
effectiveColor = settingsState.adaptiveThemeGradient
? toSoftGradient(adaptiveColor)
: adaptiveColor;
}
}
const baseSelected =
settingsState.selectedColor !== "" ? settingsState.selectedColor : "#007bff";
const toHex =
adaptiveHex ?? parseRepresentativeHex(baseSelected);
const shouldAnimate =
settingsState.adaptiveThemeColour &&
(settingsState.adaptiveThemeColourTransition ?? true) &&
!!toHex;
const applyImmediate = () => {
cancelColorTransition();
applyColorsWith(effectiveColor);
if (toHex) lastInterpolatedHex = toHex;
};
if (!shouldAnimate) {
applyImmediate();
return;
}
const fromHex = getFromHex();
if (!fromHex || !toHex || fromHex === toHex) {
applyImmediate();
return;
}
const useSoftGradientOnFrames =
!!adaptiveHex && !!settingsState.adaptiveThemeGradient;
cancelColorTransition();
const start = performance.now();
const step = (now: number) => {
const elapsed = now - start;
const t = Math.min(1, elapsed / ADAPTIVE_THEME_TRANSITION_MS);
const eased = easeInOutCubic(t);
const interpolatedHex = Color(fromHex).mix(Color(toHex), eased).hex();
const display = useSoftGradientOnFrames
? toSoftGradient(interpolatedHex)
: interpolatedHex;
applyColorsWith(display);
if (t < 1) {
colorTransitionRafId = requestAnimationFrame(step);
} else {
colorTransitionRafId = null;
applyColorsWith(effectiveColor);
lastInterpolatedHex = toHex;
}
};
colorTransitionRafId = requestAnimationFrame(step);
}
@@ -0,0 +1,40 @@
/** Tracks which author-declared CSS variables mirror the effective accent; not persisted in settings storage. */
const VALID_CUSTOM_PROP = /^--[a-zA-Z0-9_-]{1,120}$/;
let boundNames: string[] = [];
export function normalizeAdaptiveCssVariableNames(
names: string[] | undefined,
): string[] {
if (!names?.length) return [];
const out: string[] = [];
const seen = new Set<string>();
for (const raw of names) {
const s = raw.trim();
if (!VALID_CUSTOM_PROP.test(s) || seen.has(s)) continue;
seen.add(s);
out.push(s);
}
return out;
}
export function setCustomThemeAdaptiveCssVariables(
names: string[] | undefined,
): void {
for (const n of boundNames) {
document.documentElement.style.removeProperty(n);
}
boundNames = normalizeAdaptiveCssVariableNames(names);
}
export function getCustomThemeAdaptiveCssVariables(): string[] {
return boundNames;
}
export function clearCustomThemeAdaptiveCssVariables(): void {
for (const n of boundNames) {
document.documentElement.style.removeProperty(n);
}
boundNames = [];
}
+50 -21
View File
@@ -3,6 +3,8 @@ interface ElementConfig {
action: (element: Element) => void;
/** When true, element is not added to processedElements so the action runs every time (e.g. overwriting container content) */
alwaysRun?: boolean;
/** When true, never add to processedElements so the action can run again after DOM resets (e.g. home day column) */
neverMarkProcessed?: boolean;
}
interface ContentConfig {
@@ -12,6 +14,12 @@ interface ContentConfig {
// Track processed elements to avoid re-randomizing
const processedElements = new WeakSet<Element>();
/** Marks mock-generated `.day` rows so granular rules do not re-randomize them */
const MOCK_DAY_ATTR = "data-bsp-mock-day";
/** Skip MutationObserver-driven reprocessing while we inject the home mock (avoids feedback loops) */
let suppressMockMutations = false;
function debounce(func: Function, wait: number): Function {
let timeout: NodeJS.Timeout;
return function executedFunction(...args: any[]) {
@@ -44,19 +52,19 @@ function getRandomDate(): Date {
const contentConfig: ContentConfig = {
lessonTitle: {
selector: ".day h2",
selector: `.day:not([${MOCK_DAY_ATTR}]) h2`,
action: (element) => {
element.textContent = getRandomElement(mockData.subjects);
},
},
teacher: {
selector: ".day h3:first-of-type",
selector: `.day:not([${MOCK_DAY_ATTR}]) h3:first-of-type`,
action: (element) => {
element.textContent = getRandomElement(mockData.teachers);
},
},
classroom: {
selector: ".day h3:last-of-type",
selector: `.day:not([${MOCK_DAY_ATTR}]) h3:last-of-type`,
action: (element) => {
element.textContent = getRandomElement(mockData.classrooms);
},
@@ -283,13 +291,28 @@ const contentConfig: ContentConfig = {
// Home page: replace entire day with mock schedule (care + 7 lessons 8:553:15)
homeDayContainer: {
selector: "#day-container",
alwaysRun: true,
neverMarkProcessed: true,
action: (element) => {
const container = element as HTMLElement;
if (!container.closest(".timetable-container")) return; // only on home
if (container.classList.contains("loading") || container.innerHTML.trim() === "") {
delete container.dataset.bspMockSchedule;
return;
}
if (
container.dataset.bspMockSchedule === "1" &&
container.querySelector(`[${MOCK_DAY_ATTR}]`)
) {
return;
}
suppressMockMutations = true;
const schedule = getMockDaySchedule();
container.innerHTML = schedule;
container.classList.remove("loading");
container.dataset.bspMockSchedule = "1";
requestAnimationFrame(() => {
suppressMockMutations = false;
});
},
},
};
@@ -665,7 +688,7 @@ function getMockDaySchedule(): string {
return blocks
.map(
(b, i) =>
`<div class="day" style="--item-colour: ${colours[i % colours.length]};">
`<div class="day" ${MOCK_DAY_ATTR} style="--item-colour: ${colours[i % colours.length]};">
<h2>${b.title}</h2>
<h3>${b.teacher}</h3>
<h3>${b.room}</h3>
@@ -758,12 +781,12 @@ const debouncedProcessElements = debounce(processNewElements, 1);
function processNewElements() {
Object.entries(contentConfig).forEach(([_, config]) => {
const { selector, action, alwaysRun } = config;
const { selector, action, alwaysRun, neverMarkProcessed } = config;
const elements = document.querySelectorAll(selector);
elements.forEach((element: Element) => {
if (alwaysRun || !processedElements.has(element)) {
if (alwaysRun || neverMarkProcessed || !processedElements.has(element)) {
action(element);
if (!alwaysRun) {
if (!alwaysRun && !neverMarkProcessed) {
processedElements.add(element);
}
}
@@ -772,7 +795,6 @@ function processNewElements() {
}
let observer: MutationObserver | null = null;
let intervalId: NodeJS.Timeout | null = null;
export default function hideSensitiveContent() {
// Initial processing of existing elements
@@ -781,6 +803,8 @@ export default function hideSensitiveContent() {
// Set up MutationObserver if not already created
if (!observer) {
observer = new MutationObserver((mutations) => {
if (suppressMockMutations) return;
let shouldProcess = false;
mutations.forEach((mutation) => {
@@ -802,9 +826,25 @@ export default function hideSensitiveContent() {
});
}
// Also trigger on large DOM replacements (like page navigation)
// Large DOM replacements (e.g. page navigation). Skip only when #day-container gains many *mock* rows (our inject).
if (mutation.addedNodes.length > 5 || mutation.removedNodes.length > 5) {
const target = mutation.target as Element;
if (target.id === "day-container") {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as Element;
if (
el.classList?.contains("day") &&
!el.hasAttribute(MOCK_DAY_ATTR)
) {
shouldProcess = true;
break;
}
}
}
} else {
shouldProcess = true;
}
}
}
@@ -833,13 +873,6 @@ export default function hideSensitiveContent() {
attributeFilter: ['class', 'id'] // Watch for class/id changes that might affect our selectors
});
}
// Fallback: periodic check for new elements (especially useful for SPA navigation)
if (!intervalId) {
intervalId = setInterval(() => {
debouncedProcessElements();
}, 500); // Check every 500ms as a fallback
}
}
// Function to stop observing (useful for cleanup)
@@ -848,8 +881,4 @@ export function stopHidingSensitiveContent() {
observer.disconnect();
observer = null;
}
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
}
+20 -15
View File
@@ -9,21 +9,8 @@ import Settings from "@/interface/pages/settings.svelte";
let isSettingsRendered = false;
export function addExtensionSettings() {
const extensionPopup = document.createElement("div");
extensionPopup.classList.add("outside-container", "hide");
extensionPopup.id = "ExtensionPopup";
const extensionContainer = document.querySelector(
"#container",
) as HTMLDivElement;
if (extensionContainer) extensionContainer.appendChild(extensionPopup);
const container = document.getElementById("container");
new SettingsResizer();
container!.onclick = (event) => {
function extensionOutsideClickHandler(extensionPopup: HTMLElement) {
return (event: MouseEvent) => {
if (!SettingsClicked) return;
if (!(event.target as HTMLElement).closest("#AddedSettings")) {
@@ -33,6 +20,24 @@ export function addExtensionSettings() {
};
}
export function addExtensionSettings() {
if (document.getElementById("ExtensionPopup")) return;
const extensionPopup = document.createElement("div");
extensionPopup.classList.add("outside-container", "hide");
extensionPopup.id = "ExtensionPopup";
const extensionContainer =
document.querySelector("#container") ?? document.getElementById("container");
const mountParent = extensionContainer ?? document.body;
mountParent.appendChild(extensionPopup);
new SettingsResizer();
const handler = extensionOutsideClickHandler(extensionPopup);
(extensionContainer ?? document.body).addEventListener("click", handler, false);
}
export function renderSettingsIfNeeded() {
if (isSettingsRendered) return;
+20
View File
@@ -83,6 +83,25 @@ class CloudAuthService {
}
}
/** Pull cloud settings backup after a fresh sign-in (matches manual “Download from cloud”). */
private triggerCloudSettingsDownloadAfterLogin(accessToken: string): void {
void browser.runtime
.sendMessage({
type: "cloudSettingsDownload",
token: accessToken,
})
.then((res: unknown) => {
const r = res as { success?: boolean; notFound?: boolean; error?: string } | undefined;
if (r?.success || r?.notFound) return;
if (r?.error) {
console.warn("[BetterSEQTA+] Cloud settings download after login:", r.error);
}
})
.catch((err) => {
console.warn("[BetterSEQTA+] Cloud settings download after login failed:", err);
});
}
public async getStoredToken(): Promise<string | null> {
const result = await browser.storage.local.get(STORAGE_KEYS.accessToken);
return (result[STORAGE_KEYS.accessToken] as string) ?? null;
@@ -135,6 +154,7 @@ class CloudAuthService {
user: result.user ?? null,
};
this.notify();
this.triggerCloudSettingsDownloadAfterLogin(result.access_token);
return { success: true };
}
return {
@@ -0,0 +1,835 @@
import { animate } from "motion";
import browser from "webextension-polyfill";
import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat";
import debounce from "@/seqta/utils/debounce";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
import {
type EngageParentChild,
type EngageParentTimetableItem,
fetchEngageParentChildren,
fetchEngageParentTimetableWeek,
isDateInCachedWeek,
toISODate,
weekRangeContaining,
} from "@/seqta/utils/Loaders/engageParentTimetable";
export function updateEngageHomeMenuActive(isHome: boolean): void {
const home = document.getElementById("homebutton");
if (!home) return;
if (isHome) {
for (const el of document.querySelectorAll("#menu li.active")) {
if (el !== home) el.classList.remove("active");
}
home.classList.add("active");
} else {
home.classList.remove("active");
}
}
const STORAGE_KEY_STUDENT = () =>
`bsplus.engageTimetable.student.${location.origin}`;
let engageViewDate = new Date();
let engageWeekFrom = "";
let engageWeekUntil = "";
let engageWeekItems: EngageParentTimetableItem[] = [];
let engageSelectedStudentId: string | null = null;
let engageListenersCleanup: (() => void) | null = null;
function formatDateString(date: Date): string {
return `${date.toLocaleString("en-us", { weekday: "short" })} ${date.toLocaleDateString("en-au")}`;
}
function setEngageTimetableSubtitle(): void {
const el = document.getElementById("engage-home-lesson-subtitle");
if (!el) return;
const today = new Date();
const isSameMonth =
today.getFullYear() === engageViewDate.getFullYear() &&
today.getMonth() === engageViewDate.getMonth();
if (isSameMonth) {
const dayDiff = today.getDate() - engageViewDate.getDate();
switch (dayDiff) {
case 0:
el.textContent = "Today's Lessons";
break;
case 1:
el.textContent = "Yesterday's Lessons";
break;
case -1:
el.textContent = "Tomorrow's Lessons";
break;
default:
el.textContent = formatDateString(engageViewDate);
}
} else {
el.textContent = formatDateString(engageViewDate);
}
}
function makeEngageLessonDiv(
lesson: EngageParentTimetableItem,
index: number,
): HTMLElement {
let from = lesson.from?.substring(0, 5) ?? "";
let until = lesson.until?.substring(0, 5) ?? "";
if (settingsState.timeFormat === "12") {
from = convertTo12HourFormat(from);
until = convertTo12HourFormat(until);
}
const title =
lesson.type === "class"
? lesson.description
: lesson.type || "Lesson";
const div = document.createElement("div");
div.className = "day";
div.id = `engage-lesson-${lesson.code}-${index}`;
div.style.cssText = "--item-colour: #8e8e8e;";
const h2 = document.createElement("h2");
h2.textContent = title;
const hStaff = document.createElement("h3");
hStaff.textContent = lesson.staff?.trim() || "—";
const hRoom = document.createElement("h3");
hRoom.textContent = lesson.room?.trim() || "—";
const hTime = document.createElement("h4");
hTime.textContent = `${from} ${until}`;
const hPeriod = document.createElement("h5");
hPeriod.textContent = lesson.period?.trim() || "";
div.append(h2, hStaff, hRoom, hTime, hPeriod);
return div;
}
function renderEngageDayLessons(): void {
const dayContainer = document.getElementById("engage-day-container");
if (!dayContainer) return;
const dayStr = toISODate(engageViewDate);
const lessons = engageWeekItems
.filter((item) => item.date === dayStr)
.sort((a, b) => a.from.localeCompare(b.from));
dayContainer.innerHTML = "";
if (lessons.length === 0) {
dayContainer.innerHTML = `
<div class="day-empty">
<img src="${browser.runtime.getURL(LogoLight)}" alt="" />
<p>No lessons for this day.</p>
</div>`;
} else {
lessons.forEach((lesson, i) => {
dayContainer.appendChild(makeEngageLessonDiv(lesson, i));
});
}
dayContainer.classList.remove("loading");
setEngageTimetableSubtitle();
}
async function fetchWeekAndRender(): Promise<void> {
const dayContainer = document.getElementById("engage-day-container");
if (!dayContainer || !engageSelectedStudentId) return;
dayContainer.classList.add("loading");
dayContainer.innerHTML = "";
const { from, until } = weekRangeContaining(engageViewDate);
try {
engageWeekItems = await fetchEngageParentTimetableWeek(
from,
until,
engageSelectedStudentId,
);
engageWeekFrom = from;
engageWeekUntil = until;
} catch (e) {
console.error("[BetterSEQTA+] Engage parent timetable failed:", e);
engageWeekItems = [];
engageWeekFrom = from;
engageWeekUntil = until;
}
renderEngageDayLessons();
}
function shiftEngageDay(delta: number): void {
const next = new Date(engageViewDate);
next.setDate(next.getDate() + delta);
engageViewDate = next;
const dayContainer = document.getElementById("engage-day-container");
dayContainer?.classList.add("loading");
dayContainer && (dayContainer.innerHTML = "");
if (
engageWeekFrom &&
engageWeekUntil &&
isDateInCachedWeek(engageViewDate, engageWeekFrom, engageWeekUntil)
) {
renderEngageDayLessons();
} else {
void fetchWeekAndRender();
}
}
function populateChildSelector(
select: HTMLSelectElement,
children: EngageParentChild[],
): void {
select.innerHTML = "";
for (const c of children) {
const opt = document.createElement("option");
opt.value = c.id;
opt.textContent = c.name || `Student ${c.id}`;
select.appendChild(opt);
}
const stored = localStorage.getItem(STORAGE_KEY_STUDENT());
const validStored = stored && children.some((c) => c.id === stored);
engageSelectedStudentId = validStored
? stored!
: children[0]?.id ?? null;
if (engageSelectedStudentId) {
select.value = engageSelectedStudentId;
localStorage.setItem(STORAGE_KEY_STUDENT(), engageSelectedStudentId);
}
}
function bindEngageTimetableUi(): void {
engageListenersCleanup?.();
const cleanups: Array<() => void> = [];
const back = document.getElementById("engage-home-timetable-back");
const forward = document.getElementById("engage-home-timetable-forward");
const select = document.getElementById(
"engage-child-selector",
) as HTMLSelectElement | null;
const onBack = () => shiftEngageDay(-1);
const onForward = () => shiftEngageDay(1);
back?.addEventListener("click", onBack);
forward?.addEventListener("click", onForward);
cleanups.push(
() => back?.removeEventListener("click", onBack),
() => forward?.removeEventListener("click", onForward),
);
const onSelectChange = () => {
if (!select) return;
engageSelectedStudentId = select.value;
localStorage.setItem(STORAGE_KEY_STUDENT(), engageSelectedStudentId);
void fetchWeekAndRender();
};
select?.addEventListener("change", onSelectChange);
cleanups.push(() =>
select?.removeEventListener("change", onSelectChange),
);
engageListenersCleanup = () => {
cleanups.forEach((fn) => fn());
engageListenersCleanup = null;
};
}
/* ——— Notices (duplicated from Learn `LoadHomePage`; fetch uses `/seqta/parent/load/notices`.) ——— */
const ENGAGE_NOTICE_CONTAINER_ID = "engage-notice-container";
const ENGAGE_NOTICES_DATE_ID = "engage-notices-date";
function processEngageNoticeColor(colour: unknown): string | undefined {
if (typeof colour !== "string") return undefined;
const rgb = GetThresholdOfColor(colour);
if (rgb < 100 && settingsState.DarkMode) {
return undefined;
}
return colour;
}
function processEngageNotices(response: any, labelArray: string[]): void {
const noticeContainer = document.getElementById(ENGAGE_NOTICE_CONTAINER_ID);
if (!noticeContainer) return;
noticeContainer.innerHTML = "";
const notices = response?.payload;
if (!Array.isArray(notices)) {
const emptyState = document.createElement("div");
emptyState.classList.add("day-empty");
const img = document.createElement("img");
img.src = browser.runtime.getURL(LogoLight);
const text = document.createElement("p");
text.innerText = "No notices for today.";
emptyState.append(img, text);
noticeContainer.append(emptyState);
return;
}
if (!notices.length) {
const emptyState = document.createElement("div");
emptyState.classList.add("day-empty");
const img = document.createElement("img");
img.src = browser.runtime.getURL(LogoLight);
const text = document.createElement("p");
text.innerText = "No notices for today.";
emptyState.append(img, text);
noticeContainer.append(emptyState);
return;
}
const fragment = document.createDocumentFragment();
notices.forEach((notice: any) => {
const shouldInclude =
settingsState.mockNotices ||
labelArray.length === 0 ||
labelArray.includes(JSON.stringify(notice.label));
if (shouldInclude) {
const colour = processEngageNoticeColor(notice.colour);
const noticeElement = createEngageNoticeElement(notice, colour);
fragment.appendChild(noticeElement);
}
});
noticeContainer.appendChild(fragment);
}
function createEngageNoticeElement(
notice: any,
colour: string | undefined,
): Node {
const textPreview =
notice.contents
.replace(/<[^>]*>/g, "")
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/\s+/g, " ")
.trim()
.substring(0, 150) + (notice.contents.length > 150 ? "..." : "");
const noticeId = `notice-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const htmlContent = `
<div class="notice-unified-content notice-card-state" data-notice-id="${noticeId}" style="--colour: ${colour || "#8e8e8e"}; position: relative; background: var(--background-primary); cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);">
<div class="notice-header">
<div class="notice-badge-row">
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
${notice.label_title || "General"}
</span>
<span class="notice-staff">${notice.staff}</span>
</div>
<button class="notice-close-btn" style="opacity: 0; pointer-events: none;">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${textPreview}</div>
</div>`;
const element = stringToHTML(htmlContent).firstChild as HTMLElement;
element.addEventListener("click", () =>
openEngageNoticeModal(notice, colour, element),
);
return element;
}
function openEngageNoticeModal(
notice: any,
colour: string | undefined,
sourceElement: HTMLElement,
) {
const cleanContent = notice.contents
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/ +/, " ");
document.getElementById("notice-modal")?.remove();
const sourceRect = sourceElement.getBoundingClientRect();
let scrollY = Math.round(window.scrollY);
let scrollX = Math.round(window.scrollX);
let sourceLeft = sourceRect.left;
let sourceTop = sourceRect.top;
let sourceWidth = sourceRect.width;
let sourceHeight = sourceRect.height;
const modalHtml = `
<div id="notice-modal" class="notice-modal-overlay" style="opacity: 0;">
<div class="notice-modal-transition" style="
position: fixed;
left: ${sourceLeft + scrollX}px;
top: ${sourceTop + scrollY}px;
width: ${sourceWidth}px;
height: ${sourceHeight}px;
transform-origin: center;
z-index: 10001;
">
<div class="notice-modal-content notice-transitioning">
<div class="notice-unified-content notice-card-state">
<div class="notice-header">
<div class="notice-badge-row">
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
${notice.label_title || "General"}
</span>
<span class="notice-staff">${notice.staff}</span>
</div>
<button class="notice-close-btn">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${cleanContent}</div>
</div>
</div>
</div>
</div>`;
const modal = stringToHTML(modalHtml).firstChild as HTMLElement;
const transitionContainer = modal.querySelector(
".notice-modal-transition",
) as HTMLElement;
const unifiedContent = modal.querySelector(
".notice-unified-content",
) as HTMLElement;
const closeBtn = modal.querySelector(".notice-close-btn") as HTMLElement;
document.body.appendChild(modal);
sourceElement.setAttribute("data-transitioning", "true");
sourceElement.style.opacity = "0";
sourceElement.style.transform = "scale(0.95)";
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let targetWidth = Math.round(
Math.min(Math.max(sourceWidth, 800), viewportWidth - 40),
);
const tempMeasureDiv = document.createElement("div");
tempMeasureDiv.style.position = "absolute";
tempMeasureDiv.style.left = "-9999px";
tempMeasureDiv.style.width = targetWidth + "px";
tempMeasureDiv.style.visibility = "hidden";
tempMeasureDiv.innerHTML = `
<div class="notice-unified-content notice-modal-state" style="position: relative; width: 100%; padding: 16px; border: 1px solid rgba(255, 255, 255, 0.1);">
<div class="notice-header">
<div class="notice-badge-row">
<span class="notice-badge">${notice.label_title || "General"}</span>
<span class="notice-staff">${notice.staff}</span>
</div>
<button class="notice-close-btn">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${cleanContent}</div>
</div>
`;
document.body.appendChild(tempMeasureDiv);
const measuredHeight =
tempMeasureDiv.firstElementChild!.getBoundingClientRect().height;
document.body.removeChild(tempMeasureDiv);
let targetHeight = Math.round(
Math.min(Math.max(measuredHeight + 32, 200), viewportHeight * 0.9),
);
let targetLeft = Math.round((viewportWidth - targetWidth) / 2);
let targetTop = Math.round((viewportHeight - targetHeight) / 2) + scrollY;
const closeModal = () => {
window.removeEventListener("resize", handleResize);
document.removeEventListener("keydown", handleEscape);
if (!settingsState.animations) {
modal.remove();
sourceElement.style.opacity = "1";
sourceElement.style.transform = "";
sourceElement.removeAttribute("data-transitioning");
return;
}
animate(
modal,
{
backgroundColor: ["rgba(0, 0, 0, 0.5)", "rgba(0, 0, 0, 0)"],
backdropFilter: ["blur(4px)", "blur(0px)"],
},
{ duration: 0.2 },
);
animate(
transitionContainer,
{ opacity: [1, 0] },
{ duration: 0.2, delay: 0.3 },
);
sourceElement.style.opacity = "1";
sourceElement.style.transform = "";
modal.style.pointerEvents = "none";
animate(
transitionContainer,
{
left: [targetLeft + scrollX, sourceLeft + scrollX],
top: [targetTop, sourceTop + scrollY],
width: [targetWidth, sourceWidth],
height: [targetHeight, sourceHeight],
scale: [1, 1],
},
{
duration: 0.35,
type: "spring",
stiffness: 400,
damping: 35,
},
).finished.then(async () => {
modal.remove();
sourceElement.removeAttribute("data-transitioning");
});
};
closeBtn?.addEventListener("click", closeModal);
modal?.addEventListener("click", (e) => {
if (e.target === modal) {
closeModal();
}
});
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeModal();
document.removeEventListener("keydown", handleEscape);
window.removeEventListener("resize", handleResize);
}
};
document.addEventListener("keydown", handleEscape);
const handleResize = () => {
const newSourceRect = sourceElement.getBoundingClientRect();
const newScrollY = Math.round(window.scrollY);
const newScrollX = Math.round(window.scrollX);
const computedStyle = getComputedStyle(sourceElement);
const transform = computedStyle.transform;
let scaleX = 1,
scaleY = 1;
if (transform && transform !== "none") {
const matrix = transform.match(/matrix.*\((.+)\)/);
if (matrix) {
const values = matrix[1].split(", ");
scaleX = parseFloat(values[0]);
scaleY = parseFloat(values[3]);
}
}
const newSourceWidth = newSourceRect.width / scaleX;
const newSourceHeight = newSourceRect.height / scaleY;
const deltaX = (newSourceWidth - newSourceRect.width) / 2;
const deltaY = (newSourceHeight - newSourceRect.height) / 2;
const newSourceLeft = newSourceRect.left - deltaX;
const newSourceTop = newSourceRect.top - deltaY;
const newViewportWidth = window.innerWidth;
const newViewportHeight = window.innerHeight;
const newTargetWidth = Math.round(
Math.min(Math.max(newSourceWidth, 800), newViewportWidth - 40),
);
const currentHeight = unifiedContent.getBoundingClientRect().height;
const newTargetHeight = Math.round(
Math.min(Math.max(currentHeight + 32, 200), newViewportHeight * 0.9),
);
const newTargetLeft = Math.round((newViewportWidth - newTargetWidth) / 2);
const newTargetTop =
Math.round((newViewportHeight - newTargetHeight) / 2) + newScrollY;
transitionContainer.style.left =
Math.round(newTargetLeft + newScrollX) + "px";
transitionContainer.style.top = Math.round(newTargetTop) + "px";
transitionContainer.style.width = Math.round(newTargetWidth) + "px";
transitionContainer.style.height = Math.round(newTargetHeight) + "px";
sourceLeft = newSourceLeft;
sourceTop = newSourceTop;
sourceWidth = newSourceWidth;
sourceHeight = newSourceHeight;
targetLeft = newTargetLeft;
targetTop = newTargetTop;
targetWidth = newTargetWidth;
targetHeight = newTargetHeight;
scrollY = newScrollY;
scrollX = newScrollX;
};
window.addEventListener("resize", handleResize);
if (settingsState.animations) {
animate(modal, { opacity: [0, 1] }, { duration: 0.2 });
animate(
transitionContainer,
{
left: [sourceLeft + scrollX, targetLeft + scrollX],
top: [sourceTop + scrollY, targetTop],
width: [sourceWidth, targetWidth],
height: [sourceHeight, targetHeight],
scale: [1, 1],
},
{
duration: 0.5,
type: "spring",
stiffness: 280,
damping: 24,
},
);
unifiedContent.classList.remove("notice-card-state");
unifiedContent.classList.add("notice-modal-state");
} else {
modal.style.opacity = "1";
transitionContainer.style.left = Math.round(targetLeft + scrollX) + "px";
transitionContainer.style.top = Math.round(targetTop) + "px";
transitionContainer.style.width = Math.round(targetWidth) + "px";
transitionContainer.style.height = Math.round(targetHeight) + "px";
unifiedContent.classList.remove("notice-card-state");
unifiedContent.classList.add("notice-modal-state");
}
}
async function fetchEngageNoticesFromApi(
date: string,
labelTokens: string[],
): Promise<void> {
try {
const data = settingsState.mockNotices
? getMockNotices()
: await (
await fetch(`${location.origin}/seqta/parent/load/notices`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
credentials: "include",
body: JSON.stringify({ date }),
})
).json();
processEngageNotices(data, labelTokens);
} catch (e) {
console.warn("[BetterSEQTA+] Engage notices request failed:", e);
processEngageNotices({ payload: [] }, labelTokens);
}
}
function bindEngageNoticesDateInput(
labelTokens: string[],
initialDate: string,
): () => void {
const dateControl = document.getElementById(
ENGAGE_NOTICES_DATE_ID,
) as HTMLInputElement | null;
if (!dateControl) {
return () => {};
}
dateControl.value = initialDate;
const debouncedInputChange = debounce((e: Event) => {
void fetchEngageNoticesFromApi(
(e.target as HTMLInputElement).value,
labelTokens,
);
}, 250);
dateControl.addEventListener("input", debouncedInputChange);
return () => dateControl.removeEventListener("input", debouncedInputChange);
}
async function initEngageNoticesUi(todayFormatted: string): Promise<void> {
const noticeContainer = document.getElementById(ENGAGE_NOTICE_CONTAINER_ID);
if (!noticeContainer) return;
let labelFilterValues: string[] = [];
try {
const prefsRes = await fetch(`${location.origin}/seqta/parent/load/prefs?`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
credentials: "include",
body: JSON.stringify({ asArray: true, request: "userPrefs" }),
});
const prefs = await prefsRes.json();
const payload = prefs?.payload;
if (Array.isArray(payload)) {
labelFilterValues = payload
.filter((item: { name?: string }) => item.name === "notices.filters")
.map((item: { value?: string }) => item.value)
.filter((v): v is string => typeof v === "string");
}
} catch {
labelFilterValues = [];
}
const labelTokens =
labelFilterValues.length > 0
? String(labelFilterValues[0]).split(" ").filter(Boolean)
: [];
const dateControl = document.getElementById(ENGAGE_NOTICES_DATE_ID);
if (dateControl) {
(dateControl as HTMLInputElement).value = todayFormatted;
}
await fetchEngageNoticesFromApi(todayFormatted, labelTokens);
const cleanup = bindEngageNoticesDateInput(labelTokens, todayFormatted);
engageMergeNoticeCleanup(cleanup);
noticeContainer.classList.remove("loading");
}
function engageMergeNoticeCleanup(noticeCleanup: () => void): void {
const prev = engageListenersCleanup;
engageListenersCleanup = () => {
prev?.();
noticeCleanup();
};
}
function showEngageTimetableError(message: string): void {
const dayContainer = document.getElementById("engage-day-container");
if (!dayContainer) return;
dayContainer.classList.remove("loading");
dayContainer.innerHTML = `
<div class="day-empty">
<img src="${browser.runtime.getURL(LogoLight)}" alt="" />
<p>${message}</p>
</div>`;
}
function showEngageNoticesSectionError(message: string): void {
const noticeContainer = document.getElementById(ENGAGE_NOTICE_CONTAINER_ID);
if (!noticeContainer) return;
noticeContainer.classList.remove("loading");
noticeContainer.innerHTML = `
<div class="day-empty">
<img src="${browser.runtime.getURL(LogoLight)}" alt="" />
<p>${message}</p>
</div>`;
}
/** SEQTA Engage parent home: child timetable (today view) using parent APIs. */
export async function loadEngageHomePage(): Promise<void> {
updateEngageHomeMenuActive(true);
document.title = "Home ― SEQTA Engage";
let main: HTMLElement;
try {
/* Engage mounts `#main` after React hydrates; a single rAF often loses the race on cold load. */
main = (await waitForElm("#main", true, 100, 200)) as HTMLElement;
} catch {
console.warn(
"[BetterSEQTA+] Engage home: timed out waiting for #main (shell not ready).",
);
return;
}
engageListenersCleanup?.();
engageViewDate = new Date();
main.innerHTML = "";
/* `stringToHTML` returns `document.body`; use firstElementChild so we don't append a whitespace text node (which would drop #engage-home-container and break queries). */
const engageHomeBody = stringToHTML(/* html */ `
<div class="home-root" id="engage-home-root">
<div class="home-container" id="engage-home-container">
<div class="border timetable-container">
<div class="home-subtitle">
<div class="engage-timetable-title-cluster">
<h2 id="engage-home-lesson-subtitle">Today's Lessons</h2>
<select id="engage-child-selector" class="engage-child-select" aria-label="Student"></select>
</div>
<div class="timetable-arrows">
<svg width="24" height="24" viewBox="0 0 24 24" style="transform: scale(-1,1)" id="engage-home-timetable-back">
<g style="fill: currentcolor;"><path d="M8.578 16.359l4.594-4.594-4.594-4.594 1.406-1.406 6 6-6 6z"></path></g>
</svg>
<svg width="24" height="24" viewBox="0 0 24 24" id="engage-home-timetable-forward">
<g style="fill: currentcolor;"><path d="M8.578 16.359l4.594-4.594-4.594-4.594 1.406-1.406 6 6-6 6z"></path></g>
</svg>
</div>
</div>
<div class="day-container loading" id="engage-day-container"></div>
</div>
<div class="border notices-container">
<div style="display: flex; justify-content: space-between">
<h2 class="home-subtitle">Notices</h2>
<input type="date" id="engage-notices-date" />
</div>
<div class="notice-container upcoming-items loading" id="engage-notice-container"></div>
</div>
</div>
</div>
`);
const engageHomeRoot = engageHomeBody.firstElementChild as HTMLElement | null;
if (engageHomeRoot) {
main.appendChild(engageHomeRoot);
} else {
console.error(
"[BetterSEQTA+] Engage home: parsed markup had no root element (check DOMPurify / stringToHTML).",
);
return;
}
bindEngageTimetableUi();
setEngageTimetableSubtitle();
const select = document.getElementById(
"engage-child-selector",
) as HTMLSelectElement | null;
const todayFormatted = toISODate(new Date());
let children: EngageParentChild[];
try {
try {
children = await fetchEngageParentChildren();
} catch (e) {
console.error("[BetterSEQTA+] Engage parent child list failed:", e);
showEngageTimetableError("Could not load students for this account.");
return;
}
if (!select) {
showEngageTimetableError("Could not initialize the home view.");
showEngageNoticesSectionError("Could not initialize notices.");
return;
}
if (children.length === 0) {
select.disabled = true;
showEngageTimetableError("No linked students found.");
return;
}
populateChildSelector(select, children);
if (!engageSelectedStudentId) {
showEngageTimetableError("No student selected.");
return;
}
await fetchWeekAndRender();
} finally {
await initEngageNoticesUi(todayFormatted);
}
}
@@ -0,0 +1,87 @@
const TIMETABLE_URL = "/seqta/parent/load/timetable";
export interface EngageParentChild {
name: string;
id: string;
}
export interface EngageParentTimetableItem {
date: string;
period: string;
code: string;
description: string;
staff: string;
type: string;
room: string;
from: string;
until: string;
programmeID?: number;
metaID?: number;
assessments?: unknown[];
}
export function toISODate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
/** MondaySunday range (inclusive) containing `date`, as YYYY-MM-DD. */
export function weekRangeContaining(date: Date): { from: string; until: string } {
const local = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const dow = local.getDay();
const diff = dow === 0 ? -6 : 1 - dow;
local.setDate(local.getDate() + diff);
const monday = local;
const sunday = new Date(monday);
sunday.setDate(sunday.getDate() + 6);
return { from: toISODate(monday), until: toISODate(sunday) };
}
function isInWeekRange(
isoDay: string,
weekFrom: string,
weekUntil: string,
): boolean {
return isoDay >= weekFrom && isoDay <= weekUntil;
}
export function isDateInCachedWeek(
date: Date,
weekFrom: string,
weekUntil: string,
): boolean {
return isInWeekRange(toISODate(date), weekFrom, weekUntil);
}
async function postParentTimetable(body: object): Promise<any> {
const res = await fetch(`${location.origin}${TIMETABLE_URL}`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
credentials: "include",
body: JSON.stringify(body),
});
return res.json();
}
export async function fetchEngageParentChildren(): Promise<EngageParentChild[]> {
const data = await postParentTimetable({ list: true });
const raw = data?.payload;
if (!Array.isArray(raw)) return [];
return raw.map((row: { name?: string; id?: string | number }) => ({
name: String(row?.name ?? ""),
id: String(row?.id ?? ""),
}));
}
export async function fetchEngageParentTimetableWeek(
from: string,
until: string,
studentId: string,
): Promise<EngageParentTimetableItem[]> {
const student = /^\d+$/.test(studentId) ? Number(studentId) : studentId;
const data = await postParentTimetable({ from, until, student });
const items = data?.payload?.items;
return Array.isArray(items) ? items : [];
}
@@ -0,0 +1,71 @@
import stringToHTML from "../stringToHTML";
import { settingsState } from "../listeners/SettingsState";
import { openPopup } from "./PopupManager";
import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen";
/** Same hosting pattern as the What's New update video (GitHub raw). */
const BS_CLOUD_DEMO_VIDEO_URL =
"https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/bsclouddemo.webm";
export function shouldShowBsCloudAutoSyncAnnouncement(): boolean {
return !settingsState.bsCloudAutoSyncAnnouncementShown;
}
/**
* One-time announcement for BetterSEQTA Cloud automatic settings sync (after other startup popups).
* Video layout matches {@link OpenWhatsNewPopup} (`whatsnewImgContainer` / `whatsnewImg`).
*/
export function showBsCloudAutoSyncAnnouncement(onDismissed?: () => void) {
if (document.getElementById("whatsnewbk")) {
onDismissed?.();
return;
}
if (!shouldShowBsCloudAutoSyncAnnouncement()) {
onDismissed?.();
return;
}
const header = stringToHTML(
/* html */
`<div class="whatsnewHeader bsCloudAutoSyncAnnouncementHeader">
<h1>BetterSEQTA Cloud</h1>
</div>`,
).firstChild as HTMLElement;
const imageContainer = document.createElement("div");
imageContainer.classList.add("whatsnewImgContainer");
const video = document.createElement("video");
const source = document.createElement("source");
source.setAttribute("src", BS_CLOUD_DEMO_VIDEO_URL);
source.setAttribute("type", "video/webm");
video.autoplay = true;
video.muted = true;
video.loop = true;
video.appendChild(source);
video.classList.add("whatsnewImg");
imageContainer.appendChild(video);
attachPopupMediaFullscreen(video);
const text = stringToHTML(/* html */ `
<div class="whatsnewTextContainer privacyStatement" style="height: 50%; overflow-y: auto; font-size: 1.2rem; line-height: 1.6;">
<p>
<strong class="bsCloudAccent">BetterSEQTA Cloud</strong> can keep your BetterSEQTA+ settings backed up and in
sync across browsers. Optional <strong>automatic settings sync</strong> runs when you are signed in (passwords
and tokens are never included).
</p>
<p>
Close this dialog when you are done. We will not show this announcement again.
</p>
<p class="bsCloudAutoSyncSignupCallout">Sign up in BetterSEQTA settings</p>
</div>
`).firstChild as HTMLElement;
settingsState.bsCloudAutoSyncAnnouncementShown = true;
openPopup({
header,
content: [imageContainer, text],
afterClose: onDismissed,
});
}
@@ -0,0 +1,61 @@
import stringToHTML from "../stringToHTML";
import { settingsState } from "../listeners/SettingsState";
import { openPopup } from "./PopupManager";
import { attachPopupMediaFullscreenIfPresent } from "./attachPopupMediaFullscreen";
/** Same hosting pattern as the privacy statement branding images (avoids page-relative extension URLs on Engage). */
const ENGAGE_PROMO_IMG_URL =
"https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/bq%2Bengage.png";
export function shouldShowEngageParentsAnnouncement(): boolean {
return !settingsState.engageParentsAnnouncementShown;
}
/**
* One-time announcement that BetterSEQTA Plus works on SEQTA Engage (parents).
*/
export function showEngageParentsAnnouncement(onDismissed?: () => void) {
if (document.getElementById("whatsnewbk")) {
onDismissed?.();
return;
}
if (!shouldShowEngageParentsAnnouncement()) {
onDismissed?.();
return;
}
const header = stringToHTML(
/* html */
`<div class="whatsnewHeader engageParentsAnnouncementHeader">
<h1>BetterSEQTA Plus now supports <span class="seqtaEngageAccent">SEQTA Engage</span></h1>
<p class="engageParentsSubheading">Buy your mom a BetterSEQTA Plus</p>
</div>`,
).firstChild as HTMLElement;
const text = stringToHTML(/* html */ `
<div class="whatsnewTextContainer privacyStatement" style="overflow-y: auto; font-size: 1.2rem; line-height: 1.6;">
<div class="engageParentsPromoWrap">
<img class="engageParentsPromoImg" src="${ENGAGE_PROMO_IMG_URL}" width="1920" height="1080" alt="BetterSEQTA Plus now supports SEQTA Engage" />
</div>
<p>
BetterSEQTA Plus now supports <strong class="seqtaEngageAccent">SEQTA Engage</strong>, so parents get the same kinds of improvements you are used to on SEQTA Learnthemes, a clearer home experience, and other Plus polish while browsing Engage.
</p>
<p>
The title is a bit of fun; if the extension saves you time, you can always support development via Open Collective or Ko-fi from the What is New changelog or related links in settings.
</p>
<p>
Close this dialog when you are done. We will not show this announcement again.
</p>
</div>
`).firstChild as HTMLElement;
attachPopupMediaFullscreenIfPresent(text, ".engageParentsPromoImg");
settingsState.engageParentsAnnouncementShown = true;
openPopup({
header,
content: [text],
afterClose: onDismissed,
});
}
@@ -1,13 +1,31 @@
import stringToHTML from "../stringToHTML";
import { settingsState } from "../listeners/SettingsState";
import { openPopup } from "./PopupManager";
import { attachPopupMediaFullscreenIfPresent } from "./attachPopupMediaFullscreen";
export function showPrivacyNotification() {
const lastUpdated = "2025-12-19";
const PRIVACY_STATEMENT_VERSION = "2025-12-19";
if (document.getElementById("whatsnewbk")) return;
if (settingsState.privacyStatementShown) return;
if (settingsState.privacyStatementLastUpdated && new Date(settingsState.privacyStatementLastUpdated) > new Date(lastUpdated)) return;
export function shouldShowPrivacyNotification(): boolean {
if (settingsState.privacyStatementShown) return false;
if (
settingsState.privacyStatementLastUpdated &&
new Date(settingsState.privacyStatementLastUpdated) >
new Date(PRIVACY_STATEMENT_VERSION)
) {
return false;
}
return true;
}
export function showPrivacyNotification(onDismissed?: () => void) {
if (document.getElementById("whatsnewbk")) {
onDismissed?.();
return;
}
if (!shouldShowPrivacyNotification()) {
onDismissed?.();
return;
}
const header = stringToHTML(
/* html */
@@ -42,11 +60,14 @@ export function showPrivacyNotification() {
</div>
`).firstChild as HTMLElement;
attachPopupMediaFullscreenIfPresent(text, "img.aboutImg");
settingsState.privacyStatementLastUpdated = "2025-12-20";
settingsState.privacyStatementShown = true;
openPopup({
header,
content: [text],
afterClose: onDismissed,
});
}
+25 -2
View File
@@ -2,8 +2,9 @@ import stringToHTML from "../stringToHTML";
import browser from "webextension-polyfill";
import kofi from "@/resources/kofi.png?base64";
import { openPopup } from "./PopupManager";
import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen";
export function OpenWhatsNewPopup() {
export function OpenWhatsNewPopup(onDismissed?: () => void) {
const header = stringToHTML(
/* html */
`<div class="whatsnewHeader">
@@ -20,7 +21,7 @@ export function OpenWhatsNewPopup() {
source.setAttribute(
"src",
"https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/update-video.mp4",
"https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/update-video.webm",
);
video.autoplay = true;
video.muted = true;
@@ -28,10 +29,30 @@ export function OpenWhatsNewPopup() {
video.appendChild(source);
video.classList.add("whatsnewImg");
imageContainer.appendChild(video);
attachPopupMediaFullscreen(video);
const text = stringToHTML(/* html */ `
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
<h1>3.6.0 - Cloud backup, various fixes & SEQTA Engage support</h1>
<li>BetterSEQTA Cloud: back up and restore extension settings from your account (General settings).</li>
<li>Optional automatic cloud sync if signed in (on by default).</li>
<li>Option to use cloud profile photo as the local SEQTA profile picture</li>
<li>Firefox: fixed the extension settings popup.</li>
<li>SEQTA Engage: Added BetterSEQTA Plus support for SEQTA Engage for Parents.</li>
<li>Added smooth transitions to adaptive themes (on by default)</li>
<li>Added adaptive theme variables to custom themes (try it out with the Windows XP theme)</li>
<li>Fixed today's lessons on the homepage misbehaving in developer mode.</li>
<li>Reduced overlap between BetterSEQTA subject averages and SEQTA's built-in averages UI.</li>
<li>Updated outdated in-app links and update some under the hood code (Vite 8).</li>
<li>Added a notifications panel animation to work like settings.</li>
<li>Fix timetable edit plugin not working correctly.</li>
<h1>3.5.3 - Adaptive theme updates</h1>
<li>Fixed adaptive theming on current-year course and assessment pages.</li>
<h1>3.5.2 - PDF & store compliance</h1>
<li>Put PDF.js with the extension so assessment weighting stays compatible with Chrome Web Store rules</li>
<h1>3.5.1 - QR & session link fix</h1>
<li>Fixed DesQTA Connect Mobile App QR generation on Chrome</li>
@@ -346,5 +367,7 @@ export function OpenWhatsNewPopup() {
openPopup({
header,
content: [imageContainer, text, footer],
afterClose: onDismissed,
clearJustUpdated: true,
});
}
+20
View File
@@ -4,6 +4,13 @@ import { animate as motionAnimate, stagger } from "motion";
type AnimationTarget = string | Element | Element[] | NodeList | null;
let isClosing = false;
let pendingAfterClose: (() => void) | undefined;
function invokeAfterClose() {
const fn = pendingAfterClose;
pendingAfterClose = undefined;
fn?.();
}
export async function closePopup() {
if (isClosing) return;
@@ -16,12 +23,14 @@ export async function closePopup() {
if (!background || !popup) {
isClosing = false;
invokeAfterClose();
return;
}
if (!settingsState.animations) {
background.remove();
isClosing = false;
invokeAfterClose();
return;
}
@@ -33,19 +42,28 @@ export async function closePopup() {
background.remove();
isClosing = false;
invokeAfterClose();
}
interface OpenPopupOptions {
header?: Node | null;
content?: (Node | null | undefined)[];
animateSelector?: AnimationTarget;
/** Called once after this popup is fully closed (including skip-animation path). */
afterClose?: () => void;
/** When true, clears the post-update flag when this popup opens (What's New only). */
clearJustUpdated?: boolean;
}
export function openPopup({
header,
content = [],
animateSelector = ".whatsnewTextContainer *",
afterClose,
clearJustUpdated = false,
}: OpenPopupOptions = {}) {
pendingAfterClose = afterClose;
const background = document.createElement("div");
background.id = "whatsnewbk";
background.classList.add("whatsnewBackground");
@@ -88,7 +106,9 @@ export function openPopup({
}
}
if (clearJustUpdated) {
delete settingsState.justupdated;
}
background.addEventListener("click", (event) => {
if (event.target === background) void closePopup();
@@ -0,0 +1,48 @@
import { settingsState } from "../listeners/SettingsState";
import { OpenWhatsNewPopup } from "./OpenWhatsNewPopup";
import {
shouldShowPrivacyNotification,
showPrivacyNotification,
} from "./OpenPrivacyNotification";
import {
shouldShowEngageParentsAnnouncement,
showEngageParentsAnnouncement,
} from "./OpenEngageParentsAnnouncement";
import {
shouldShowBsCloudAutoSyncAnnouncement,
showBsCloudAutoSyncAnnouncement,
} from "./OpenBsCloudAutoSyncAnnouncement";
type QueueStep = (goNext: () => void) => void;
/**
* Runs startup modals in order: What's New (if the extension just updated),
* privacy statement (if required), SEQTA Engage announcement (once), then BS Cloud
* auto-sync (once, last).
*/
export function runStartupPopupQueue() {
const steps: QueueStep[] = [];
if (settingsState.justupdated) {
steps.push((goNext) => OpenWhatsNewPopup(goNext));
}
if (shouldShowPrivacyNotification()) {
steps.push((goNext) => showPrivacyNotification(goNext));
}
if (shouldShowEngageParentsAnnouncement()) {
steps.push((goNext) => showEngageParentsAnnouncement(goNext));
}
if (shouldShowBsCloudAutoSyncAnnouncement()) {
steps.push((goNext) => showBsCloudAutoSyncAnnouncement(goNext));
}
function runNext() {
const step = steps.shift();
if (step) step(runNext);
}
runNext();
}
@@ -0,0 +1,158 @@
/**
* Makes popup hero images/videos open a padded overlay (not browser fullscreen) on click.
* Escape or backdrop click dismisses it. Clicks use stopPropagation so the
* parent SEQTA popup does not close.
*/
import { settingsState } from "../listeners/SettingsState";
const FULLSCREENABLE_CLASS = "popup-media-fullscreenable";
const OVERLAY_VISIBLE_CLASS = "bsplus-popup-media-overlay-backdrop--visible";
const OVERLAY_ANIM_MS = 280;
function isImageOrVideo(el: Element): el is HTMLImageElement | HTMLVideoElement {
return el instanceof HTMLImageElement || el instanceof HTMLVideoElement;
}
export function attachPopupMediaFullscreen(el: HTMLImageElement | HTMLVideoElement) {
el.classList.add(FULLSCREENABLE_CLASS);
el.setAttribute("tabindex", "0");
el.setAttribute("role", "button");
el.setAttribute("aria-label", "View larger");
el.title = "Click to view larger";
const open = (e: Event) => {
e.preventDefault();
e.stopPropagation();
openMediaOverlayViewer(el);
};
el.addEventListener("click", open);
el.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
open(e);
}
});
}
function openMediaOverlayViewer(source: HTMLImageElement | HTMLVideoElement) {
const backdrop = document.createElement("div");
backdrop.id = "bsplus-popup-media-overlay";
backdrop.className = "bsplus-popup-media-overlay-backdrop";
const inner = document.createElement("div");
inner.className = "bsplus-popup-media-overlay-inner";
const slot = document.createElement("div");
slot.className = "bsplus-popup-media-overlay-slot";
let media: HTMLImageElement | HTMLVideoElement;
if (source instanceof HTMLVideoElement) {
const v = source;
const nv = document.createElement("video");
nv.classList.add("bsplus-popup-media-overlay-media");
nv.controls = true;
nv.playsInline = true;
nv.loop = v.loop;
nv.muted = v.muted;
nv.volume = v.volume;
for (const s of v.querySelectorAll("source")) {
const ns = document.createElement("source");
ns.src = (s as HTMLSourceElement).src;
const t = (s as HTMLSourceElement).type;
if (t) ns.type = t;
nv.appendChild(ns);
}
nv.addEventListener(
"loadeddata",
() => {
try {
nv.currentTime = v.currentTime;
} catch {
/* ignore */
}
void nv.play().catch(() => {});
},
{ once: true },
);
v.pause();
nv.load();
media = nv;
} else {
const img = document.createElement("img");
img.classList.add("bsplus-popup-media-overlay-media");
img.src = source.currentSrc || source.src;
img.alt = source.alt || "";
media = img;
}
media.addEventListener("click", (e) => e.stopPropagation());
slot.appendChild(media);
inner.append(slot);
backdrop.appendChild(inner);
document.body.append(backdrop);
if (!settingsState.animations) {
backdrop.classList.add("bsplus-popup-media-overlay--instant");
backdrop.classList.add(OVERLAY_VISIBLE_CLASS);
} else {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
backdrop.classList.add(OVERLAY_VISIBLE_CLASS);
});
});
}
inner.addEventListener("click", (e) => e.stopPropagation());
let done = false;
const removeOverlay = () => {
if (source instanceof HTMLVideoElement && media instanceof HTMLVideoElement) {
try {
source.currentTime = media.currentTime;
} catch {
/* ignore */
}
void source.play().catch(() => {});
}
backdrop.remove();
};
const close = () => {
if (done) return;
done = true;
document.removeEventListener("keydown", onDocKey, true);
if (!settingsState.animations) {
removeOverlay();
return;
}
backdrop.classList.remove(OVERLAY_VISIBLE_CLASS);
window.setTimeout(removeOverlay, OVERLAY_ANIM_MS);
};
const onDocKey = (ev: KeyboardEvent) => {
if (ev.key === "Escape") {
ev.stopPropagation();
close();
}
};
document.addEventListener("keydown", onDocKey, true);
backdrop.addEventListener("click", () => {
close();
});
}
export function attachPopupMediaFullscreenIfPresent(
root: ParentNode,
selector: string,
) {
const el = root.querySelector(selector);
if (el && isImageOrVideo(el)) {
attachPopupMediaFullscreen(el);
}
}
+8 -3
View File
@@ -2,12 +2,17 @@ import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
/**
* Parses the current page from window.location.hash.
* Returns { programme, metaclass } for /courses/SEMESTER/X:Y or /assessments/SEMESTER/X:Y, or null.
* e.g. #?page=/courses/2023S/4804:11066 or #?page=/assessments/2023S/4621:10772
* Supports both old and current URL formats, e.g.
* /courses/SEMESTER/X:Y and /courses/X:Y
* /assessments/SEMESTER/X:Y and /assessments/X:Y
* e.g. #?page=/courses/2023S/4804:11066,
* #?page=/courses/4804:11066,
* #?page=/assessments/2023S/4621:10772,
* #?page=/assessments/4621:10772
*/
function parsePageContext(): { programme: number; metaclass: number } | null {
const hash = window.location.hash || "";
const match = hash.match(/[?&]page=\/(courses|assessments)\/[^/]+\/(\d+):(\d+)/);
const match = hash.match(/[?&]page=\/(courses|assessments)\/(?:[^/]+\/)?(\d+):(\d+)/);
if (!match) return null;
const programme = parseInt(match[2], 10);
const metaclass = parseInt(match[3], 10);
@@ -0,0 +1,128 @@
import { animate } from "motion";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { waitForElm } from "@/seqta/utils/waitForElm";
/**
* Finds the SEQTA notifications dropdown panel (the list container next to the bell).
*/
function findNotificationPanel(): HTMLElement | null {
const wrapper = document.querySelector(".connectedNotificationsWrapper");
if (!wrapper) return null;
const flat = wrapper.querySelector<HTMLElement>(":scope > div > button + div");
if (flat) return flat;
const notifBlock = wrapper.querySelector("[class*='notifications__notifications___']");
if (notifBlock?.nextElementSibling instanceof HTMLElement) {
return notifBlock.nextElementSibling;
}
const list = wrapper.querySelector<HTMLElement>("[class*='notifications__list___']");
if (list) return list;
return null;
}
function isPanelVisible(el: HTMLElement): boolean {
return (
el.getClientRects().length > 0 && getComputedStyle(el).visibility !== "hidden"
);
}
let lastVisible = false;
/** Invalidates in-flight open animations when the panel closes or reopens. */
let motionGeneration = 0;
function runOpenAnimation(panel: HTMLElement) {
const myGen = ++motionGeneration;
panel.classList.add("bsplus-notifications-panel");
if (!settingsState.animations) {
panel.style.opacity = "1";
panel.style.transform = "scale(1)";
return;
}
panel.style.opacity = "0";
panel.style.transform = "scale(0)";
requestAnimationFrame(() => {
if (myGen !== motionGeneration) return;
animate(0, 1, {
onUpdate: (progress) => {
panel.style.opacity = String(progress);
panel.style.transform = `scale(${progress})`;
},
type: "spring",
stiffness: 280,
damping: 20,
});
});
}
function clearPanelMotionStyles(panel: HTMLElement) {
motionGeneration++;
panel.style.opacity = "";
panel.style.transform = "";
}
/**
* Spring open / fade close for the native SEQTA notifications dropdown, matching ExtensionPopup.
*/
export function attachNotificationsPanelAnimation() {
void setupNotificationsPanelAnimation();
}
async function setupNotificationsPanelAnimation() {
try {
await waitForElm(".connectedNotificationsWrapper", true, 100, 60);
} catch {
return;
}
const wrapper = document.querySelector(".connectedNotificationsWrapper");
if (!wrapper) return;
const sync = () => {
const panel = findNotificationPanel();
// When SEQTA removes the dropdown from the DOM on close, we must reset
// lastVisible — otherwise the next open still looks "already visible" and skips animation.
if (!panel) {
if (lastVisible) {
lastVisible = false;
motionGeneration++;
}
return;
}
const visible = isPanelVisible(panel);
if (visible === lastVisible) return;
if (visible) {
runOpenAnimation(panel);
} else {
clearPanelMotionStyles(panel);
}
lastVisible = visible;
};
const observer = new MutationObserver(() => {
sync();
});
observer.observe(wrapper, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["style", "class"],
});
document.addEventListener(
"click",
() => {
requestAnimationFrame(() => requestAnimationFrame(sync));
},
true,
);
sync();
}
+154
View File
@@ -0,0 +1,154 @@
import browser from "webextension-polyfill";
/** Matches the contract in docs/CLOUD_SETTINGS_SYNC_SERVER.md */
export const CLOUD_SETTINGS_SYNC_SCHEMA_VERSION = 1;
/**
* Client-only: last known remote `updated_at` for BS+ settings (from summary or sync responses).
* Never uploaded; preserved on restore; used to decide when to pull a newer cloud backup.
*/
export const BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY =
"bsplus_cloud_settings_known_remote_updated_at";
/**
* Never uploaded to the cloud backup (OAuth and legacy keys).
* IndexedDB (e.g. Global Searchs `betterseqta-index` database) is not part of
* `chrome.storage.local` and is never included in this payload.
*/
export const KEYS_OMITTED_FROM_CLOUD_UPLOAD = [
"bsplus_token",
"bsplus_refresh_token",
"bsplus_client_id",
"bsplus_user",
"cloudAccessToken",
"cloudUsername",
] as const;
/**
* Device-only caches / school-related data: never uploaded, never applied from a
* cloud snapshot (local values are kept on restore).
*/
export const SENSITIVE_DEVICE_STORAGE_KEYS_EXACT = [
"plugin.assessments-average.storage.assessments",
"plugin.assessments-average.storage.weightings",
] as const;
/** e.g. any future `plugin.global-search.storage.*` keys in chrome.storage */
export const SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES = ["plugin.global-search.storage."] as const;
const CLIENT_ONLY_CLOUD_KEYS_EXACT = [BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY] as const;
/** After restoring from cloud, keep local session so the user stays signed in. */
const AUTH_KEYS_TO_PRESERVE = [
"bsplus_token",
"bsplus_refresh_token",
"bsplus_client_id",
"bsplus_user",
] as const;
const OMIT_FROM_UPLOAD_EXACT = new Set<string>([
...KEYS_OMITTED_FROM_CLOUD_UPLOAD,
...SENSITIVE_DEVICE_STORAGE_KEYS_EXACT,
...CLIENT_ONLY_CLOUD_KEYS_EXACT,
]);
/** True if a storage key is part of the upload payload (and should trigger auto-upload when changed). */
export function isKeyIncludedInCloudUploadPayload(key: string): boolean {
return !shouldOmitKeyFromCloudPayload(key);
}
function shouldOmitKeyFromCloudPayload(key: string): boolean {
if (OMIT_FROM_UPLOAD_EXACT.has(key)) return true;
for (const prefix of SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES) {
if (key.startsWith(prefix)) return true;
}
return false;
}
function isSensitiveDeviceKey(key: string): boolean {
if ((SENSITIVE_DEVICE_STORAGE_KEYS_EXACT as readonly string[]).includes(key)) return true;
for (const prefix of SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES) {
if (key.startsWith(prefix)) return true;
}
return false;
}
/** Auth + device-only caches + client-only cloud metadata to keep when merging a downloaded snapshot. */
function collectLocalKeysToPreserve(local: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const k of AUTH_KEYS_TO_PRESERVE) {
if (local[k] !== undefined) out[k] = local[k];
}
for (const k of CLIENT_ONLY_CLOUD_KEYS_EXACT) {
if (local[k] !== undefined) out[k] = local[k];
}
for (const [k, v] of Object.entries(local)) {
if (isSensitiveDeviceKey(k)) out[k] = v;
}
return out;
}
/** Remove keys that must never come from the server blob (defense in depth). */
function stripExcludedKeysFromRemoteData(remote: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(remote)) {
if (shouldOmitKeyFromCloudPayload(k)) continue;
out[k] = v;
}
return out;
}
export function buildUploadPayload(all: Record<string, unknown>): {
schemaVersion: number;
data: Record<string, unknown>;
} {
const data: Record<string, unknown> = {};
for (const [k, v] of Object.entries(all)) {
if (shouldOmitKeyFromCloudPayload(k)) continue;
data[k] = v;
}
return { schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, data };
}
export async function getSnapshotForUpload(): Promise<{
schemaVersion: number;
data: Record<string, unknown>;
}> {
const all = await browser.storage.local.get();
return buildUploadPayload(all as Record<string, unknown>);
}
export async function setKnownRemoteUpdatedAt(iso: string | undefined): Promise<void> {
if (!iso || typeof iso !== "string") return;
await browser.storage.local.set({ [BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY]: iso });
}
/**
* Replace local extension storage with the downloaded snapshot, except auth keys
* and device-only sensitive caches, which are preserved from the current device.
*/
export async function applyDownloadedEnvelope(envelope: unknown): Promise<void> {
let remoteFlat: Record<string, unknown>;
if (
envelope &&
typeof envelope === "object" &&
"data" in envelope &&
(envelope as { data?: unknown }).data !== undefined &&
typeof (envelope as { data?: unknown }).data === "object" &&
(envelope as { data?: unknown }).data !== null &&
!Array.isArray((envelope as { data?: unknown }).data)
) {
remoteFlat = (envelope as { data: Record<string, unknown> }).data;
} else if (envelope && typeof envelope === "object" && !Array.isArray(envelope)) {
remoteFlat = envelope as Record<string, unknown>;
} else {
throw new Error("Invalid cloud settings payload");
}
const local = await browser.storage.local.get();
const preserved = collectLocalKeysToPreserve(local);
const remoteSanitized = stripExcludedKeysFromRemoteData(remoteFlat);
await browser.storage.local.clear();
await browser.storage.local.set({ ...remoteSanitized, ...preserved });
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Learn-style hash routes on Engage: `#?page=/home` `"home"`.
* Falls back to the legacy path segment used by classic Learn routing.
*/
export function getEngageRoutePage(): string | undefined {
const hash = window.location.hash.replace(/^#/, "");
if (hash) {
const qs = hash.startsWith("?") ? hash : `?${hash}`;
const params = new URLSearchParams(qs);
const page = params.get("page");
if (page?.startsWith("/")) {
const segment = page.replace(/^\//, "").split("/")[0];
return segment || undefined;
}
}
return window.location.href.split("/")[4];
}
+4
View File
@@ -0,0 +1,4 @@
/** SEQTA Engage (React) uses a different shell from classic SEQTA Learn. */
export function isSeqtaEngageExperience(): boolean {
return document.title.includes("SEQTA Engage");
}
+5 -2
View File
@@ -6,13 +6,13 @@ import {
OpenMenuOptions,
} from "@/seqta/utils/Openers/OpenMenuOptions";
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
import {
CloseThemeCreator,
OpenThemeCreator,
} from "@/plugins/built-in/themes/ThemeCreator";
import sendThemeUpdate from "@/seqta/utils/sendThemeUpdate";
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent";
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
const themeManager = ThemeManager.getInstance();
@@ -34,7 +34,10 @@ export class MessageHandler {
case "UpdateThemePreview":
if (request?.save == true) {
const save = async () => {
await themeManager.saveTheme(request.body);
await themeManager.saveTheme({
...request.body,
userEdited: true,
});
if (request.body.enableTheme) {
await themeManager.setTheme(request.body.id);
}
@@ -17,6 +17,10 @@ export class StorageChangeHandler {
settingsState.register("selectedColor", () => void updateAllColors());
settingsState.register("adaptiveThemeColour", () => void updateAllColors());
settingsState.register("adaptiveThemeGradient", () => void updateAllColors());
settingsState.register("adaptiveThemeColourTransition", () =>
void updateAllColors(),
);
settingsState.register("selectedTheme", () => void updateAllColors());
settingsState.register("DarkMode", this.handleDarkModeChange.bind(this));
settingsState.register("onoff", this.handleOnOffChange.bind(this));
settingsState.register("shortcuts", this.handleShortcutsChange.bind(this));
+10 -9
View File
@@ -9,10 +9,11 @@ import { renderSettingsIfNeeded } from "./Adders/AddExtensionSettings";
import { delay } from "./delay";
export function setupSettingsButton() {
var AddedSettings = document.getElementById("AddedSettings");
var extensionPopup = document.getElementById("ExtensionPopup");
const AddedSettings = document.getElementById("AddedSettings");
const extensionPopup = document.getElementById("ExtensionPopup");
if (!AddedSettings || !extensionPopup) return;
AddedSettings!.addEventListener("click", async () => {
AddedSettings.addEventListener("click", async () => {
if (SettingsClicked) {
closeExtensionPopup(extensionPopup as HTMLElement);
} else {
@@ -23,20 +24,20 @@ export function setupSettingsButton() {
if (settingsState.animations) {
animate(0, 1, {
onUpdate: (progress) => {
extensionPopup!.style.opacity = progress.toString();
extensionPopup!.style.transform = `scale(${progress})`;
extensionPopup.style.opacity = progress.toString();
extensionPopup.style.transform = `scale(${progress})`;
},
type: "spring",
stiffness: 280,
damping: 20,
});
} else {
extensionPopup!.style.opacity = "1";
extensionPopup!.style.transform = "scale(1)";
extensionPopup!.style.transition =
extensionPopup.style.opacity = "1";
extensionPopup.style.transform = "scale(1)";
extensionPopup.style.transition =
"opacity 0s linear, transform 0s linear";
}
extensionPopup!.classList.remove("hide");
extensionPopup.classList.remove("hide");
changeSettingsClicked(true);
}
});
+28
View File
@@ -12,7 +12,20 @@ export type CustomTheme = {
hideThemeName: boolean;
webURL?: string;
selectedColor?: string;
/**
* When true, the theme forces light/dark via `forceDark` (`false` = light, `true` = dark).
* When false/omitted, use legacy rule: `forceDark !== undefined` still means "force" for old JSON.
*/
forceTheme?: boolean;
forceDark?: boolean;
/** True if installed from the BetterSEQTA theme store (not file import). */
installedFromStore?: boolean;
/** Server `updated_at` (Unix seconds) when this copy was installed or last auto-updated. */
storeSyncedAtSec?: number;
/** User saved edits in theme creator or popup; blocks store auto-update. */
userEdited?: boolean;
/** CSS custom property names (e.g. `--my-accent`) that receive the same value as `--better-main` when adaptive colours apply. */
adaptiveCssVariables?: string[];
};
export type LoadedCustomTheme = CustomTheme & {
@@ -37,3 +50,18 @@ export type ThemeList = {
themes: CustomTheme[];
selectedTheme: string;
};
/** Whether the theme forces appearance (light vs dark). */
export function shouldForceThemeAppearance(theme: {
forceTheme?: boolean;
forceDark?: boolean;
}): boolean {
if (theme.forceTheme === true) return true;
if (theme.forceTheme === false) return false;
return theme.forceDark !== undefined;
}
/** Resolved forced dark mode when forcing is active. */
export function getForcedDarkMode(theme: { forceDark?: boolean }): boolean {
return theme.forceDark === true;
}
+7
View File
@@ -32,6 +32,10 @@ export interface SettingsState {
justupdated?: boolean;
privacyStatementShown?: boolean;
privacyStatementLastUpdated?: string;
/** One-time announcement: SEQTA Engage support for parents (dismissed popup queue). */
engageParentsAnnouncementShown?: boolean;
/** One-time announcement: BS Cloud automatic settings sync (last in startup popup queue). */
bsCloudAutoSyncAnnouncementShown?: boolean;
timeFormat?: string;
animations: boolean;
defaultPage: string;
@@ -43,6 +47,7 @@ export interface SettingsState {
iconOnlySidebar?: boolean;
adaptiveThemeColour?: boolean;
adaptiveThemeGradient?: boolean;
adaptiveThemeColourTransition?: boolean;
// depreciated keys
animatedbk: boolean;
@@ -56,6 +61,8 @@ export interface SettingsState {
bsplus_token?: string;
bsplus_refresh_token?: string;
bsplus_user?: { id: string; email?: string; username?: string; displayName?: string; pfpUrl?: string; admin_level?: number };
/** When not `false`, automatic cloud settings sync is enabled (default-on). */
autoCloudSettingsSync?: boolean;
}
interface ToggleItem {
+10 -5
View File
@@ -6,6 +6,7 @@ import InlineWorkerPlugin from "./lib/inlineWorker";
import { base64Loader } from "./lib/base64loader";
import type { BuildTarget } from "./lib/types";
import ClosePlugin from "./lib/closePlugin";
import { firefoxStripFunctionProbe } from "./lib/firefoxStripFunctionProbe";
import million from "million/compiler";
@@ -23,6 +24,9 @@ const targets: BuildTarget[] = [chrome, brave, edge, firefox, opera, safari];
const mode = process.env.MODE || "chrome"; // Check the environment variable to determine which build type to use.
//const sourcemap = (process.env.SOURCEMAP === "true") || false; // Check whether we want sourcemaps.
/** Million's compiler can emit `new Function()`, which Firefox extension pages block (strict CSP, no unsafe-eval). */
const useMillion = mode.toLowerCase() !== "firefox";
export default defineConfig(({ command }) => ({
plugins: [
base64Loader,
@@ -30,7 +34,7 @@ export default defineConfig(({ command }) => ({
svelte({
emitCss: false,
}),
million.vite({ auto: true }),
...(useMillion ? [million.vite({ auto: true })] : []),
crx({
manifest:
targets.find((t) => t.browser === mode.toLowerCase())?.manifest ??
@@ -38,7 +42,7 @@ export default defineConfig(({ command }) => ({
browser: mode.toLowerCase() === "firefox" ? "firefox" : "chrome",
}),
touchGlobalCSSPlugin(),
...(command === "build" ? [ClosePlugin()] : []),
...(command === "build" ? [ClosePlugin(), firefoxStripFunctionProbe()] : []),
],
root: resolve(__dirname, "./src"),
resolve: {
@@ -56,9 +60,7 @@ export default defineConfig(({ command }) => ({
},
css: {
preprocessorOptions: {
scss: {
api: "modern",
},
scss: {},
},
},
optimizeDeps: {
@@ -84,6 +86,9 @@ export default defineConfig(({ command }) => ({
settings: join(__dirname, "src", "interface", "index.html"),
pageState: join(__dirname, "src", "pageState.js"),
},
output: {
assetFileNames: "assets/[name]-[hash][extname]",
},
onwarn(warning, warn) {
if (warning.code === "FILE_NAME_CONFLICT") return;
warn(warning);