Merge origin/main and combine changelog updates in OpenWhatsNewPopup.

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-13 19:27:06 +09:30
32 changed files with 2598 additions and 178 deletions
+21 -22
View File
@@ -5,31 +5,30 @@
"es2021": true, "es2021": true,
"node": true "node": true
}, },
"extends": "eslint:recommended", "extends": ["eslint:recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": { "parserOptions": {
"ecmaVersion": "latest", "ecmaVersion": "latest",
"sourceType": "module" "sourceType": "module"
}, },
"rules": { "plugins": ["@typescript-eslint", "import"],
// allow importing ts extensions "ignorePatterns": ["**/*.d.ts"],
"sort-imports": [ "globals": {
"error", "__ENABLE_GH_RELEASE_UPDATE_CHECK__": "readonly",
{ "__GH_RELEASE_REPO__": "readonly",
"ignoreCase": true, "__UPDATE_CHANNEL__": "readonly",
"ignoreDeclarationSort": true, "__BUILD_LABEL__": "readonly"
"ignoreMemberSort": false,
"memberSyntaxSortOrder": ["none", "all", "multiple", "single"]
}
],
"import/extensions": [
"error",
"ignorePackages",
{
"js": "never",
"ts": "never",
"tsx": "never"
}
]
}, },
"plugins": ["import"] "rules": {
"no-unused-vars": "off",
"no-undef": "off",
"no-useless-escape": "off",
"no-prototype-builtins": "off",
"no-empty": "off",
"no-case-declarations": "off",
"no-irregular-whitespace": "off",
"sort-imports": "off",
"import/extensions": "off",
"no-async-promise-executor": "off"
}
} }
@@ -0,0 +1,67 @@
name: Build Extension
description: Install dependencies, build Chrome and Firefox extensions, and package zips.
inputs:
gh_release_update_check:
description: Enable GitHub release update checker in the built extension.
required: false
default: "false"
update_channel:
description: Update channel baked into the build (stable or nightly).
required: false
default: stable
build_label:
description: Optional build label for nightly display (e.g. run number).
required: false
default: ""
release_repo:
description: GitHub repo slug for the update checker (owner/name).
required: false
default: BetterSEQTA/BetterSEQTA-Plus
outputs:
version:
description: Version from package.json
value: ${{ steps.version.outputs.version }}
chrome_zip:
description: Path to the Chrome extension zip
value: ${{ steps.zip.outputs.chrome_zip }}
firefox_zip:
description: Path to the Firefox extension zip
value: ${{ steps.zip.outputs.firefox_zip }}
runs:
using: composite
steps:
- name: Use Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Install dependencies
shell: bash
run: npm install --legacy-peer-deps
- name: Read version
id: version
shell: bash
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
- name: Build extension
shell: bash
env:
GH_RELEASE_UPDATE_CHECK: ${{ inputs.gh_release_update_check }}
UPDATE_CHANNEL: ${{ inputs.update_channel }}
GH_RELEASE_REPO: ${{ inputs.release_repo }}
BUILD_LABEL: ${{ inputs.build_label }}
run: npm run build
- name: Package zips
id: zip
shell: bash
run: |
VERSION="${{ steps.version.outputs.version }}"
(cd dist/chrome && zip -r "../betterseqtaplus-${VERSION}-chrome.zip" .)
(cd dist/firefox && zip -r "../betterseqtaplus-${VERSION}-firefox.zip" .)
echo "chrome_zip=dist/betterseqtaplus-${VERSION}-chrome.zip" >> "$GITHUB_OUTPUT"
echo "firefox_zip=dist/betterseqtaplus-${VERSION}-firefox.zip" >> "$GITHUB_OUTPUT"
+1
View File
@@ -0,0 +1 @@
Experimental nightly build from `main`. May break. **Do not upload to Chrome Web Store or Firefox Add-ons.** Download, replace your sideloaded copy, and reload the extension.
-2
View File
@@ -3,8 +3,6 @@ name: NodeJS Build
on: on:
push: push:
branches: ["main"] branches: ["main"]
pull_request:
branches: ["main"]
jobs: jobs:
build: build:
+46
View File
@@ -0,0 +1,46 @@
# Nightly release workflow — updates the same "nightly" release with fresh builds from main.
# Runs only on BetterSEQTA/BetterSEQTA-Plus. Uses the default GITHUB_TOKEN.
name: Nightly Release
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
permissions:
contents: write
env:
NIGHTLY_TAG: nightly
jobs:
nightly:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build extension
id: build
uses: ./.github/actions/build-extension
with:
gh_release_update_check: "true"
update_channel: nightly
build_label: ${{ github.run_number }}
release_repo: ${{ github.repository }}
- name: Ensure nightly release exists
run: |
if ! gh release view "${{ env.NIGHTLY_TAG }}" 2>/dev/null; then
gh release create "${{ env.NIGHTLY_TAG }}" \
--prerelease \
--title "Nightly" \
--notes-file .github/nightly-release-notes.md
fi
- name: Upload nightly assets
run: |
gh release upload "${{ env.NIGHTLY_TAG }}" \
--clobber \
"${{ steps.build.outputs.chrome_zip }}" \
"${{ steps.build.outputs.firefox_zip }}"
+29
View File
@@ -0,0 +1,29 @@
name: PR CI
on:
pull_request:
branches: ["main"]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Install dependencies
run: npm install --legacy-peer-deps
- name: Lint
run: npm run lint
env:
ESLINT_USE_FLAT_CONFIG: "false"
- name: Build extension
uses: ./.github/actions/build-extension
with:
gh_release_update_check: "false"
+56
View File
@@ -0,0 +1,56 @@
# Manual release workflow only — runs on BetterSEQTA/BetterSEQTA-Plus via workflow_dispatch.
# Bump package.json version on main, confirm when dispatching, then run.
# Uses the default GITHUB_TOKEN (contents: write).
name: Release
on:
workflow_dispatch:
inputs:
version_updated:
description: I have already updated the version in package.json
type: boolean
required: true
default: false
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Confirm version was updated
if: inputs.version_updated != true
run: |
echo "Check the confirmation box: you must update package.json version before releasing."
exit 1
- uses: actions/checkout@v4
- name: Read version
id: version
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
- name: Build extension
id: build
uses: ./.github/actions/build-extension
with:
gh_release_update_check: "true"
update_channel: stable
release_repo: ${{ github.repository }}
- name: Create release if missing
run: |
if ! gh release view "${{ steps.version.outputs.version }}" 2>/dev/null; then
gh release create "${{ steps.version.outputs.version }}" \
--title "${{ steps.version.outputs.version }}" \
--notes "Edit this release description on GitHub."
fi
- name: Upload release assets
run: |
gh release upload "${{ steps.version.outputs.version }}" \
--clobber \
"${{ steps.build.outputs.chrome_zip }}" \
"${{ steps.build.outputs.firefox_zip }}"
+1
View File
@@ -9,6 +9,7 @@
- [Documentation home](https://docs.betterseqta.org/) - [Documentation home](https://docs.betterseqta.org/)
- [Installation](https://docs.betterseqta.org/install/) - [Installation](https://docs.betterseqta.org/install/)
- [Contributing](https://docs.betterseqta.org/contributing/) - [Contributing](https://docs.betterseqta.org/contributing/)
- [GitHub releases & CI](RELEASES.md) — workflows, nightly builds, update detector
- [Architecture](https://docs.betterseqta.org/architecture/) - [Architecture](https://docs.betterseqta.org/architecture/)
- [Contribution guidelines (repository)](../CONTRIBUTING.md) - [Contribution guidelines (repository)](../CONTRIBUTING.md)
- [Troubleshooting](https://docs.betterseqta.org/troubleshooting/) - [Troubleshooting](https://docs.betterseqta.org/troubleshooting/)
+266
View File
@@ -0,0 +1,266 @@
# GitHub releases, CI, and the update detector
BetterSEQTA+ is distributed on the Chrome Web Store and Firefox Add-ons, but some users sideload builds from GitHub. This document explains how automated builds, releases, and the in-extension update badge work.
All published releases target the upstream repository: **[BetterSEQTA/BetterSEQTA-Plus](https://github.com/BetterSEQTA/BetterSEQTA-Plus)**.
---
## Overview
```mermaid
flowchart TB
subgraph ci [Continuous integration]
PrCi[pr-ci.yml — every PR]
Mvp[mvp.yml — push to main]
end
subgraph releases [GitHub releases]
Manual[release.yml — manual stable]
Nightly[nightly.yml — daily cron]
end
subgraph output [Outputs]
Zips["betterseqtaplus-VERSION-chrome.zip\nbetterseqtaplus-VERSION-firefox.zip"]
GhStable["Release tag: 3.7.0"]
GhNightly["Release tag: nightly"]
Badge[Update badge in settings]
end
PrCi -->|build only| NoRelease[No release]
Mvp -->|artifact| DistZip[dist.zip artifact]
Manual --> Zips
Manual --> GhStable
Manual --> Badge
Nightly --> Zips
Nightly --> GhNightly
Nightly --> Badge
```
| Workflow | Trigger | Creates a release? | Update detector in build? |
|----------|---------|------------------|---------------------------|
| `pr-ci.yml` | Every pull request to `main` | No | No |
| `mvp.yml` | Every push to `main` | No | No |
| `release.yml` | Manual (`workflow_dispatch`) | Yes — stable version tag | Yes — stable channel |
| `nightly.yml` | Daily at 03:00 UTC + manual | Yes — reuses `nightly` tag | Yes — nightly channel |
---
## Shared build action
All release and CI builds that need packaged extensions use the composite action at [`.github/actions/build-extension/action.yml`](../.github/actions/build-extension/action.yml).
It:
1. Installs dependencies (`npm install --legacy-peer-deps`)
2. Runs `npm run build` (Chrome then Firefox via Vite)
3. Zips each unpacked folder into sideload-ready archives:
- `dist/betterseqtaplus-{version}-chrome.zip`
- `dist/betterseqtaplus-{version}-firefox.zip`
The `{version}` comes from `package.json`.
### Build-time flags (release builds only)
Release and nightly workflows pass environment variables into Vite, which bakes them into the extension at compile time via `define` in [`vite.config.ts`](../vite.config.ts):
| Variable | Stable release | Nightly | PR / local dev |
|----------|----------------|---------|----------------|
| `GH_RELEASE_UPDATE_CHECK` | `true` | `true` | `false` / unset |
| `UPDATE_CHANNEL` | `stable` | `nightly` | `stable` (unused) |
| `GH_RELEASE_REPO` | `BetterSEQTA/BetterSEQTA-Plus` | same | same |
| `BUILD_LABEL` | empty | GitHub run number | empty |
When `GH_RELEASE_UPDATE_CHECK` is not `true`, the update-checker code is tree-shaken out of the bundle. PR CI builds and local `npm run build` do **not** include the update badge.
To test a release-style build locally:
```bash
# PowerShell
$env:GH_RELEASE_UPDATE_CHECK="true"
$env:UPDATE_CHANNEL="stable"
npm run build
# bash
GH_RELEASE_UPDATE_CHECK=true UPDATE_CHANNEL=stable npm run build
```
---
## Stable release (`release.yml`)
**Purpose:** A safe, manual way to publish versioned builds that users can download from GitHub.
**Trigger:** Manual only — Actions → **Release****Run workflow**. There is no schedule or automatic trigger.
When dispatching, check **“I have already updated the version in package.json”**. The workflow will not run without that confirmation.
### Before you run it
1. Merge your changes to `main`.
2. Bump `version` in [`package.json`](../package.json) (e.g. `3.7.0``3.8.0`).
3. Commit and push that bump.
### What the workflow does
1. Aborts if the version confirmation was not checked.
2. Reads the version from `package.json`.
3. Builds Chrome and Firefox with the update detector enabled (stable channel).
4. If no release exists for that tag: creates one (e.g. `3.8.0`) with a placeholder description.
5. If a release already exists for that tag: **only replaces the zip assets** (`--clobber`). Title and body are left unchanged.
6. Uploads `betterseqtaplus-{version}-chrome.zip` and `-firefox.zip`.
On first publish, edit the **title** and **release notes** on GitHub afterward. Re-running for the same version refreshes the files only.
### Downloading and installing
1. Open [github.com/BetterSEQTA/BetterSEQTA-Plus/releases](https://github.com/BetterSEQTA/BetterSEQTA-Plus/releases).
2. Pick the version you want.
3. Download `betterseqtaplus-{version}-chrome.zip` or `-firefox.zip`.
4. Unzip and load the unpacked folder as a temporary extension (Chrome: Extensions → Load unpacked; Firefox: `about:debugging` → Load Temporary Add-on).
**These GitHub builds are for sideloading only. Do not upload them to the Chrome Web Store or Firefox Add-ons.**
---
## Nightly release (`nightly.yml`)
**Purpose:** Continuous experimental builds from `main` so testers always have a single place to get the latest code.
**Trigger:**
- Automatically every day at **03:00 UTC**
- Manually via Actions → **Nightly Release****Run workflow**
### What the workflow does
1. Builds from the current `main` branch with the update detector enabled (nightly channel).
2. Uses a fixed release tag: **`nightly`** (marked as prerelease).
3. On first run: creates the `nightly` release with the text in [`.github/nightly-release-notes.md`](../.github/nightly-release-notes.md).
4. On every subsequent run: **replaces** the zip assets on the same release (`--clobber`). The release title and body are not rewritten.
The nightly release body warns that builds are experimental and must not be uploaded to extension stores.
---
## PR CI (`pr-ci.yml`)
**Purpose:** Verify that every pull request builds cleanly in a fresh environment.
**Trigger:** Every pull request targeting `main`.
**Steps:**
1. `npm install --legacy-peer-deps`
2. `npm run lint` (ESLint on `src/**/*.{js,ts}`)
3. Build via the shared action with **no** update detector
No release is created and no artifacts are published for end users.
---
## Push CI (`mvp.yml`)
**Purpose:** Build verification when code lands on `main`.
**Trigger:** Push to `main` only (not pull requests — those use `pr-ci.yml`).
Uploads a `dist.zip` artifact containing the full `dist/` folder for debugging. No release, no update detector.
---
## Authentication
Release workflows run **only on [BetterSEQTA/BetterSEQTA-Plus](https://github.com/BetterSEQTA/BetterSEQTA-Plus)**. They use the default `GITHUB_TOKEN` with `contents: write` — no extra secrets or PATs required.
---
## Update detector (in-extension)
GitHub release builds include a small feature that tells sideload users when a newer build is available, so they do not have to check GitHub manually.
### Where it appears
In the **settings popup** header (top-right): an amber **“Update available — X.X.X”** pill when an update exists, plus a muted line:
> GitHub release build — do not upload to extension stores.
Clicking the badge opens the relevant GitHub releases page.
### How it checks for updates
Implementation: [`src/utils/githubReleaseUpdate.ts`](../src/utils/githubReleaseUpdate.ts)
**Stable channel** (from `release.yml` builds):
1. Fetches releases from the GitHub API for `BetterSEQTA/BetterSEQTA-Plus`.
2. Ignores the `nightly` tag and any non-semver tags.
3. Finds the highest semver tag.
4. Compares it to `browser.runtime.getManifest().version`.
5. Shows the badge if the remote version is newer.
**Nightly channel** (from `nightly.yml` builds):
1. Fetches the `nightly` release.
2. Compares its `published_at` timestamp to `lastSeenNightlyPublishedAt` in extension storage.
3. On first install, records the current publish time without showing a badge.
4. Shows **“Update available — nightly #123”** (run number) when a newer nightly has been published.
Checks are throttled to once every **6 hours** per browser profile (`localStorage` key `bsplus_lastGhReleaseCheck`). Recent results are cached in memory for the session.
### Dev override (testing)
To test the badge without publishing a real release:
1. Open settings and unlock **dev mode** (click the logo, type `dev`).
2. In the developer section, set **GitHub latest version override** to a version higher than your installed one (e.g. `9.9.9`).
3. Reopen settings — the badge should appear.
This only applies when dev mode is on. Clear the field to return to normal API checks.
---
## File reference
| Path | Role |
|------|------|
| `.github/actions/build-extension/action.yml` | Shared install, build, zip |
| `.github/workflows/release.yml` | Manual stable releases |
| `.github/workflows/nightly.yml` | Nightly releases |
| `.github/workflows/pr-ci.yml` | PR lint + build |
| `.github/workflows/mvp.yml` | Push-to-main build artifact |
| `.github/nightly-release-notes.md` | Static body for the `nightly` release |
| `vite.config.ts` | Injects build-time `define` flags |
| `src/env.d.ts` | TypeScript declarations for those flags |
| `src/utils/githubReleaseUpdate.ts` | Update check logic |
| `src/interface/pages/settings.svelte` | Badge and disclaimer UI |
| `src/interface/pages/settings/general.svelte` | Dev override input |
| `src/types/storage.ts` | `devGhReleaseVersionOverride`, `lastSeenNightlyPublishedAt` |
---
## Quick reference
### Publish a stable release
```text
1. Bump version in package.json on main
2. Actions → Release → Run workflow → confirm version checkbox
3. Edit release title/notes on GitHub (first time only)
4. Re-run with same version to refresh zips without changing release text
```
### Get the latest nightly
```text
https://github.com/BetterSEQTA/BetterSEQTA-Plus/releases/tag/nightly
```
### Verify a PR locally
```bash
npm run lint
npm run build
```
+23 -3
View File
@@ -119,12 +119,13 @@ git checkout -b feature/my-new-feature
If your changes require documentation updates, include them in the same PR. If your changes require documentation updates, include them in the same PR.
4. **Run Tests** 4. **Run CI checks locally**
Make sure all tests pass before submitting your PR: Pull requests trigger **PR CI**, which lints and builds in a clean environment:
```bash ```bash
npm test npm run lint
npm run build
``` ```
5. **Submit Your PR** 5. **Submit Your PR**
@@ -139,6 +140,25 @@ git checkout -b feature/my-new-feature
Once approved, a maintainer will merge your PR. Once approved, a maintainer will merge your PR.
### GitHub Actions workflows
See [RELEASES.md](RELEASES.md) for a full guide (workflows, sideload installs, and update detector).
| Workflow | Trigger | Purpose |
|----------|---------|---------|
| `pr-ci.yml` | Every PR to `main` | Typecheck, lint, and build (no release) |
| `mvp.yml` | Push to `main` | Build and upload `dist.zip` artifact |
| `release.yml` | Manual (`workflow_dispatch`) | Stable release on [BetterSEQTA/BetterSEQTA-Plus](https://github.com/BetterSEQTA/BetterSEQTA-Plus) tagged with `package.json` version |
| `nightly.yml` | Daily cron + manual | Updates the fixed `nightly` prerelease with latest `main` builds |
**Releasing a stable version:** bump `version` in `package.json` on `main`, then manually run the **Release** workflow and confirm the version checkbox. Edit the release title and notes on GitHub after the first publish. Re-running for the same tag only updates the zip files. Assets are `betterseqtaplus-{version}-chrome.zip` and `-firefox.zip`.
**Nightly builds** replace assets on the same `nightly` release. The release body warns that builds are experimental and must not be uploaded to extension stores.
**GitHub release builds** include an in-extension update checker (settings header badge). **PR CI and local dev builds do not.**
Release workflows are dispatched only on the main **BetterSEQTA/BetterSEQTA-Plus** repository and use the default `GITHUB_TOKEN`.
### Coding Standards ### Coding Standards
We follow TypeScript best practices and have a consistent code style: We follow TypeScript best practices and have a consistent code style:
+5 -1
View File
@@ -17,7 +17,8 @@
"build:dev": "cross-env MODE=chrome SOURCEMAP=true vite build && cross-env MODE=firefox SOURCEMAP=true vite build", "build:dev": "cross-env MODE=chrome SOURCEMAP=true vite build && cross-env MODE=firefox SOURCEMAP=true vite build",
"convert:safari": "xcrun safari-web-extension-converter dist/safari --project-location . --app-name $npm_package_name-safari", "convert:safari": "xcrun safari-web-extension-converter dist/safari --project-location . --app-name $npm_package_name-safari",
"dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg", "dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg",
"release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes", "lint": "cross-env ESLINT_USE_FLAT_CONFIG=false eslint \"src/**/*.{js,ts}\"",
"release": "gh release create $npm_package_version --repo BetterSEQTA/BetterSEQTA-Plus ./dist/*.zip --generate-notes",
"publish": "bun lib/publish.js --b", "publish": "bun lib/publish.js --b",
"zip": "bedframe zip" "zip": "bedframe zip"
}, },
@@ -45,9 +46,12 @@
"@types/mime-types": "^3.0.1", "@types/mime-types": "^3.0.1",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@typescript-eslint/eslint-plugin": "^8.60.1",
"@typescript-eslint/parser": "^8.60.1",
"cross-env": "^10.0.0", "cross-env": "^10.0.0",
"dependency-cruiser": "^17.0.1", "dependency-cruiser": "^17.0.1",
"eslint": "^9.33.0", "eslint": "^9.33.0",
"eslint-plugin-import": "^2.31.0",
"glob": "^11.0.1", "glob": "^11.0.1",
"mime-types": "^3.0.1", "mime-types": "^3.0.1",
"prettier": "^3.5.3", "prettier": "^3.5.3",
+327 -18
View File
@@ -3809,19 +3809,306 @@ div.day-empty {
color: var(--text-primary); color: var(--text-primary);
} }
.themeOfTheMonthBackdrop {
position: fixed;
inset: 0;
z-index: 47;
background: color-mix(in srgb, #000 52%, transparent);
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
opacity: 0;
pointer-events: none;
transition: opacity 0.55s cubic-bezier(0.76, 0, 0.24, 1);
}
.themeOfTheMonthBackdropVisible {
opacity: 1;
pointer-events: auto;
}
.themeOfTheMonthCard { .themeOfTheMonthCard {
position: fixed; position: fixed;
right: max(18px, env(safe-area-inset-right)); top: 0;
bottom: max(18px, env(safe-area-inset-bottom)); left: 0;
right: auto;
bottom: auto;
z-index: 48; z-index: 48;
margin: 0;
width: min(360px, calc(100vw - 36px)); width: min(360px, calc(100vw - 36px));
overflow: visible; display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
border-radius: 20px; border-radius: 20px;
background: var(--background-primary); background: var(--background-primary);
color: var(--text-primary); color: var(--text-primary);
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35); box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
animation: themeOfTheMonthCardIn 0.24s ease-out; transform-origin: bottom right;
transition: none;
animation: themeOfTheMonthCardIn 0.28s ease-out;
}
.themeOfTheMonthCardExpanded {
transform-origin: center center;
}
/* translate(x,y) is set inline; transition enabled after mount */
.themeOfTheMonthCardMorphReady:not(.themeOfTheMonthCardReducedMotion) {
transition:
transform 0.55s cubic-bezier(0.76, 0, 0.24, 1),
width 0.55s cubic-bezier(0.76, 0, 0.24, 1),
height 0.55s cubic-bezier(0.76, 0, 0.24, 1),
max-height 0.55s cubic-bezier(0.76, 0, 0.24, 1),
border-radius 0.55s cubic-bezier(0.76, 0, 0.24, 1);
}
.themeOfTheMonthCardAnchoredBottom,
.themeOfTheMonthCardCollapsing {
transform-origin: 100% 100% !important;
}
/* Expanded: fixed shell; copy scrolls; actions pinned to the bottom. */
.themeOfTheMonthCardExpanded.themeOfTheMonthCardExpandedShell .themeOfTheMonthCardBody {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
padding: 14px 16px 16px;
}
.themeOfTheMonthCardExpanded.themeOfTheMonthCardExpandedShell .themeOfTheMonthCardDescription {
flex: 1 1 auto;
min-height: 0;
margin: 8px 0 0;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.themeOfTheMonthCardExpanded.themeOfTheMonthCardExpandedShell .themeOfTheMonthCardActions {
flex-shrink: 0;
margin-top: auto;
padding-top: 14px;
}
.themeOfTheMonthCardReducedMotion {
transition: none !important;
}
.themeOfTheMonthCardMedia {
position: relative;
flex-shrink: 0;
}
#theme-of-the-month-card .themeOfTheMonthCardPopout {
position: absolute;
top: 12px;
left: 12px;
z-index: 6;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
min-width: 36px;
min-height: 36px;
padding: 0;
appearance: none;
border: none;
border-radius: 50% !important;
aspect-ratio: 1;
cursor: pointer;
color: var(--text-primary);
background: color-mix(in srgb, var(--background-primary) 80%, transparent);
box-shadow:
0 2px 10px rgba(0, 0, 0, 0.24),
inset 0 0 0 1px color-mix(in srgb, var(--text-primary) 14%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
transition: background 0.15s ease, transform 0.15s ease;
}
.themeOfTheMonthCardPopoutIcon {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
pointer-events: none;
}
.themeOfTheMonthCardPopout:hover {
background: color-mix(in srgb, var(--background-primary) 95%, transparent);
transform: scale(1.05);
}
.themeOfTheMonthCardPopout:active {
transform: scale(0.96);
}
.themeOfTheMonthCardPopout[hidden] {
display: none;
}
.themeOfTheMonthCardCompactMedia {
position: relative;
display: block;
overflow: hidden;
border-radius: 20px 20px 0 0;
line-height: 0;
}
.themeOfTheMonthCardExpanded.themeOfTheMonthCardShowGallery .themeOfTheMonthCardCompactMedia {
display: none;
}
.themeOfTheMonthCardExpandedPanel {
display: none;
}
.themeOfTheMonthCardExpandedPanel[hidden] {
display: none !important;
}
.themeOfTheMonthCardExpanded.themeOfTheMonthCardShowGallery
.themeOfTheMonthCardExpandedPanel:not([hidden]) {
display: block;
}
.themeOfTheMonthCardGallery {
position: relative;
}
.themeOfTheMonthCardHeroEmboss {
display: none;
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 4;
flex-direction: column;
justify-content: flex-end;
pointer-events: none;
}
.themeOfTheMonthCardHeroEmbossScrim {
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
transparent 0%,
color-mix(in srgb, #000 8%, transparent) 40%,
color-mix(in srgb, #000 55%, transparent) 72%,
color-mix(in srgb, #000 82%, transparent) 100%
);
}
.themeOfTheMonthCardHeroEmbossContent {
position: relative;
z-index: 1;
}
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmboss {
display: flex;
padding: 14px 16px 16px;
}
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossTitle {
margin: 0;
font-size: 1.45rem;
font-weight: 800;
line-height: 1.15;
letter-spacing: -0.02em;
color: #fff;
text-shadow:
0 1px 2px rgba(0, 0, 0, 0.55),
0 2px 14px rgba(0, 0, 0, 0.35);
}
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossAuthor {
margin: 4px 0 0;
font-size: 0.8rem;
font-weight: 600;
line-height: 1.25;
color: color-mix(in srgb, #fff 88%, transparent);
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.45);
}
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossDescription {
margin: 8px 0 0;
font-size: 0.84rem;
line-height: 1.4;
color: color-mix(in srgb, #fff 92%, transparent);
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.5);
}
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossVariants {
margin: 6px 0 0;
font-size: 0.74rem;
font-weight: 600;
line-height: 1.2;
color: color-mix(in srgb, #fff 72%, transparent);
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.4);
}
.themeOfTheMonthCardExpanded.themeOfTheMonthCardShowGallery
.themeOfTheMonthCardGallerySlide
figcaption {
display: none;
}
.themeOfTheMonthCardGalleryTrack {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none;
}
.themeOfTheMonthCardGalleryTrack::-webkit-scrollbar {
display: none;
}
.themeOfTheMonthCardGallerySlide {
flex: 0 0 100%;
margin: 0;
scroll-snap-align: start;
}
.themeOfTheMonthCardGallerySlide img {
display: block;
width: 100%;
height: min(42vh, 280px);
object-fit: cover;
}
.themeOfTheMonthCardGallerySlide figcaption {
padding: 8px 14px 0;
font-size: 0.78rem;
line-height: 1.3;
color: color-mix(in srgb, var(--text-primary) 68%, transparent);
}
.themeOfTheMonthCardGalleryPrev,
.themeOfTheMonthCardGalleryNext {
position: absolute;
top: 50%;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
margin-top: -17px;
padding: 0;
appearance: none;
border: none;
border-radius: 9999px;
cursor: pointer;
font-size: 1.35rem;
line-height: 1;
color: var(--text-primary);
background: color-mix(in srgb, var(--background-primary) 88%, transparent);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}
.themeOfTheMonthCardGalleryPrev {
left: 10px;
}
.themeOfTheMonthCardGalleryNext {
right: 10px;
}
.themeOfTheMonthCardGalleryDots {
display: flex;
justify-content: center;
gap: 6px;
padding: 8px 14px 0;
}
.themeOfTheMonthCardGalleryDot {
width: 7px;
height: 7px;
padding: 0;
appearance: none;
border: none;
border-radius: 9999px;
cursor: pointer;
background: color-mix(in srgb, var(--text-primary) 28%, transparent);
transition: background 0.15s ease, transform 0.15s ease;
}
.themeOfTheMonthCardGalleryDotActive {
background: var(--better-pri, #6366f1);
transform: scale(1.15);
}
.themeOfTheMonthCardDescriptionTyping {
display: block;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: unset;
} }
.themeOfTheMonthCard::before { .themeOfTheMonthCard::before {
content: ""; content: "";
@@ -3908,16 +4195,25 @@ div.day-empty {
.themeOfTheMonthCardConfirmAccept:active { .themeOfTheMonthCardConfirmAccept:active {
transform: translateY(0); transform: translateY(0);
} }
.themeOfTheMonthCardImage { #theme-of-the-month-card .themeOfTheMonthCardImage {
display: block; display: block;
width: 100%; width: 100% !important;
height: 150px; min-width: 100%;
height: 150px !important;
max-width: none !important;
max-height: none !important;
margin: 0; margin: 0;
border-radius: 20px 20px 0 0; padding: 0;
border: 0;
border-radius: 0;
object-fit: cover; object-fit: cover;
object-position: center center;
}
.themeOfTheMonthCardExpanded .themeOfTheMonthCardGallerySlide img {
border-radius: 22px 22px 0 0;
} }
.themeOfTheMonthCardBody { .themeOfTheMonthCardBody {
padding: 14px 16px 16px; padding: 14px 16px 12px;
} }
.themeOfTheMonthCardEyebrow { .themeOfTheMonthCardEyebrow {
margin: 0 0 6px; margin: 0 0 6px;
@@ -3933,14 +4229,26 @@ div.day-empty {
line-height: 1.2; line-height: 1.2;
} }
.themeOfTheMonthCardDescription { .themeOfTheMonthCardDescription {
display: -webkit-box; margin: 8px 0 10px;
margin: 8px 0 14px;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
font-size: 0.92rem; font-size: 0.92rem;
line-height: 1.45; line-height: 1.45;
color: color-mix(in srgb, var(--text-primary) 78%, transparent); color: color-mix(in srgb, var(--text-primary) 78%, transparent);
overflow-wrap: anywhere;
word-wrap: break-word;
}
.themeOfTheMonthCardDescriptionClipped {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.themeOfTheMonthCardDescriptionExpanded {
display: block;
-webkit-line-clamp: unset;
}
.themeOfTheMonthCardExpanding:not(.themeOfTheMonthCardExpandedShell)
.themeOfTheMonthCardDescriptionExpanded {
overflow: hidden;
} }
.themeOfTheMonthCardActions { .themeOfTheMonthCardActions {
display: flex; display: flex;
@@ -4028,21 +4336,22 @@ div.day-empty {
@keyframes themeOfTheMonthCardIn { @keyframes themeOfTheMonthCardIn {
from { from {
opacity: 0; opacity: 0;
transform: translateY(18px) scale(0.98);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0) scale(1);
} }
} }
@keyframes themeOfTheMonthCardOut { @keyframes themeOfTheMonthCardOut {
to { to {
opacity: 0; opacity: 0;
transform: translateY(12px) scale(0.98);
} }
} }
.themeOfTheMonthCardExpanded.themeOfTheMonthCardClosing {
animation: themeOfTheMonthCardOut 0.18s ease-in forwards;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.themeOfTheMonthCard { .themeOfTheMonthCard,
.themeOfTheMonthBackdrop {
z-index: 2147483645; z-index: 2147483645;
} }
} }
+4
View File
@@ -0,0 +1,4 @@
declare const __ENABLE_GH_RELEASE_UPDATE_CHECK__: boolean;
declare const __GH_RELEASE_REPO__: string;
declare const __UPDATE_CHANNEL__: "stable" | "nightly";
declare const __BUILD_LABEL__: string;
+5 -4
View File
@@ -3,6 +3,7 @@
import { animate } from "motion"; import { animate } from "motion";
import { delay } from "@/seqta/utils/delay.ts"; import { delay } from "@/seqta/utils/delay.ts";
import { cloudAuth } from "@/seqta/utils/CloudAuth"; import { cloudAuth } from "@/seqta/utils/CloudAuth";
import CloudPfpAvatar from "@/interface/components/CloudPfpAvatar.svelte";
const { hidePanel } = $props<{ const { hidePanel } = $props<{
hidePanel: () => void; hidePanel: () => void;
@@ -105,12 +106,12 @@
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{#if cloudState.user?.pfpUrl} {#if cloudState.user?.pfpUrl}
<img <CloudPfpAvatar
src={cloudState.user.pfpUrl} user={cloudState.user}
alt=""
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600" class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
/> />
{:else} {/if}
{#if !cloudState.user?.pfpUrl}
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base"> <div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
{getInitials()} {getInitials()}
</div> </div>
@@ -0,0 +1,44 @@
<script lang="ts">
import { resolveCloudPfp } from "@/seqta/utils/cloudPfpCache";
import type { CloudUser } from "@/seqta/utils/CloudAuth";
const { user, class: className = "" } = $props<{
user: CloudUser | null | undefined;
class?: string;
}>();
let avatarSrc = $state<string | undefined>(undefined);
let revokeUrl: string | undefined;
$effect(() => {
const u = user;
if (revokeUrl) {
URL.revokeObjectURL(revokeUrl);
revokeUrl = undefined;
}
avatarSrc = undefined;
if (!u?.pfpUrl || !u.id) return;
let cancelled = false;
void resolveCloudPfp(u.id, u.pfpUrl).then((resolved) => {
if (cancelled || !resolved) return;
if (resolved.fromCache) {
revokeUrl = resolved.src;
}
avatarSrc = resolved.src;
});
return () => {
cancelled = true;
if (revokeUrl) {
URL.revokeObjectURL(revokeUrl);
revokeUrl = undefined;
}
};
});
</script>
{#if avatarSrc}
<img src={avatarSrc} alt="" class={className} />
{/if}
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { cloudAuth } from "@/seqta/utils/CloudAuth"; import { cloudAuth } from "@/seqta/utils/CloudAuth";
import CloudPfpAvatar from "@/interface/components/CloudPfpAvatar.svelte";
let { alwaysShowUserName = false, onClick = undefined } = $props<{ let { alwaysShowUserName = false, onClick = undefined } = $props<{
alwaysShowUserName?: boolean; alwaysShowUserName?: boolean;
@@ -72,12 +73,12 @@
> >
{#if cloudState.isLoggedIn} {#if cloudState.isLoggedIn}
{#if cloudState.user?.pfpUrl} {#if cloudState.user?.pfpUrl}
<img <CloudPfpAvatar
src={cloudState.user.pfpUrl} user={cloudState.user}
alt=""
class="w-5 h-5 rounded-full object-cover ring-1 ring-zinc-200 dark:ring-zinc-600" class="w-5 h-5 rounded-full object-cover ring-1 ring-zinc-200 dark:ring-zinc-600"
/> />
{:else} {/if}
{#if !cloudState.user?.pfpUrl}
<div class="flex items-center justify-center w-5 h-5 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-[0.6rem]"> <div class="flex items-center justify-center w-5 h-5 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-[0.6rem]">
{getInitials()} {getInitials()}
</div> </div>
@@ -111,12 +112,12 @@
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{#if cloudState.user?.pfpUrl} {#if cloudState.user?.pfpUrl}
<img <CloudPfpAvatar
src={cloudState.user.pfpUrl} user={cloudState.user}
alt=""
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600" class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
/> />
{:else} {/if}
{#if !cloudState.user?.pfpUrl}
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base"> <div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
{getInitials()} {getInitials()}
</div> </div>
+44 -1
View File
@@ -19,6 +19,12 @@
import CloudPanel from "../components/CloudPanel.svelte"; import CloudPanel from "../components/CloudPanel.svelte";
import DisclaimerModal from "../components/DisclaimerModal.svelte"; import DisclaimerModal from "../components/DisclaimerModal.svelte";
import { settingsPopup } from "../hooks/SettingsPopup"; import { settingsPopup } from "../hooks/SettingsPopup";
import {
checkGithubReleaseUpdate,
dismissNightlyUpdate,
isGhReleaseUpdateCheckEnabled,
type GhReleaseUpdateInfo,
} from "@/utils/githubReleaseUpdate";
let devModeSequence = ""; let devModeSequence = "";
let settingsActiveTab = $state(0); let settingsActiveTab = $state(0);
@@ -26,6 +32,18 @@
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null); let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
let disclaimerTitle = $state("Confirm"); let disclaimerTitle = $state("Confirm");
let disclaimerMessage = $state(""); let disclaimerMessage = $state("");
const ghReleaseUpdateEnabled = isGhReleaseUpdateCheckEnabled();
let ghReleaseUpdate = $state<GhReleaseUpdateInfo | null>(null);
const openGhRelease = () => {
const url = ghReleaseUpdate?.url
?? "https://github.com/BetterSEQTA/BetterSEQTA-Plus/releases";
if (ghReleaseUpdate?.available) {
dismissNightlyUpdate();
}
window.open(url, "_blank");
closeExtensionPopup();
};
const handleDevModeToggle = () => { const handleDevModeToggle = () => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@@ -98,6 +116,12 @@
if (standalone) { if (standalone) {
StandaloneStore.setStandalone(true); StandaloneStore.setStandalone(true);
} }
if (ghReleaseUpdateEnabled) {
void checkGithubReleaseUpdate().then((info) => {
ghReleaseUpdate = info;
});
}
}); });
</script> </script>
@@ -134,7 +158,25 @@
/> />
{#if !standalone} {#if !standalone}
<div class="flex absolute top-1 right-1 gap-1 items-center"> <div class="flex absolute top-1 right-1 gap-1 items-start">
{#if ghReleaseUpdateEnabled}
<div class="flex flex-col items-end gap-0.5 max-w-[9rem] mr-0.5">
{#if ghReleaseUpdate?.available}
<button
type="button"
onclick={openGhRelease}
class="px-1.5 py-0.5 text-[10px] font-semibold leading-tight text-white rounded-full bg-amber-500 hover:bg-amber-600 dark:bg-amber-600 dark:hover:bg-amber-500"
title="Open GitHub release"
>
Update available — {ghReleaseUpdate.label}
</button>
{/if}
<p class="text-[9px] leading-tight text-right text-zinc-500 dark:text-zinc-400">
GitHub release build — do not upload to extension stores.
</p>
</div>
{/if}
<div class="flex gap-1 items-center">
<button <button
onclick={openAbout} onclick={openAbout}
class="flex justify-center items-center w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700" class="flex justify-center items-center w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700"
@@ -156,6 +198,7 @@
> >
{"\uecba"} {"\uecba"}
</button> </button>
</div>
<!-- <button <!-- <button
onclick={openMinecraftServer} onclick={openMinecraftServer}
@@ -585,6 +585,21 @@
{/if} {/if}
</div> </div>
</div> </div>
<div class="flex flex-col gap-2 px-4 py-3">
<div>
<h2 class="text-sm font-bold">GitHub latest version override</h2>
<p class="text-xs">Pretend a newer GitHub release exists to test the update badge. Only applies when dev mode is on.</p>
</div>
<input
type="text"
placeholder="e.g. 9.9.9"
value={$settingsState.devGhReleaseVersionOverride ?? ""}
oninput={(e) => {
settingsState.devGhReleaseVersionOverride = e.currentTarget.value;
}}
class="px-2 py-1 text-xs rounded border bg-white dark:bg-zinc-800 border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100"
/>
</div>
</div> </div>
{/if} {/if}
</div> </div>
@@ -15,11 +15,12 @@ import {
letterToNumber, letterToNumber,
parseAssessments, parseAssessments,
processAssessments, processAssessments,
type WeightingEntry,
} from "./utils.ts"; } from "./utils.ts";
import { injectRubricCopyButtons } from "./rubricCopy.ts"; import { injectRubricCopyButtons } from "./rubricCopy.ts";
interface weightingsStorage { interface weightingsStorage {
weightings: Record<string, string>; weightings: Record<string, WeightingEntry>;
assessments: Record<string, string>; assessments: Record<string, string>;
weightingOverrides: Record<string, string>; weightingOverrides: Record<string, string>;
} }
@@ -61,8 +62,8 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
1000, 1000,
); );
await parseAssessments(api); // Wire listeners first so the very first re-render triggered by a
await renderSubjectAverage(api); // background handleWeightings completion can find them.
overrideListenerController?.abort(); overrideListenerController?.abort();
overrideListenerController = new AbortController(); overrideListenerController = new AbortController();
document.addEventListener( document.addEventListener(
@@ -70,6 +71,21 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
() => renderSubjectAverage(api), () => renderSubjectAverage(api),
{ signal: overrideListenerController.signal }, { signal: overrideListenerController.signal },
); );
document.addEventListener(
"betterseqta:weightingsChanged",
() => renderSubjectAverage(api),
{ signal: overrideListenerController.signal },
);
// Render immediately with whatever is already cached. Fresh entries
// and stale-with-previous-value entries both contribute their numeric
// weights, so the subject average appears without waiting on any
// background PDF refetches.
await renderSubjectAverage(api);
// Kick off indexing in the background. Each completion dispatches
// betterseqta:weightingsChanged, which triggers a fresh render.
void parseAssessments(api);
const wrapper = document.querySelector(".assessmentsWrapper"); const wrapper = document.querySelector(".assessmentsWrapper");
if (wrapper) { if (wrapper) {
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
@@ -87,8 +103,15 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
}; };
let renderInFlight = false; let renderInFlight = false;
let renderQueued = false;
async function renderSubjectAverage(api: any) { async function renderSubjectAverage(api: any) {
if (renderInFlight) return; if (renderInFlight) {
// Coalesce: remember that fresh data arrived during this render and
// re-run once the current pass finishes, so the UI catches up to the
// latest storage state instead of silently dropping the event.
renderQueued = true;
return;
}
renderInFlight = true; renderInFlight = true;
try { try {
@@ -141,8 +164,13 @@ async function renderSubjectAverage(api: any) {
?.textContent?.includes("Subject Average"), ?.textContent?.includes("Subject Average"),
); );
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } = const {
await processAssessments(api, assessmentItems); weightedTotal,
totalWeight,
hasInaccurateWeighting,
hasRefreshingWeighting,
count,
} = await processAssessments(api, assessmentItems);
if (!count || totalWeight === 0) return; if (!count || totalWeight === 0) return;
const thermoscoreElement = document.querySelector( const thermoscoreElement = document.querySelector(
@@ -176,11 +204,22 @@ async function renderSubjectAverage(api: any) {
let warningHTML = ""; let warningHTML = "";
if (hasInaccurateWeighting) { if (hasInaccurateWeighting) {
warningHTML = /* html */ ` warningHTML = /* html */ `
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3;"> <div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3; white-space: nowrap;">
Some weightings unavailable Some weightings unavailable
</div> </div>
`; `;
} else if (hasRefreshingWeighting) {
warningHTML = /* html */ `
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.55); opacity: 0.8; line-height: 1.3; white-space: nowrap;" title="Some weightings are being re-checked; the average may change shortly">
Refreshing weightings
</div>
`;
} }
const thermoscoreTitle = hasInaccurateWeighting
? `${display} (some weightings unavailable)`
: hasRefreshingWeighting
? `${display} (re-checking weightings)`
: display;
assessmentsList.insertBefore( assessmentsList.insertBefore(
stringToHTML(/* html */ ` stringToHTML(/* html */ `
<div class="${assessmentItemClass}"> <div class="${assessmentItemClass}">
@@ -194,7 +233,7 @@ async function renderSubjectAverage(api: any) {
</div> </div>
<div class="${thermoscoreClass}"> <div class="${thermoscoreClass}">
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%"> <div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
<div class="${textClass}" title="${hasInaccurateWeighting ? display + " (some weightings unavailable)" : display}">${display}</div> <div class="${textClass}" title="${thermoscoreTitle}">${display}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -204,6 +243,10 @@ async function renderSubjectAverage(api: any) {
applySubjectColourToOverallResult(); applySubjectColourToOverallResult();
} finally { } finally {
renderInFlight = false; renderInFlight = false;
if (renderQueued) {
renderQueued = false;
void renderSubjectAverage(api);
}
} }
} }
function applySubjectColourToOverallResult() { function applySubjectColourToOverallResult() {
+157 -25
View File
@@ -14,6 +14,59 @@ import * as pdfjs from "pdfjs-dist";
ensurePdfjsWorker(); ensurePdfjsWorker();
export const WEIGHTING_SCHEMA_VERSION = 1;
export interface WeightingEntry {
weight: string;
fingerprint: string;
pluginVersion: number;
refreshing?: boolean;
}
export type WeightingsMap = Record<string, WeightingEntry>;
export function computeFingerprint(mark: any): string {
const score =
mark?.results?.percentage ?? mark?.results?.score ?? null;
return JSON.stringify([
mark?.status ?? "",
Boolean(mark?.graded),
mark?.availability ?? "",
score,
mark?.due ?? "",
mark?.title ?? "",
]);
}
function migrateWeightings(api: any) {
const w = api.storage.weightings ?? {};
let dirty = false;
const out: WeightingsMap = {};
for (const [id, v] of Object.entries(w)) {
if (typeof v === "string") {
out[id] = { weight: v, fingerprint: "", pluginVersion: 0 };
dirty = true;
} else if (v && typeof v === "object") {
const entry = v as Partial<WeightingEntry>;
if (
typeof entry.weight === "string" &&
typeof entry.fingerprint === "string" &&
typeof entry.pluginVersion === "number"
) {
out[id] = entry as WeightingEntry;
} else {
out[id] = {
weight: String(entry.weight ?? "N/A"),
fingerprint: "",
pluginVersion: 0,
};
dirty = true;
}
}
}
if (dirty) api.storage.weightings = out;
}
export async function initStorage(api: any) { export async function initStorage(api: any) {
await api.storage.loaded; await api.storage.loaded;
@@ -26,19 +79,34 @@ export async function initStorage(api: any) {
if (!api.storage.weightingOverrides) { if (!api.storage.weightingOverrides) {
api.storage.weightingOverrides = {}; api.storage.weightingOverrides = {};
} }
migrateWeightings(api);
} }
export function clearStuck(api: any) { export function clearStuck(api: any) {
let hasStuckProcessing = false; const map = (api.storage.weightings ?? {}) as WeightingsMap;
for (const key in api.storage.weightings) { let dirty = false;
if (api.storage.weightings[key] === "processing") { const out: WeightingsMap = {};
delete api.storage.weightings[key]; for (const [key, entry] of Object.entries(map)) {
hasStuckProcessing = true; if (!entry || typeof entry !== "object") {
dirty = true;
continue;
} }
if (entry.weight === "processing") {
// Stuck mid-fetch from a previous session: drop it so the next
// page load can re-run handleWeightings from scratch.
dirty = true;
continue;
}
if (entry.refreshing) {
const { refreshing: _ignored, ...rest } = entry;
out[key] = rest;
dirty = true;
continue;
}
out[key] = entry;
} }
if (hasStuckProcessing) { if (dirty) api.storage.weightings = out;
api.storage.weightings = { ...api.storage.weightings };
}
} }
// Helper function to find actual class names by their base pattern // Helper function to find actual class names by their base pattern
@@ -137,6 +205,7 @@ function updateWeightLabelContent(
weighting: string | undefined, weighting: string | undefined,
assessmentID: string | undefined, assessmentID: string | undefined,
api: any, api: any,
refreshing = false,
) { ) {
const existingInput = weightLabel.querySelector( const existingInput = weightLabel.querySelector(
".betterseqta-weight-input", ".betterseqta-weight-input",
@@ -178,10 +247,15 @@ function updateWeightLabelContent(
const span = document.createElement("span"); const span = document.createElement("span");
span.className = "betterseqta-weight-value"; span.className = "betterseqta-weight-value";
span.textContent = const baseText =
weighting && weighting !== "N/A" weighting && weighting !== "N/A"
? formatWeightDisplay(weighting) ? formatWeightDisplay(weighting)
: "N/A"; : "N/A";
span.textContent = refreshing ? `${baseText}` : baseText;
if (refreshing) {
span.style.opacity = "0.7";
weightLabel.title = "Re-checking weighting…";
}
weightLabel.appendChild(span); weightLabel.appendChild(span);
} }
@@ -189,6 +263,7 @@ function createWeightLabel(
assessmentItem: Element, assessmentItem: Element,
weighting: string | undefined, weighting: string | undefined,
api: any, api: any,
refreshing = false,
) { ) {
let statsContainer = assessmentItem.querySelector( let statsContainer = assessmentItem.querySelector(
`[class*='AssessmentItem__stats___'], .betterseqta-stats-container`, `[class*='AssessmentItem__stats___'], .betterseqta-stats-container`,
@@ -224,7 +299,13 @@ function createWeightLabel(
) as HTMLElement | null; ) as HTMLElement | null;
if (existingLabel) { if (existingLabel) {
updateWeightLabelContent(existingLabel, weighting, assessmentID, api); updateWeightLabelContent(
existingLabel,
weighting,
assessmentID,
api,
refreshing,
);
return; return;
} }
@@ -256,7 +337,13 @@ function createWeightLabel(
const innerTextDiv = weightLabel.querySelector(`[class*='Label__innerText___']`); const innerTextDiv = weightLabel.querySelector(`[class*='Label__innerText___']`);
if (innerTextDiv) innerTextDiv.textContent = "Weight"; if (innerTextDiv) innerTextDiv.textContent = "Weight";
updateWeightLabelContent(weightLabel, weighting, assessmentID, api); updateWeightLabelContent(
weightLabel,
weighting,
assessmentID,
api,
refreshing,
);
statsContainer.appendChild(weightLabel); statsContainer.appendChild(weightLabel);
} }
@@ -563,16 +650,41 @@ async function handleWeightings(mark: any, api: any) {
const metaclassID = mark.metaclassID; const metaclassID = mark.metaclassID;
const title = mark.title; const title = mark.title;
if ( const fingerprint = computeFingerprint(mark);
api.storage.weightings[assessmentID] != undefined && const existing = api.storage.weightings[assessmentID] as
api.storage.weightings[assessmentID] !== "processing" | WeightingEntry
) { | undefined;
return;
} const isFresh =
existing &&
existing.weight !== "processing" &&
existing.fingerprint === fingerprint &&
existing.pluginVersion === WEIGHTING_SCHEMA_VERSION;
if (isFresh) return;
// If we have a previous usable value, keep showing it while we refetch
// by marking the entry as refreshing instead of wiping it. We claim the
// new fingerprint + version on the placeholder so a second parseAssessments
// pass (e.g. a fast re-mount of the wrapper) doesn't kick off a duplicate
// refetch for the same id while this one is still in flight.
const placeholder: WeightingEntry =
existing && existing.weight !== "processing"
? {
...existing,
fingerprint,
pluginVersion: WEIGHTING_SCHEMA_VERSION,
refreshing: true,
}
: {
weight: "processing",
fingerprint,
pluginVersion: WEIGHTING_SCHEMA_VERSION,
};
api.storage.weightings = { api.storage.weightings = {
...api.storage.weightings, ...api.storage.weightings,
[assessmentID]: "processing", [assessmentID]: placeholder,
}; };
api.storage.assessments = { api.storage.assessments = {
@@ -580,6 +692,10 @@ async function handleWeightings(mark: any, api: any) {
[title.trim()]: assessmentID, [title.trim()]: assessmentID,
}; };
// Surface the refreshing indicator on the affected row immediately,
// without waiting for the PDF fetch to finish.
document.dispatchEvent(new CustomEvent("betterseqta:weightingsChanged"));
try { try {
let pdfUrl: string; let pdfUrl: string;
@@ -655,14 +771,24 @@ async function handleWeightings(mark: any, api: any) {
api.storage.weightings = { api.storage.weightings = {
...api.storage.weightings, ...api.storage.weightings,
[assessmentID]: match ? match[1] : "N/A", [assessmentID]: {
weight: match ? match[1] : "N/A",
fingerprint,
pluginVersion: WEIGHTING_SCHEMA_VERSION,
},
}; };
} catch (error: any) { } catch (error: any) {
api.storage.weightings = { api.storage.weightings = {
...api.storage.weightings, ...api.storage.weightings,
[assessmentID]: "N/A", [assessmentID]: {
weight: "N/A",
fingerprint,
pluginVersion: WEIGHTING_SCHEMA_VERSION,
},
}; };
} }
document.dispatchEvent(new CustomEvent("betterseqta:weightingsChanged"));
} }
export async function parseAssessments(api: any) { export async function parseAssessments(api: any) {
@@ -684,6 +810,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
let weightedTotal = 0; let weightedTotal = 0;
let totalWeight = 0; let totalWeight = 0;
let hasInaccurateWeighting = false; let hasInaccurateWeighting = false;
let hasRefreshingWeighting = false;
let count = 0; let count = 0;
for (const assessmentItem of assessmentItems) { for (const assessmentItem of assessmentItems) {
@@ -696,15 +823,17 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
if (!title) continue; if (!title) continue;
const assessmentID = api.storage.assessments?.[title]; const assessmentID = api.storage.assessments?.[title];
const autoWeighting = assessmentID const entry = assessmentID
? api.storage.weightings?.[assessmentID] ? (api.storage.weightings?.[assessmentID] as WeightingEntry | undefined)
: undefined; : undefined;
const autoWeighting = entry?.weight;
const override = assessmentID const override = assessmentID
? api.storage.weightingOverrides?.[assessmentID] ? api.storage.weightingOverrides?.[assessmentID]
: undefined; : undefined;
const weighting = override ?? autoWeighting; const weighting = override ?? autoWeighting;
const refreshing = !override && Boolean(entry?.refreshing);
createWeightLabel(assessmentItem, weighting, api); createWeightLabel(assessmentItem, weighting, api, refreshing);
const gradeElement = assessmentItem.querySelector( const gradeElement = assessmentItem.querySelector(
`[class*='Thermoscore__text___']`, `[class*='Thermoscore__text___']`,
@@ -727,6 +856,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
if (!isNaN(weight) && weight >= 0) { if (!isNaN(weight) && weight >= 0) {
weightedTotal += grade * weight; weightedTotal += grade * weight;
totalWeight += weight; totalWeight += weight;
if (refreshing) hasRefreshingWeighting = true;
} else { } else {
weightedTotal += grade; weightedTotal += grade;
totalWeight += 1; totalWeight += 1;
@@ -740,6 +870,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
weightedTotal, weightedTotal,
totalWeight, totalWeight,
hasInaccurateWeighting, hasInaccurateWeighting,
hasRefreshingWeighting,
count, count,
}; };
} }
@@ -811,9 +942,10 @@ function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
const title = titleEl?.textContent?.trim(); const title = titleEl?.textContent?.trim();
const assessmentID = title ? api.storage.assessments?.[title] : undefined; const assessmentID = title ? api.storage.assessments?.[title] : undefined;
const rawWeight = assessmentID const entry = assessmentID
? api.storage.weightings?.[assessmentID] ? (api.storage.weightings?.[assessmentID] as WeightingEntry | undefined)
: undefined; : undefined;
const rawWeight = entry?.weight;
const weightingUnavailable = rawWeight === "N/A"; const weightingUnavailable = rawWeight === "N/A";
@@ -0,0 +1,351 @@
import type { Plugin } from "@/plugins/core/types";
import { BasePlugin } from "@/plugins/core/settings";
import {
booleanSetting,
defineSettings,
Setting,
} from "@/plugins/core/settingsHelpers";
const settings = defineSettings({
autoScrollOnClick: booleanSetting({
default: false,
title: "Auto-scroll navigator on click",
description:
"When you click a lesson directly in the side panel, automatically scroll it to the centre. The prev/next arrows always centre the selected lesson regardless of this setting.",
}),
});
class EnhancedNavigationSettings extends BasePlugin<typeof settings> {
@Setting(settings.autoScrollOnClick)
autoScrollOnClick!: boolean;
}
const settingsInstance = new EnhancedNavigationSettings();
const ARROW_CONTAINER_ID = "betterseqta-en-arrows";
const STYLE_ID = "betterseqta-en-styles";
const injectStyles = () => {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
#${ARROW_CONTAINER_ID} {
position: fixed;
right: 24px;
display: flex;
gap: 6px;
z-index: 15;
pointer-events: none;
transition: opacity 0.15s ease;
}
body:has(.outside-container:not(.hide)) #${ARROW_CONTAINER_ID},
body:has(.bsplus-notifications-panel:not(.hide)) #${ARROW_CONTAINER_ID} {
opacity: 0;
pointer-events: none;
}
body:has(.outside-container:not(.hide)) #${ARROW_CONTAINER_ID} .en-arrow,
body:has(.bsplus-notifications-panel:not(.hide)) #${ARROW_CONTAINER_ID} .en-arrow {
pointer-events: none;
}
#${ARROW_CONTAINER_ID} .en-arrow {
pointer-events: auto;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 6px;
background: rgba(0, 0, 0, 0.08);
color: #000;
cursor: pointer;
transition: background 0.15s ease, transform 0.1s ease;
padding: 0;
}
#${ARROW_CONTAINER_ID} .en-arrow:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.18);
}
#${ARROW_CONTAINER_ID} .en-arrow:active:not(:disabled) {
transform: scale(0.92);
}
#${ARROW_CONTAINER_ID} .en-arrow:disabled {
opacity: 0.35;
cursor: not-allowed;
}
html.dark #${ARROW_CONTAINER_ID} .en-arrow {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
html.dark #${ARROW_CONTAINER_ID} .en-arrow:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.18);
}
#${ARROW_CONTAINER_ID} .en-arrow svg {
width: 18px;
height: 18px;
fill: currentColor;
display: block;
}
`;
document.head.appendChild(style);
};
const getOrderedItems = (navigator: Element): HTMLElement[] => {
const items: HTMLElement[] = [];
const cover = navigator.querySelector<HTMLElement>("li.cover");
if (cover) items.push(cover);
const lessons = Array.from(
navigator.querySelectorAll<HTMLElement>("li.lesson"),
);
lessons.sort((a, b) => {
const wa = parseInt(a.dataset.week ?? "0", 10);
const wb = parseInt(b.dataset.week ?? "0", 10);
if (wa !== wb) return wa - wb;
const na = parseInt(a.dataset.number ?? "0", 10);
const nb = parseInt(b.dataset.number ?? "0", 10);
return na - nb;
});
items.push(...lessons);
return items;
};
const getSelected = (navigator: Element): HTMLElement | null =>
navigator.querySelector<HTMLElement>("li.selected");
const findScrollableAncestor = (el: Element | null): HTMLElement | null => {
let cur: HTMLElement | null = el as HTMLElement | null;
while (cur && cur !== document.body) {
const style = getComputedStyle(cur);
const oy = style.overflowY;
if (
(oy === "auto" || oy === "scroll" || oy === "overlay") &&
cur.scrollHeight > cur.clientHeight + 1
) {
return cur;
}
cur = cur.parentElement;
}
return null;
};
const scrollSelectedIntoView = (navigator: Element) => {
const selected = getSelected(navigator);
if (!selected) return;
const scroller =
findScrollableAncestor(selected) ?? (navigator as HTMLElement);
if (!scroller) return;
const scrollerRect = scroller.getBoundingClientRect();
const selectedRect = selected.getBoundingClientRect();
const offset =
selectedRect.top -
scrollerRect.top -
scroller.clientHeight / 2 +
selectedRect.height / 2;
scroller.scrollTop = Math.max(
0,
Math.min(
scroller.scrollTop + offset,
scroller.scrollHeight - scroller.clientHeight,
),
);
};
const positionArrows = () => {
const container = document.getElementById(ARROW_CONTAINER_ID);
if (!container) return;
const ref =
document.getElementById("toolbar") ??
document.querySelector<HTMLElement>(".course");
if (!ref) return;
const rect = ref.getBoundingClientRect();
const arrowH = container.offsetHeight || 32;
const verticalOffset = 4;
container.style.top = `${Math.max(0, rect.top + (rect.height - arrowH) / 2 + verticalOffset)}px`;
};
let scrollOnNextSelect = false;
const navigate = (course: HTMLElement, direction: "prev" | "next") => {
const nav = course.querySelector(".navigator");
if (!nav) return;
const items = getOrderedItems(nav);
const selected = getSelected(nav);
const idx = selected ? items.indexOf(selected) : -1;
const target = idx + (direction === "next" ? 1 : -1);
if (target < 0 || target >= items.length) return;
scrollOnNextSelect = true;
items[target].click();
};
const updateArrowState = (course: HTMLElement) => {
const nav = course.querySelector(".navigator");
const container = document.getElementById(ARROW_CONTAINER_ID);
if (!nav || !container) return;
const items = getOrderedItems(nav);
const selected = getSelected(nav);
const idx = selected ? items.indexOf(selected) : -1;
const prev = container.querySelector<HTMLButtonElement>(
'button[data-en-action="prev"]',
);
const next = container.querySelector<HTMLButtonElement>(
'button[data-en-action="next"]',
);
if (prev) prev.disabled = idx <= 0;
if (next) next.disabled = idx === -1 || idx >= items.length - 1;
};
const ensureArrows = (course: HTMLElement) => {
if (!course.querySelector(".programmeNavigator")) return;
let container = document.getElementById(ARROW_CONTAINER_ID);
if (!container) {
container = document.createElement("div");
container.id = ARROW_CONTAINER_ID;
container.innerHTML = `
<button type="button" class="en-arrow" data-en-action="prev" title="Previous lesson" aria-label="Previous lesson">
<svg viewBox="0 0 24 24"><path d="M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button>
<button type="button" class="en-arrow" data-en-action="next" title="Next lesson" aria-label="Next lesson">
<svg viewBox="0 0 24 24"><path d="M8.59 16.59 10 18l6-6-6-6-1.41 1.41L13.17 12z"/></svg>
</button>
`;
document.body.appendChild(container);
container.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const btn = (e.target as Element).closest<HTMLButtonElement>(
"button[data-en-action]",
);
if (!btn) return;
const liveCourse = document.querySelector<HTMLElement>(".course");
if (liveCourse)
navigate(liveCourse, btn.dataset.enAction as "prev" | "next");
});
}
positionArrows();
updateArrowState(course);
};
const watchNavigator = (navigator: Element, onChange: () => void) => {
onChange();
const observer = new MutationObserver((muts) => {
if (
muts.some(
(m) =>
(m.type === "attributes" && m.attributeName === "class") ||
m.type === "childList",
)
) {
onChange();
}
});
observer.observe(navigator, {
subtree: true,
attributes: true,
attributeFilter: ["class"],
childList: true,
});
return observer;
};
const handleSlidePane = (pane: Element) => {
const navigator = pane.querySelector(".navigator");
if (!navigator) return;
requestAnimationFrame(() => scrollSelectedIntoView(navigator));
setTimeout(() => scrollSelectedIntoView(navigator), 50);
const observer = new MutationObserver(() =>
scrollSelectedIntoView(navigator),
);
observer.observe(navigator, {
subtree: true,
attributes: true,
attributeFilter: ["class"],
childList: true,
});
const cleanup = new MutationObserver((muts) => {
muts.forEach((m) => {
m.removedNodes.forEach((n) => {
if (n === pane) {
observer.disconnect();
cleanup.disconnect();
}
});
});
});
cleanup.observe(document.body, { childList: true });
};
const enhancedNavigationPlugin: Plugin<typeof settings> = {
id: "enhanced-navigation",
name: "Enhanced Navigation",
description:
"Keeps the course navigator focused on the current lesson and adds prev/next lesson arrows.",
version: "1.0.0",
disableToggle: true,
settings: settingsInstance.settings,
beta: false,
run: async (api) => {
injectStyles();
window.addEventListener("resize", positionArrows);
window.addEventListener("scroll", positionArrows, true);
api.seqta.onMount(".course", async (element) => {
const course = element as HTMLElement;
let navObserver: MutationObserver | null = null;
const setup = () => {
const nav = course.querySelector(".navigator");
if (!nav) return false;
if (navObserver) return true;
ensureArrows(course);
navObserver = watchNavigator(nav, () => {
if (scrollOnNextSelect || api.settings.autoScrollOnClick) {
scrollSelectedIntoView(nav);
scrollOnNextSelect = false;
}
ensureArrows(course);
});
return true;
};
if (!setup()) {
const courseObserver = new MutationObserver(() => {
if (setup()) courseObserver.disconnect();
});
courseObserver.observe(course, { childList: true, subtree: true });
}
});
const bodyObserver = new MutationObserver((muts) => {
muts.forEach((m) => {
m.addedNodes.forEach((n) => {
if (n.nodeType !== 1) return;
const el = n as Element;
if (el.classList?.contains("uiSlidePane")) handleSlidePane(el);
});
});
});
bodyObserver.observe(document.body, { childList: true });
return () => {
bodyObserver.disconnect();
document.getElementById(ARROW_CONTAINER_ID)?.remove();
document.getElementById(STYLE_ID)?.remove();
};
},
};
export default enhancedNavigationPlugin;
+12 -8
View File
@@ -8,6 +8,7 @@ import ProfilePictureSetting from "./ProfilePictureSetting.svelte";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import { cloudAuth } from "@/seqta/utils/CloudAuth"; import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { resolveCloudPfp } from "@/seqta/utils/cloudPfpCache";
import styles from "./styles.css?inline"; import styles from "./styles.css?inline";
import localforage from "localforage"; import localforage from "localforage";
@@ -65,14 +66,17 @@ const profilePicturePlugin: Plugin<typeof settings> = {
const useCloud = api.settings.useCloudPfp; const useCloud = api.settings.useCloudPfp;
const pfpUrl = cloudAuth.state.user?.pfpUrl; const pfpUrl = cloudAuth.state.user?.pfpUrl;
if (useCloud && pfpUrl) { if (useCloud && pfpUrl && cloudAuth.state.user?.id) {
img = document.createElement("img"); const resolved = await resolveCloudPfp(cloudAuth.state.user.id, pfpUrl);
img.className = "userInfoImg"; if (resolved) {
const base = pfpUrl.split("?")[0]!; currentBlobUrl = resolved.src;
img.src = `${base}?v=${Date.now()}`; img = document.createElement("img");
if (svg) svg.style.display = "none"; img.className = "userInfoImg";
container.appendChild(img); img.src = resolved.src;
return; if (svg) svg.style.display = "none";
container.appendChild(img);
return;
}
} }
const blob = await store.getItem<Blob>("profile-picture"); const blob = await store.getItem<Blob>("profile-picture");
+2
View File
@@ -11,6 +11,7 @@ import profilePicturePlugin from "./built-in/profilePicture";
import assessmentsOverviewPlugin from "./built-in/assessmentsOverview"; import assessmentsOverviewPlugin from "./built-in/assessmentsOverview";
import backgroundMusicPlugin from "./built-in/backgroundMusic"; import backgroundMusicPlugin from "./built-in/backgroundMusic";
import messageFoldersPlugin from "./built-in/messageFolders"; import messageFoldersPlugin from "./built-in/messageFolders";
import enhancedNavigationPlugin from "./built-in/enhancedNavigation";
//import testPlugin from './built-in/test'; //import testPlugin from './built-in/test';
// Heavy plugins (lazy-loaded only when enabled) // Heavy plugins (lazy-loaded only when enabled)
@@ -31,6 +32,7 @@ pluginManager.registerPlugin(profilePicturePlugin);
pluginManager.registerPlugin(assessmentsOverviewPlugin); pluginManager.registerPlugin(assessmentsOverviewPlugin);
pluginManager.registerPlugin(backgroundMusicPlugin); pluginManager.registerPlugin(backgroundMusicPlugin);
pluginManager.registerPlugin(messageFoldersPlugin); pluginManager.registerPlugin(messageFoldersPlugin);
pluginManager.registerPlugin(enhancedNavigationPlugin);
//pluginManager.registerPlugin(testPlugin); //pluginManager.registerPlugin(testPlugin);
// Register heavy plugins with lazy loading // Register heavy plugins with lazy loading
+4
View File
@@ -1,4 +1,5 @@
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import { clearCloudPfpCache } from "@/seqta/utils/cloudPfpCache";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback"; const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback";
@@ -16,6 +17,7 @@ export type CloudUser = {
username?: string; username?: string;
displayName?: string; displayName?: string;
pfpUrl?: string; pfpUrl?: string;
pfpHash?: string | null;
admin_level?: number; admin_level?: number;
}; };
@@ -201,6 +203,8 @@ class CloudAuthService {
} }
public async logout(): Promise<void> { public async logout(): Promise<void> {
const userId = this._state.user?.id;
if (userId) await clearCloudPfpCache(userId);
await browser.storage.local.remove([ await browser.storage.local.remove([
STORAGE_KEYS.accessToken, STORAGE_KEYS.accessToken,
STORAGE_KEYS.refreshToken, STORAGE_KEYS.refreshToken,
@@ -5,11 +5,13 @@ import { closePopup } from "./PopupManager";
import { getApiBase } from "../DevApiBase"; import { getApiBase } from "../DevApiBase";
import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight"; import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight";
import { cloudAuth } from "../CloudAuth"; import { cloudAuth } from "../CloudAuth";
import type { Theme } from "@/interface/types/Theme";
import {
buildModalHeroSlides,
normalizeStoreTheme,
} from "@/interface/utils/themeStoreFlavours";
import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen";
/**
* Server response shape from `/api/theme-of-the-month/current`.
* Hero image is resolved client-side via the theme store API when `theme_id` is set.
*/
export interface ThemeOfTheMonthEntry { export interface ThemeOfTheMonthEntry {
id: string; id: string;
month: string; month: string;
@@ -22,12 +24,6 @@ export interface ThemeOfTheMonthEntry {
updated_at: number; updated_at: number;
} }
/**
* Fetches the current month's Theme of the Month entry from the API.
* Returns `null` when no entry is configured for this month, or when the
* request fails (we never want a network problem to block other startup
* popups).
*/
export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | null> { export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | null> {
try { try {
const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, { const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, {
@@ -45,7 +41,6 @@ export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | nul
} }
} }
/** True when the current month's entry should appear in the startup queue. */
export function shouldShowThemeOfTheMonth(entry: ThemeOfTheMonthEntry | null): boolean { export function shouldShowThemeOfTheMonth(entry: ThemeOfTheMonthEntry | null): boolean {
if (!entry || settingsState.themeOfTheMonthDisabled) return false; if (!entry || settingsState.themeOfTheMonthDisabled) return false;
return settingsState.themeOfTheMonthDismissedMonth !== entry.month; return settingsState.themeOfTheMonthDismissedMonth !== entry.month;
@@ -67,7 +62,6 @@ function formatMonthLabel(month: string): string {
return date.toLocaleDateString("en-US", { year: "numeric", month: "long" }); return date.toLocaleDateString("en-US", { year: "numeric", month: "long" });
} }
/** Same priority as the theme store: marquee, then cover/banner. */
function heroUrlFromStoreTheme(theme: { function heroUrlFromStoreTheme(theme: {
marqueeImage?: string | null; marqueeImage?: string | null;
coverImage?: string | null; coverImage?: string | null;
@@ -76,41 +70,484 @@ function heroUrlFromStoreTheme(theme: {
return url || null; return url || null;
} }
/** export async function fetchThemeStoreTheme(themeId: string): Promise<Theme | null> {
* Loads hero image for a store theme via the background script (same path as
* {@link ThemeSelector} / theme store detail fetches).
*/
export async function fetchThemeStoreHeroImage(themeId: string): Promise<string | null> {
try { try {
const token = await cloudAuth.getStoredToken(); const token = await cloudAuth.getStoredToken();
const res = (await browser.runtime.sendMessage({ const res = (await browser.runtime.sendMessage({
type: "fetchThemeDetails", type: "fetchThemeDetails",
themeId, themeId,
token: token ?? undefined, token: token ?? undefined,
})) as { success?: boolean; data?: { theme?: { marqueeImage?: string; coverImage?: string } } }; })) as { success?: boolean; data?: { theme?: Record<string, unknown> } };
if (!res?.success || !res?.data?.theme) return null; if (!res?.success || !res?.data?.theme) return null;
return heroUrlFromStoreTheme(res.data.theme); return normalizeStoreTheme(res.data.theme);
} catch (err) { } catch (err) {
console.warn("[ThemeOfTheMonth] Failed to fetch theme store image:", err); console.warn("[ThemeOfTheMonth] Failed to fetch theme store details:", err);
return null; return null;
} }
} }
/** Linked theme store image, else optional admin-uploaded cover. */ export async function fetchThemeStoreHeroImage(themeId: string): Promise<string | null> {
async function resolvePopupHeroImageUrl(entry: ThemeOfTheMonthEntry): Promise<string | null> { const theme = await fetchThemeStoreTheme(themeId);
const themeId = entry.theme_id ?? entry.theme?.id; return theme ? heroUrlFromStoreTheme(theme) : null;
if (themeId) {
const fromStore = await fetchThemeStoreHeroImage(themeId);
if (fromStore) return fromStore;
}
const fallback = entry.cover_image?.trim();
return fallback || null;
} }
function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void) { type PopupGallerySlide = { imageUrl: string; caption: string };
if (card.classList.contains("themeOfTheMonthCardClosing")) return;
function buildPopupGallerySlides(
entry: ThemeOfTheMonthEntry,
storeTheme: Theme | null,
heroUrl: string | null,
): PopupGallerySlide[] {
if (storeTheme) {
return buildModalHeroSlides(storeTheme).filter((s) => s.imageUrl.trim());
}
if (heroUrl) {
return [{ imageUrl: heroUrl, caption: entry.title }];
}
return [];
}
/** Store theme identity on the hero — not the TOTM notice copy in the body. */
function renderHeroEmbossHtml(storeTheme: Theme, entry: ThemeOfTheMonthEntry): string {
const name = (storeTheme.name || entry.title).trim();
const author = storeTheme.author?.trim() ?? "";
const storeDescription = storeTheme.description?.trim() ?? "";
const entryDesc = entry.description.trim();
const showDescription =
storeDescription.length > 0 && storeDescription !== entryDesc;
const flavourCount = storeTheme.flavours?.length ?? 0;
const flavourLine =
flavourCount > 0
? `${flavourCount} colour variant${flavourCount === 1 ? "" : "s"}`
: "";
if (!name && !author && !showDescription && !flavourLine) return "";
return `
<div class="themeOfTheMonthCardHeroEmboss">
<div class="themeOfTheMonthCardHeroEmbossScrim" aria-hidden="true"></div>
<div class="themeOfTheMonthCardHeroEmbossContent">
<h3 class="themeOfTheMonthCardHeroEmbossTitle">${escapeHTML(name)}</h3>
${author ? `<p class="themeOfTheMonthCardHeroEmbossAuthor">By ${escapeHTML(author)}</p>` : ""}
${
showDescription
? `<p class="themeOfTheMonthCardHeroEmbossDescription">${escapeHTML(storeDescription).replace(/\n/g, "<br />")}</p>`
: ""
}
${flavourLine ? `<p class="themeOfTheMonthCardHeroEmbossVariants">${escapeHTML(flavourLine)}</p>` : ""}
</div>
</div>
`;
}
function renderGallerySlidesHtml(slides: PopupGallerySlide[]): string {
if (slides.length === 0) return "";
const slidesHtml = slides
.map(
(s, i) => `
<figure class="themeOfTheMonthCardGallerySlide" data-slide="${i}">
<img src="${escapeHTML(s.imageUrl)}" alt="${escapeHTML(s.caption)}" loading="lazy" />
<figcaption>${escapeHTML(s.caption)}</figcaption>
</figure>
`,
)
.join("");
const nav =
slides.length > 1
? `
<button type="button" class="themeOfTheMonthCardGalleryPrev" aria-label="Previous image"></button>
<button type="button" class="themeOfTheMonthCardGalleryNext" aria-label="Next image"></button>
<div class="themeOfTheMonthCardGalleryDots" role="tablist" aria-label="Theme previews">
${slides
.map(
(_, i) =>
`<button type="button" class="themeOfTheMonthCardGalleryDot${i === 0 ? " themeOfTheMonthCardGalleryDotActive" : ""}" data-slide="${i}" role="tab" aria-label="Image ${i + 1} of ${slides.length}" aria-selected="${i === 0 ? "true" : "false"}"></button>`,
)
.join("")}
</div>
`
: "";
return `
<div class="themeOfTheMonthCardGallery">
<div class="themeOfTheMonthCardGalleryTrack">${slidesHtml}</div>
${nav}
</div>
`;
}
const POPOUT_EXPAND_SVG = /* svg */ `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/></svg>`;
const POPOUT_COLLAPSE_SVG = /* svg */ `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 14h6v6"/><path d="M20 10h-6V4"/><path d="M14 10l7-7"/><path d="M3 21l7-7"/></svg>`;
const TOTM_MARGIN_PX = 18;
const TOTM_EXPANDED_SHELL_MAX_PX = 560;
const TOTM_EASE = "cubic-bezier(0.76, 0, 0.24, 1)";
const TOTM_MORPH_MS = 550;
const TOTM_LAYOUT_SWAP_MS = TOTM_MORPH_MS / 2;
let themeOfTheMonthAnimGen = 0;
// ---------------------------------------------------------------------------
// Dimension helpers
// ---------------------------------------------------------------------------
function themeOfTheMonthCollapsedWidth(): number {
return Math.min(360, window.innerWidth - TOTM_MARGIN_PX * 2);
}
function themeOfTheMonthExpandedWidth(): number {
return Math.min(520, window.innerWidth - 32);
}
function themeOfTheMonthMaxCardHeight(): number {
return window.innerHeight - TOTM_MARGIN_PX * 2;
}
/** Fixed expanded card height — stable morph target; footer pinned inside via CSS. */
function themeOfTheMonthExpandedShellHeight(): number {
return Math.min(TOTM_EXPANDED_SHELL_MAX_PX, themeOfTheMonthMaxCardHeight());
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
// ---------------------------------------------------------------------------
// Pure-transform positioning
//
// The card sits at position: fixed; top: 0; left: 0 at all times.
// All movement is expressed as translate(x, y) so CSS transitions drive
// the full path — no top/left changes mid-animation that would cause snapping.
// ---------------------------------------------------------------------------
/**
* Compute the translate values that place the card at the correct position.
* Collapsed bottom-right corner. Expanded viewport centre.
* Both states are expressed purely as transform offsets from (0, 0).
*/
function computeCardTranslate(
cardWidth: number,
cardHeight: number,
expanded: boolean,
): { x: number; y: number } {
if (expanded) {
const x = Math.round(
Math.max(
TOTM_MARGIN_PX,
Math.min(
(window.innerWidth - cardWidth) / 2,
window.innerWidth - TOTM_MARGIN_PX - cardWidth,
),
),
);
const y = Math.round(
Math.max(
TOTM_MARGIN_PX,
Math.min(
(window.innerHeight - cardHeight) / 2,
window.innerHeight - TOTM_MARGIN_PX - cardHeight,
),
),
);
return { x, y };
} else {
const x = Math.round(
Math.max(
TOTM_MARGIN_PX,
Math.min(
window.innerWidth - cardWidth - TOTM_MARGIN_PX,
window.innerWidth - TOTM_MARGIN_PX - cardWidth,
),
),
);
const y = Math.round(
Math.max(
TOTM_MARGIN_PX,
window.innerHeight - cardHeight - TOTM_MARGIN_PX,
),
);
return { x, y };
}
}
/**
* Apply card dimensions + border-radius, then set transform so the card
* lands at the right position.
*
* `targetHeight` must be passed explicitly never read scrollHeight here,
* because content may be hidden/shown mid-animation and scrollHeight would
* give the wrong value, causing the snap-to-full-height bug.
*/
function applyThemeOfTheMonthCardPosition(
card: HTMLElement,
expanded: boolean,
animate: boolean,
targetHeight?: number,
): void {
const width = expanded
? themeOfTheMonthExpandedWidth()
: themeOfTheMonthCollapsedWidth();
card.style.width = `${width}px`;
card.style.maxHeight = expanded ? `${themeOfTheMonthMaxCardHeight()}px` : "";
card.style.borderRadius = expanded ? "22px" : "20px";
const h = targetHeight ?? card.offsetHeight;
const { x, y } = computeCardTranslate(width, h, expanded);
const canAnimate =
animate &&
settingsState.animations &&
card.classList.contains("themeOfTheMonthCardMorphReady");
if (canAnimate) {
card.style.transition = [
`transform ${TOTM_MORPH_MS}ms ${TOTM_EASE}`,
`width ${TOTM_MORPH_MS}ms ${TOTM_EASE}`,
`height ${TOTM_MORPH_MS}ms ${TOTM_EASE}`,
`border-radius ${TOTM_MORPH_MS}ms ${TOTM_EASE}`,
].join(", ");
} else {
card.style.transition = "none";
}
// Force a reflow so the browser registers the pre-transition state.
void card.offsetHeight;
if (targetHeight !== undefined) card.style.height = `${targetHeight}px`;
card.style.transform = `translate(${x}px, ${y}px)`;
if (!canAnimate) {
requestAnimationFrame(() => {
if (card.isConnected) card.style.transition = "";
});
}
}
// ---------------------------------------------------------------------------
// Height helpers — keep card height explicit during animation so transforms
// can be calculated correctly.
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Expand / collapse animations
// ---------------------------------------------------------------------------
function applyExpandedCardShell(card: HTMLElement): number {
const h = themeOfTheMonthExpandedShellHeight();
card.classList.add("themeOfTheMonthCardExpandedShell");
card.style.height = `${h}px`;
card.style.maxHeight = `${themeOfTheMonthMaxCardHeight()}px`;
card.style.overflow = "hidden";
return h;
}
function clearExpandedCardShell(card: HTMLElement): void {
card.classList.remove("themeOfTheMonthCardExpandedShell");
card.style.height = "";
card.style.maxHeight = "";
card.style.overflow = "";
}
function applyExpandedLayout(card: HTMLElement, descriptionHtml: string): void {
const desc = card.querySelector<HTMLElement>(".themeOfTheMonthCardDescription");
const expandedPanel = card.querySelector<HTMLElement>(".themeOfTheMonthCardExpandedPanel");
card.classList.add("themeOfTheMonthCardExpanded", "themeOfTheMonthCardShowGallery");
expandedPanel?.removeAttribute("hidden");
if (expandedPanel) {
expandedPanel.style.opacity = "";
expandedPanel.style.transition = "";
}
if (desc) {
desc.innerHTML = descriptionHtml;
desc.classList.add("themeOfTheMonthCardDescriptionExpanded");
desc.classList.remove("themeOfTheMonthCardDescriptionClipped");
}
}
function applyCollapsedLayout(card: HTMLElement, descriptionHtml: string): void {
const desc = card.querySelector<HTMLElement>(".themeOfTheMonthCardDescription");
const expandedPanel = card.querySelector<HTMLElement>(".themeOfTheMonthCardExpandedPanel");
const body = card.querySelector<HTMLElement>(".themeOfTheMonthCardBody");
card.classList.remove(
"themeOfTheMonthCardExpanded",
"themeOfTheMonthCardShowGallery",
"themeOfTheMonthCardExpandedShell",
);
clearExpandedCardShell(card);
expandedPanel?.setAttribute("hidden", "");
if (expandedPanel) {
expandedPanel.style.opacity = "";
expandedPanel.style.transition = "";
}
if (body) {
body.style.opacity = "";
body.style.transition = "";
}
if (desc) {
desc.innerHTML = descriptionHtml;
desc.classList.remove("themeOfTheMonthCardDescriptionExpanded");
desc.classList.add("themeOfTheMonthCardDescriptionClipped");
}
}
function clearCardInlineSizeForMeasure(card: HTMLElement): void {
card.style.height = "";
card.style.maxHeight = "";
card.style.overflow = "";
}
function measureCollapsedTargetHeight(card: HTMLElement, descriptionHtml: string): number {
applyCollapsedLayout(card, descriptionHtml);
card.style.width = `${themeOfTheMonthCollapsedWidth()}px`;
clearCardInlineSizeForMeasure(card);
void card.offsetHeight;
return Math.min(card.scrollHeight, themeOfTheMonthMaxCardHeight());
}
async function runThemeOfTheMonthExpand(
card: HTMLElement,
backdrop: HTMLElement | null,
descriptionHtml: string,
): Promise<void> {
const gen = ++themeOfTheMonthAnimGen;
const fromH = card.offsetHeight;
const toH = themeOfTheMonthExpandedShellHeight();
// Morph starts in mini layout; swap to expanded layout halfway through the move.
applyCollapsedLayout(card, descriptionHtml);
card.style.width = `${themeOfTheMonthCollapsedWidth()}px`;
card.classList.add("themeOfTheMonthCardExpanding");
card.style.height = `${fromH}px`;
card.style.overflow = "hidden";
if (backdrop) {
backdrop.hidden = false;
backdrop.setAttribute("aria-hidden", "false");
requestAnimationFrame(() => backdrop.classList.add("themeOfTheMonthBackdropVisible"));
}
applyThemeOfTheMonthCardPosition(card, true, true, toH);
await sleep(TOTM_LAYOUT_SWAP_MS);
if (gen !== themeOfTheMonthAnimGen) return;
applyExpandedLayout(card, descriptionHtml);
applyExpandedCardShell(card);
await sleep(TOTM_LAYOUT_SWAP_MS);
if (gen !== themeOfTheMonthAnimGen) return;
card.classList.remove("themeOfTheMonthCardExpanding");
card.style.transition = "";
const finalH = themeOfTheMonthExpandedShellHeight();
card.style.height = `${finalH}px`;
applyThemeOfTheMonthCardPosition(card, true, false, finalH);
}
async function runThemeOfTheMonthCollapse(
card: HTMLElement,
backdrop: HTMLElement | null,
descriptionHtml: string,
): Promise<void> {
const gen = ++themeOfTheMonthAnimGen;
const fromH = card.offsetHeight;
const toH = measureCollapsedTargetHeight(card, descriptionHtml);
// Restore expanded visuals, then run one morph (size + position + height together).
applyExpandedLayout(card, descriptionHtml);
card.style.width = `${themeOfTheMonthExpandedWidth()}px`;
card.classList.add("themeOfTheMonthCardExpanding", "themeOfTheMonthCardCollapsing");
card.style.height = `${fromH}px`;
card.style.overflow = "hidden";
if (backdrop) {
backdrop.classList.remove("themeOfTheMonthBackdropVisible");
backdrop.setAttribute("aria-hidden", "true");
}
applyThemeOfTheMonthCardPosition(card, false, true, toH);
await sleep(TOTM_LAYOUT_SWAP_MS);
if (gen !== themeOfTheMonthAnimGen) return;
applyCollapsedLayout(card, descriptionHtml);
await sleep(TOTM_LAYOUT_SWAP_MS);
if (gen !== themeOfTheMonthAnimGen) return;
card.classList.remove(
"themeOfTheMonthCardExpanding",
"themeOfTheMonthCardCollapsing",
);
card.style.height = `${toH}px`;
card.style.overflow = "";
card.style.transition = "";
if (backdrop) backdrop.hidden = true;
}
// ---------------------------------------------------------------------------
// Instant (reduced-motion) state setter
// ---------------------------------------------------------------------------
function setThemeOfTheMonthExpandedInstant(
card: HTMLElement,
backdrop: HTMLElement | null,
expanded: boolean,
descriptionHtml: string,
): void {
themeOfTheMonthAnimGen++;
card.classList.toggle("themeOfTheMonthCardExpanded", expanded);
updateThemeOfTheMonthPopoutUi(card, expanded);
if (expanded) {
applyExpandedLayout(card, descriptionHtml);
if (backdrop) {
backdrop.hidden = false;
backdrop.setAttribute("aria-hidden", "false");
backdrop.classList.add("themeOfTheMonthBackdropVisible");
}
applyExpandedCardShell(card);
} else {
applyCollapsedLayout(card, descriptionHtml);
if (backdrop) {
backdrop.classList.remove("themeOfTheMonthBackdropVisible");
backdrop.setAttribute("aria-hidden", "true");
backdrop.hidden = true;
}
}
applyThemeOfTheMonthCardPosition(
card,
expanded,
false,
expanded ? themeOfTheMonthExpandedShellHeight() : undefined,
);
}
// ---------------------------------------------------------------------------
// UI helpers
// ---------------------------------------------------------------------------
function updateThemeOfTheMonthPopoutUi(card: HTMLElement, expanded: boolean): void {
const popout = card.querySelector<HTMLButtonElement>(".themeOfTheMonthCardPopout");
const popoutIcon = popout?.querySelector(".themeOfTheMonthCardPopoutIcon");
if (popoutIcon) {
popoutIcon.innerHTML = expanded ? POPOUT_COLLAPSE_SVG : POPOUT_EXPAND_SVG;
}
if (popout) {
popout.setAttribute("aria-label", expanded ? "Collapse" : "Expand");
popout.title = expanded ? "Collapse" : "Expand";
}
}
function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void): void {
if (card.classList.contains("themeOfTheMonthCardClosing")) return;
card.classList.add("themeOfTheMonthCardClosing"); card.classList.add("themeOfTheMonthCardClosing");
window.setTimeout(() => { window.setTimeout(() => {
card.remove(); card.remove();
@@ -118,38 +555,127 @@ function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void) {
}, 180); }, 180);
} }
/** // ---------------------------------------------------------------------------
* Renders the Theme of the Month announcement card. // Gallery
*/ // ---------------------------------------------------------------------------
function initThemeOfTheMonthGallery(card: HTMLElement): void {
const track = card.querySelector<HTMLElement>(".themeOfTheMonthCardGalleryTrack");
if (!track) return;
const slides = [...track.querySelectorAll<HTMLElement>(".themeOfTheMonthCardGallerySlide")];
if (slides.length <= 1) return;
const dots = [...card.querySelectorAll<HTMLButtonElement>(".themeOfTheMonthCardGalleryDot")];
let activeIndex = 0;
const scrollToIndex = (index: number) => {
const clamped = ((index % slides.length) + slides.length) % slides.length;
activeIndex = clamped;
const slide = slides[clamped];
track.scrollTo({ left: slide.offsetLeft, behavior: "smooth" });
for (const dot of dots) {
const isActive = Number(dot.dataset.slide) === clamped;
dot.classList.toggle("themeOfTheMonthCardGalleryDotActive", isActive);
dot.setAttribute("aria-selected", isActive ? "true" : "false");
}
};
card.querySelector(".themeOfTheMonthCardGalleryPrev")?.addEventListener("click", (e) => {
e.stopPropagation();
scrollToIndex(activeIndex - 1);
});
card.querySelector(".themeOfTheMonthCardGalleryNext")?.addEventListener("click", (e) => {
e.stopPropagation();
scrollToIndex(activeIndex + 1);
});
for (const dot of dots) {
dot.addEventListener("click", (e) => {
e.stopPropagation();
scrollToIndex(Number((e.currentTarget as HTMLButtonElement).dataset.slide));
});
}
const syncDotsFromScroll = () => {
const mid = track.scrollLeft + track.clientWidth / 2;
let nearest = 0;
let nearestDist = Infinity;
slides.forEach((slide, i) => {
const center = slide.offsetLeft + slide.offsetWidth / 2;
const dist = Math.abs(center - mid);
if (dist < nearestDist) {
nearestDist = dist;
nearest = i;
}
});
if (nearest === activeIndex) return;
activeIndex = nearest;
for (const dot of dots) {
const isActive = Number(dot.dataset.slide) === nearest;
dot.classList.toggle("themeOfTheMonthCardGalleryDotActive", isActive);
dot.setAttribute("aria-selected", isActive ? "true" : "false");
}
};
track.addEventListener("scroll", syncDotsFromScroll, { passive: true });
}
function attachPopupImages(root: ParentNode): void {
for (const img of root.querySelectorAll<HTMLImageElement>("img")) {
attachPopupMediaFullscreen(img);
}
}
// ---------------------------------------------------------------------------
// Main entry point
// ---------------------------------------------------------------------------
export async function OpenThemeOfTheMonthPopup( export async function OpenThemeOfTheMonthPopup(
entry: ThemeOfTheMonthEntry, entry: ThemeOfTheMonthEntry,
onDismissed?: () => void, onDismissed?: () => void,
) { ): Promise<void> {
document.getElementById("theme-of-the-month-card")?.remove(); document.getElementById("theme-of-the-month-card")?.remove();
document.getElementById("theme-of-the-month-backdrop")?.remove();
const monthLabel = formatMonthLabel(entry.month); const monthLabel = formatMonthLabel(entry.month);
const heroUrl = await resolvePopupHeroImageUrl(entry);
const description = escapeHTML(entry.description).replace(/\n/g, " ");
const linkedThemeId = entry.theme_id ?? entry.theme?.id; const linkedThemeId = entry.theme_id ?? entry.theme?.id;
const storeTheme = linkedThemeId ? await fetchThemeStoreTheme(linkedThemeId) : null;
const heroUrl =
(storeTheme ? heroUrlFromStoreTheme(storeTheme) : null) ??
entry.cover_image?.trim() ??
null;
const gallerySlides = buildPopupGallerySlides(entry, storeTheme, heroUrl);
const hasExpandableContent = gallerySlides.length > 0 || entry.description.trim().length > 0;
const descriptionHtml = escapeHTML(entry.description).replace(/\n/g, "<br />");
const heroEmbossHtml =
heroUrl && storeTheme ? renderHeroEmbossHtml(storeTheme, entry) : "";
const backdrop = stringToHTML(/* html */ `
<div id="theme-of-the-month-backdrop" class="themeOfTheMonthBackdrop" hidden aria-hidden="true"></div>
`).firstChild as HTMLElement;
const card = stringToHTML(/* html */ ` const card = stringToHTML(/* html */ `
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard" role="dialog" aria-label="Theme of the Month"> <aside id="theme-of-the-month-card" class="themeOfTheMonthCard${settingsState.animations ? "" : " themeOfTheMonthCardReducedMotion"}" role="dialog" aria-label="Theme of the Month">
${ <div class="themeOfTheMonthCardMedia">
heroUrl <button type="button" class="themeOfTheMonthCardPopout" aria-label="Expand" title="Expand"${hasExpandableContent ? "" : " hidden"}>
? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />` <span class="themeOfTheMonthCardPopoutIcon">${POPOUT_EXPAND_SVG}</span>
: "" </button>
} <div class="themeOfTheMonthCardCompactMedia"${heroUrl ? "" : " hidden"}>
${heroUrl ? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />` : ""}
</div>
<div class="themeOfTheMonthCardExpandedPanel" hidden>
${renderGallerySlidesHtml(gallerySlides)}
</div>
${heroEmbossHtml}
</div>
<div class="themeOfTheMonthCardBody"> <div class="themeOfTheMonthCardBody">
<p class="themeOfTheMonthCardEyebrow">Theme of the Month · ${escapeHTML(monthLabel)}</p> <p class="themeOfTheMonthCardEyebrow">Theme of the Month · ${escapeHTML(monthLabel)}</p>
<h2>${escapeHTML(entry.title)}</h2> <h2>${escapeHTML(entry.title)}</h2>
<p class="themeOfTheMonthCardDescription">${description}</p> <p class="themeOfTheMonthCardDescription themeOfTheMonthCardDescriptionClipped">${descriptionHtml}</p>
<div class="themeOfTheMonthCardActions"> <div class="themeOfTheMonthCardActions">
<div class="themeOfTheMonthCardActionsStart"> <div class="themeOfTheMonthCardActionsStart">
${ ${linkedThemeId ? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>` : ""}
linkedThemeId
? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>`
: ""
}
</div> </div>
<div class="themeOfTheMonthCardActionsEnd"> <div class="themeOfTheMonthCardActionsEnd">
<button type="button" class="themeOfTheMonthCardSecondary">Close</button> <button type="button" class="themeOfTheMonthCardSecondary">Close</button>
@@ -170,32 +696,97 @@ export async function OpenThemeOfTheMonthPopup(
</aside> </aside>
`).firstChild as HTMLElement; `).firstChild as HTMLElement;
const autoCloseTimeout = window.setTimeout(() => { let isExpanded = false;
closeThemeOfTheMonthCard(card, onDismissed); let expandAnimating = false;
}, 30_000);
const dismiss = () => { const applyExpandedState = async (expanded: boolean): Promise<void> => {
window.clearTimeout(autoCloseTimeout); updateThemeOfTheMonthPopoutUi(card, expanded);
if (!settingsState.animations) {
setThemeOfTheMonthExpandedInstant(card, backdrop, expanded, descriptionHtml);
return;
}
expandAnimating = true;
try {
if (expanded) {
await runThemeOfTheMonthExpand(card, backdrop, descriptionHtml);
} else {
await runThemeOfTheMonthCollapse(card, backdrop, descriptionHtml);
}
} finally {
expandAnimating = false;
updateThemeOfTheMonthPopoutUi(card, expanded);
}
};
const onDocKey = (ev: KeyboardEvent) => {
if (ev.key !== "Escape") return;
if (!isExpanded || expandAnimating) return;
ev.stopPropagation();
isExpanded = false;
void applyExpandedState(false);
};
let autoCloseTimeout = 0;
const pauseAutoClose = () => window.clearTimeout(autoCloseTimeout);
const onResize = () => {
if (isExpanded) applyExpandedCardShell(card);
applyThemeOfTheMonthCardPosition(
card,
isExpanded,
false,
isExpanded ? themeOfTheMonthExpandedShellHeight() : undefined,
);
};
const dismissWithCleanup = () => {
pauseAutoClose();
window.removeEventListener("resize", onResize);
backdrop.remove();
document.removeEventListener("keydown", onDocKey, true);
closeThemeOfTheMonthCard(card, onDismissed); closeThemeOfTheMonthCard(card, onDismissed);
}; };
card.addEventListener("mouseenter", () => window.clearTimeout(autoCloseTimeout), { once: true }); autoCloseTimeout = window.setTimeout(dismissWithCleanup, 30_000);
card.addEventListener("mouseenter", pauseAutoClose, { once: true });
initThemeOfTheMonthGallery(card);
attachPopupImages(card);
const confirmEl = card.querySelector<HTMLElement>(".themeOfTheMonthCardConfirm"); const confirmEl = card.querySelector<HTMLElement>(".themeOfTheMonthCardConfirm");
const toggleExpanded = () => {
if (expandAnimating) return;
isExpanded = !isExpanded;
pauseAutoClose();
void applyExpandedState(isExpanded);
};
card.querySelector(".themeOfTheMonthCardPopout")?.addEventListener("click", (e) => {
e.stopPropagation();
toggleExpanded();
});
backdrop.addEventListener("click", () => {
if (!isExpanded || expandAnimating) return;
isExpanded = false;
void applyExpandedState(false);
});
document.addEventListener("keydown", onDocKey, true);
card.querySelector(".themeOfTheMonthCardSecondary")?.addEventListener("click", () => { card.querySelector(".themeOfTheMonthCardSecondary")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDismissedMonth = entry.month; settingsState.themeOfTheMonthDismissedMonth = entry.month;
dismiss(); dismissWithCleanup();
}); });
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => { card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDismissedMonth = entry.month; settingsState.themeOfTheMonthDismissedMonth = entry.month;
dismiss(); dismissWithCleanup();
openThemeStoreWithHighlight(linkedThemeId!); openThemeStoreWithHighlight(linkedThemeId!);
}); });
const openDontShowConfirm = () => { const openDontShowConfirm = () => {
window.clearTimeout(autoCloseTimeout); pauseAutoClose();
if (!confirmEl) return; if (!confirmEl) return;
confirmEl.hidden = false; confirmEl.hidden = false;
requestAnimationFrame(() => confirmEl.classList.add("themeOfTheMonthCardConfirmVisible")); requestAnimationFrame(() => confirmEl.classList.add("themeOfTheMonthCardConfirmVisible"));
@@ -206,23 +797,37 @@ export async function OpenThemeOfTheMonthPopup(
card.querySelector(".themeOfTheMonthCardConfirmCancel")?.addEventListener("click", () => { card.querySelector(".themeOfTheMonthCardConfirmCancel")?.addEventListener("click", () => {
if (!confirmEl) return; if (!confirmEl) return;
confirmEl.classList.remove("themeOfTheMonthCardConfirmVisible"); confirmEl.classList.remove("themeOfTheMonthCardConfirmVisible");
window.setTimeout(() => { window.setTimeout(() => { confirmEl.hidden = true; }, 160);
confirmEl.hidden = true;
}, 160);
}); });
card.querySelector(".themeOfTheMonthCardConfirmAccept")?.addEventListener("click", () => { card.querySelector(".themeOfTheMonthCardConfirmAccept")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDisabled = true; settingsState.themeOfTheMonthDisabled = true;
dismiss(); dismissWithCleanup();
}); });
document.body.appendChild(card); // Mount — card at top:0; left:0, all positioning via transform.
card.style.position = "fixed";
card.style.top = "0";
card.style.left = "0";
document.body.append(backdrop, card);
// Set initial collapsed position instantly (no transition).
applyThemeOfTheMonthCardPosition(card, false, false);
window.addEventListener("resize", onResize);
// Enable morph-ready class after two frames so the initial snap doesn't
// accidentally play a transition.
if (settingsState.animations) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
card.classList.add("themeOfTheMonthCardMorphReady");
});
});
}
} }
/**
* Dev helper: fetch the current month's entry and show the popup immediately,
* even if the user dismissed it for this calendar month.
*/
export async function showThemeOfTheMonthPopupNow(): Promise<void> { export async function showThemeOfTheMonthPopupNow(): Promise<void> {
const entry = await fetchThemeOfTheMonth(); const entry = await fetchThemeOfTheMonth();
if (!entry) { if (!entry) {
@@ -240,4 +845,4 @@ export async function showThemeOfTheMonthPopupNow(): Promise<void> {
} }
await OpenThemeOfTheMonthPopup(entry); await OpenThemeOfTheMonthPopup(entry);
} }
+3 -1
View File
@@ -42,7 +42,8 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
const text = stringToHTML(/* html */ ` const text = stringToHTML(/* html */ `
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;"> <div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
<h1>3.7.0 Grade Analytics, fonts, Global Search & SEQTA Engage Improvements</h1> <h1>3.7.0 Grade Analytics, Enhanced Navigation, fonts, Global Search & SEQTA Engage Improvements</h1>
<li>Added Enhanced Navigation for courses: the navigator now auto-scrolls to the selected lesson (e.g. inside the "Go to…" popup) and prev/next arrows for jumping between lessons.</li>
<li>Added Grade Analytics, new sidebar page with grade trend charts synced from SEQTA.</li> <li>Added Grade Analytics, new sidebar page with grade trend charts synced from SEQTA.</li>
<li>Added Grade distribution auto-detects your schools letter scale from released marks for analytics page.</li> <li>Added Grade distribution auto-detects your schools letter scale from released marks for analytics page.</li>
<li>Added documents, notices, portals, folios, goals, and more to Global Search.</li> <li>Added documents, notices, portals, folios, goals, and more to Global Search.</li>
@@ -53,6 +54,7 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
<li>Added font picker in settings.</li> <li>Added font picker in settings.</li>
<li>Added rubric copy on assessment detail pages.</li> <li>Added rubric copy on assessment detail pages.</li>
<li>Added manual weight entry when an assessment weight is N/A.</li> <li>Added manual weight entry when an assessment weight is N/A.</li>
<li>Added an automatic reindex of assessments if any of a series of tracked values change (title, release state, etc). Helps keep weightings up to date.</li>
<li>Fixed BetterSEQTA sidebar injection issues on some pages.</li> <li>Fixed BetterSEQTA sidebar injection issues on some pages.</li>
<li>Tweak Theme of the Month popup making it more clear about dismissals and respecting Dont show again.</li> <li>Tweak Theme of the Month popup making it more clear about dismissals and respecting Dont show again.</li>
<li>Fixed duplicate-result fixes.</li> <li>Fixed duplicate-result fixes.</li>
+112
View File
@@ -0,0 +1,112 @@
import localforage from "localforage";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
const ACCOUNTS_BASE = "https://accounts.betterseqta.org";
const store = localforage.createInstance({
name: "cloud-pfp-store",
storeName: "cloudPfp",
});
function hashKey(userId: string) {
return `hash:${userId}`;
}
function blobKey(userId: string) {
return `blob:${userId}`;
}
export function isAccountsHostedPfpUrl(url: string): boolean {
if (!url.includes("/api/user/pfp/")) return false;
if (url.includes("/hist/")) return false;
return /\/api\/user\/pfp\/[^/?#]+/.test(url.split("?")[0]!);
}
export function pfpUrlWithHash(url: string, hash: string | null | undefined): string {
if (!url || !hash || !isAccountsHostedPfpUrl(url)) return url;
const base = url.split("?")[0]!;
return `${base}?v=${hash}`;
}
async function fetchServerHash(userId: string): Promise<string | null> {
const res = await fetch(`${ACCOUNTS_BASE}/api/user/pfp/${userId}/meta`);
if (!res.ok) return null;
const data = (await res.json()) as { pfpHash?: string | null };
return data.pfpHash ?? null;
}
async function clearLocal(userId: string): Promise<void> {
await store.removeItem(hashKey(userId));
await store.removeItem(blobKey(userId));
}
export async function clearCloudPfpCache(userId?: string): Promise<void> {
const id = userId ?? cloudAuth.state.user?.id;
if (!id) return;
await clearLocal(id);
}
export type ResolveCloudPfpResult = {
src: string;
fromCache: boolean;
};
/**
* Returns an object URL or direct URL for the cloud profile picture.
* Order: session hash match local blob; else meta download store blob then hash.
*/
export async function resolveCloudPfp(
userId: string,
pfpUrl: string,
): Promise<ResolveCloudPfpResult | null> {
if (!isAccountsHostedPfpUrl(pfpUrl)) {
return { src: pfpUrl, fromCache: false };
}
const sessionHash = cloudAuth.state.user?.pfpHash ?? null;
const localHash = await store.getItem<string>(hashKey(userId));
const localBlob = await store.getItem<Blob>(blobKey(userId));
let serverHash = sessionHash;
const localMatches =
!!serverHash && serverHash === localHash && localBlob instanceof Blob;
if (localMatches) {
return { src: URL.createObjectURL(localBlob), fromCache: true };
}
if (!serverHash || serverHash !== localHash) {
serverHash = await fetchServerHash(userId);
}
if (!serverHash) {
await clearLocal(userId);
return null;
}
if (serverHash === localHash && localBlob instanceof Blob) {
return { src: URL.createObjectURL(localBlob), fromCache: true };
}
await clearLocal(userId);
const imageUrl = pfpUrlWithHash(pfpUrl, serverHash);
const headers: HeadersInit = {};
if (localHash) {
headers["If-None-Match"] = `"${localHash}"`;
}
const res = await fetch(imageUrl, { headers });
if (res.status === 304 && localBlob instanceof Blob) {
await store.setItem(hashKey(userId), serverHash);
return { src: URL.createObjectURL(localBlob), fromCache: true };
}
if (!res.ok) return null;
const blob = await res.blob();
await store.setItem(blobKey(userId), blob);
await store.setItem(hashKey(userId), serverHash);
return { src: URL.createObjectURL(blob), fromCache: false };
}
+41 -8
View File
@@ -1,6 +1,7 @@
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import localforage from "localforage"; import localforage from "localforage";
import { cloudAuth } from "@/seqta/utils/CloudAuth"; import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { clearCloudPfpCache, pfpUrlWithHash } from "@/seqta/utils/cloudPfpCache";
const ACCOUNTS_BASE = "https://accounts.betterseqta.org"; const ACCOUNTS_BASE = "https://accounts.betterseqta.org";
const PLUGIN_SETTINGS_KEY = "plugin.profile-picture.settings"; const PLUGIN_SETTINGS_KEY = "plugin.profile-picture.settings";
@@ -10,9 +11,32 @@ const profileStore = localforage.createInstance({
storeName: "profilePicture", storeName: "profilePicture",
}); });
function cacheBustPfpUrl(url: string): string { /** Downscale before upload to reduce ingress (server still normalizes). */
const base = url.split("?")[0]!; async function downscaleForUpload(blob: Blob, maxEdge = 512): Promise<Blob> {
return `${base}?v=${Date.now()}`; if (!blob.type.startsWith("image/")) return blob;
const bitmap = await createImageBitmap(blob);
const maxSide = Math.max(bitmap.width, bitmap.height);
if (maxSide <= maxEdge) {
bitmap.close();
return blob;
}
const scale = maxEdge / maxSide;
const w = Math.max(1, Math.round(bitmap.width * scale));
const h = Math.max(1, Math.round(bitmap.height * scale));
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext("2d");
if (!ctx) {
bitmap.close();
return blob;
}
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close();
const out = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.85 });
return out;
} }
export async function isUseCloudPfpEnabled(): Promise<boolean> { export async function isUseCloudPfpEnabled(): Promise<boolean> {
@@ -41,6 +65,9 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
const token = await cloudAuth.getStoredToken(); const token = await cloudAuth.getStoredToken();
if (!token) return { success: false, error: "Not logged in" }; if (!token) return { success: false, error: "Not logged in" };
const user = cloudAuth.state.user;
const userId = user?.id;
const blob = await profileStore.getItem<Blob>("profile-picture"); const blob = await profileStore.getItem<Blob>("profile-picture");
try { try {
@@ -57,10 +84,10 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
if (!res.ok) { if (!res.ok) {
return { success: false, error: (data.error as string) ?? `Clear failed (${res.status})` }; return { success: false, error: (data.error as string) ?? `Clear failed (${res.status})` };
} }
const user = cloudAuth.state.user;
if (user) { if (user) {
await cloudAuth.setUser({ ...user, pfpUrl: undefined }); await cloudAuth.setUser({ ...user, pfpUrl: undefined, pfpHash: null });
} }
if (userId) await clearCloudPfpCache(userId);
return { success: true }; return { success: true };
} }
@@ -71,8 +98,9 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
return { success: false, error: "File too large (max 5MB)" }; return { success: false, error: "File too large (max 5MB)" };
} }
const uploadBlob = await downscaleForUpload(blob);
const formData = new FormData(); const formData = new FormData();
formData.append("file", blob, "profile-picture"); formData.append("file", uploadBlob, "profile-picture.jpg");
const res = await fetch(`${ACCOUNTS_BASE}/api/user/pfp`, { const res = await fetch(`${ACCOUNTS_BASE}/api/user/pfp`, {
method: "POST", method: "POST",
@@ -85,10 +113,15 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
} }
const pfpUrl = data.pfpUrl as string | undefined; const pfpUrl = data.pfpUrl as string | undefined;
const user = cloudAuth.state.user; const pfpHash = (data.pfpHash as string | null | undefined) ?? null;
if (user && pfpUrl) { if (user && pfpUrl) {
await cloudAuth.setUser({ ...user, pfpUrl: cacheBustPfpUrl(pfpUrl) }); await cloudAuth.setUser({
...user,
pfpUrl: pfpUrlWithHash(pfpUrl, pfpHash),
pfpHash,
});
} }
if (userId) await clearCloudPfpCache(userId);
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
return { return {
+5 -1
View File
@@ -49,6 +49,10 @@ export interface SettingsState {
animations: boolean; animations: boolean;
defaultPage: string; defaultPage: string;
devMode?: boolean; devMode?: boolean;
/** Dev-only: pretend this is the latest GitHub release version for update badge testing. */
devGhReleaseVersionOverride?: string;
/** ISO timestamp of the last acknowledged nightly release publish time. */
lastSeenNightlyPublishedAt?: string;
originalDarkMode?: boolean; originalDarkMode?: boolean;
newsSource?: string; newsSource?: string;
mockNotices?: boolean; mockNotices?: boolean;
@@ -71,7 +75,7 @@ export interface SettingsState {
bsplus_client_id?: string; bsplus_client_id?: string;
bsplus_token?: string; bsplus_token?: string;
bsplus_refresh_token?: string; bsplus_refresh_token?: string;
bsplus_user?: { id: string; email?: string; username?: string; displayName?: string; pfpUrl?: string; admin_level?: number }; bsplus_user?: { id: string; email?: string; username?: string; displayName?: string; pfpUrl?: string; pfpHash?: string | null; admin_level?: number };
/** When not `false`, automatic cloud settings sync is enabled (default-on). */ /** When not `false`, automatic cloud settings sync is enabled (default-on). */
autoCloudSettingsSync?: boolean; autoCloudSettingsSync?: boolean;
} }
+213
View File
@@ -0,0 +1,213 @@
import semver from "semver";
import browser from "webextension-polyfill";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
const CHECK_THROTTLE_MS = 6 * 60 * 60 * 1000;
const LAST_CHECK_KEY = "bsplus_lastGhReleaseCheck";
const NIGHTLY_TAG = "nightly";
let cachedResult: GhReleaseUpdateInfo | null = null;
export interface GhReleaseUpdateInfo {
available: boolean;
label: string;
url: string;
}
function isUpdateCheckEnabled(): boolean {
return typeof __ENABLE_GH_RELEASE_UPDATE_CHECK__ !== "undefined"
&& __ENABLE_GH_RELEASE_UPDATE_CHECK__;
}
function getRepoSlug(): string {
return typeof __GH_RELEASE_REPO__ !== "undefined"
? __GH_RELEASE_REPO__
: "BetterSEQTA/BetterSEQTA-Plus";
}
function getUpdateChannel(): "stable" | "nightly" {
return typeof __UPDATE_CHANNEL__ !== "undefined"
? __UPDATE_CHANNEL__
: "stable";
}
function getBuildLabel(): string {
return typeof __BUILD_LABEL__ !== "undefined" ? __BUILD_LABEL__ : "";
}
function getCurrentVersion(): string {
return browser.runtime.getManifest().version;
}
function releasesBaseUrl(): string {
return `https://github.com/${getRepoSlug()}/releases`;
}
function shouldThrottleCheck(): boolean {
try {
const last = localStorage.getItem(LAST_CHECK_KEY);
if (!last) return false;
return Date.now() - Number(last) < CHECK_THROTTLE_MS;
} catch {
return false;
}
}
function markChecked(): void {
try {
localStorage.setItem(LAST_CHECK_KEY, String(Date.now()));
} catch {
/* ignore */
}
}
function getDevOverrideVersion(): string | null {
if (!settingsState.devMode) return null;
const override = settingsState.devGhReleaseVersionOverride?.trim();
return override || null;
}
function compareWithOverride(current: string): GhReleaseUpdateInfo | null {
const override = getDevOverrideVersion();
if (!override) return null;
const currentCoerced = semver.coerce(current);
const overrideCoerced = semver.coerce(override);
if (!currentCoerced || !overrideCoerced) return null;
if (semver.gt(overrideCoerced, currentCoerced)) {
return {
available: true,
label: override,
url: releasesBaseUrl(),
};
}
return { available: false, label: "", url: releasesBaseUrl() };
}
interface GhRelease {
tag_name: string;
published_at: string;
prerelease: boolean;
}
async function fetchJson<T>(url: string): Promise<T | null> {
try {
const response = await fetch(url, {
headers: { Accept: "application/vnd.github+json" },
});
if (!response.ok) return null;
return (await response.json()) as T;
} catch {
return null;
}
}
function isStableSemverTag(tag: string): boolean {
if (tag === NIGHTLY_TAG) return false;
return semver.valid(semver.coerce(tag)) !== null;
}
async function checkStableUpdate(current: string): Promise<GhReleaseUpdateInfo> {
const url = releasesBaseUrl();
const releases = await fetchJson<GhRelease[]>(
`https://api.github.com/repos/${getRepoSlug()}/releases`,
);
if (!releases?.length) {
return { available: false, label: "", url };
}
let latestTag: string | null = null;
let latestVersion: semver.SemVer | null = null;
for (const release of releases) {
const tag = release.tag_name;
if (!isStableSemverTag(tag)) continue;
const coerced = semver.coerce(tag);
if (!coerced) continue;
if (!latestVersion || semver.gt(coerced, latestVersion)) {
latestVersion = coerced;
latestTag = tag;
}
}
const currentCoerced = semver.coerce(current);
if (!latestTag || !latestVersion || !currentCoerced) {
return { available: false, label: "", url };
}
if (semver.gt(latestVersion, currentCoerced)) {
return { available: true, label: latestTag, url: `${url}/tag/${latestTag}` };
}
return { available: false, label: "", url };
}
async function checkNightlyUpdate(): Promise<GhReleaseUpdateInfo> {
const url = `${releasesBaseUrl()}/tag/${NIGHTLY_TAG}`;
const release = await fetchJson<GhRelease>(
`https://api.github.com/repos/${getRepoSlug()}/releases/tags/${NIGHTLY_TAG}`,
);
if (!release?.published_at) {
return { available: false, label: "", url };
}
const lastSeen = settingsState.lastSeenNightlyPublishedAt;
const buildLabel = getBuildLabel();
const label = buildLabel ? `nightly #${buildLabel}` : "nightly";
if (!lastSeen) {
settingsState.lastSeenNightlyPublishedAt = release.published_at;
return { available: false, label: "", url };
}
if (new Date(release.published_at) > new Date(lastSeen)) {
return { available: true, label, url };
}
return { available: false, label: "", url };
}
export function isGhReleaseUpdateCheckEnabled(): boolean {
return isUpdateCheckEnabled();
}
export async function checkGithubReleaseUpdate(): Promise<GhReleaseUpdateInfo> {
const fallback = { available: false, label: "", url: releasesBaseUrl() };
if (!isUpdateCheckEnabled()) return fallback;
const current = getCurrentVersion();
const overrideResult = compareWithOverride(current);
if (overrideResult) return overrideResult;
if (shouldThrottleCheck()) {
return cachedResult ?? fallback;
}
markChecked();
const result =
getUpdateChannel() === "nightly"
? await checkNightlyUpdate()
: await checkStableUpdate(current);
cachedResult = result;
return result;
}
export function dismissNightlyUpdate(): void {
void (async () => {
const release = await fetchJson<GhRelease>(
`https://api.github.com/repos/${getRepoSlug()}/releases/tags/${NIGHTLY_TAG}`,
);
if (release?.published_at) {
settingsState.lastSeenNightlyPublishedAt = release.published_at;
}
})();
}
+1
View File
@@ -35,6 +35,7 @@
"src/**/*.js", "src/**/*.js",
"src/**/*.svelte", "src/**/*.svelte",
"src/interface/+layout.svelte", "src/interface/+layout.svelte",
"src/env.d.ts",
"declarations.d.ts" "declarations.d.ts"
] ]
} }
+10
View File
@@ -28,6 +28,16 @@ const mode = process.env.MODE || "chrome"; // Check the environment variable to
const useMillion = mode.toLowerCase() !== "firefox"; const useMillion = mode.toLowerCase() !== "firefox";
export default defineConfig(({ command }) => ({ export default defineConfig(({ command }) => ({
define: {
__ENABLE_GH_RELEASE_UPDATE_CHECK__: JSON.stringify(
process.env.GH_RELEASE_UPDATE_CHECK === "true",
),
__GH_RELEASE_REPO__: JSON.stringify(
process.env.GH_RELEASE_REPO ?? "BetterSEQTA/BetterSEQTA-Plus",
),
__UPDATE_CHANNEL__: JSON.stringify(process.env.UPDATE_CHANNEL ?? "stable"),
__BUILD_LABEL__: JSON.stringify(process.env.BUILD_LABEL ?? ""),
},
plugins: [ plugins: [
base64Loader, base64Loader,
InlineWorkerPlugin(), InlineWorkerPlugin(),