mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-13 23:24:40 +00:00
Merge origin/main and combine changelog updates in OpenWhatsNewPopup.
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+21
-22
@@ -5,31 +5,30 @@
|
||||
"es2021": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": "eslint:recommended",
|
||||
"extends": ["eslint:recommended"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
// allow importing ts extensions
|
||||
"sort-imports": [
|
||||
"error",
|
||||
{
|
||||
"ignoreCase": true,
|
||||
"ignoreDeclarationSort": true,
|
||||
"ignoreMemberSort": false,
|
||||
"memberSyntaxSortOrder": ["none", "all", "multiple", "single"]
|
||||
}
|
||||
],
|
||||
"import/extensions": [
|
||||
"error",
|
||||
"ignorePackages",
|
||||
{
|
||||
"js": "never",
|
||||
"ts": "never",
|
||||
"tsx": "never"
|
||||
}
|
||||
]
|
||||
"plugins": ["@typescript-eslint", "import"],
|
||||
"ignorePatterns": ["**/*.d.ts"],
|
||||
"globals": {
|
||||
"__ENABLE_GH_RELEASE_UPDATE_CHECK__": "readonly",
|
||||
"__GH_RELEASE_REPO__": "readonly",
|
||||
"__UPDATE_CHANNEL__": "readonly",
|
||||
"__BUILD_LABEL__": "readonly"
|
||||
},
|
||||
"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"
|
||||
@@ -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.
|
||||
@@ -3,8 +3,6 @@ name: NodeJS Build
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -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 }}"
|
||||
@@ -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"
|
||||
@@ -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 }}"
|
||||
@@ -9,6 +9,7 @@
|
||||
- [Documentation home](https://docs.betterseqta.org/)
|
||||
- [Installation](https://docs.betterseqta.org/install/)
|
||||
- [Contributing](https://docs.betterseqta.org/contributing/)
|
||||
- [GitHub releases & CI](RELEASES.md) — workflows, nightly builds, update detector
|
||||
- [Architecture](https://docs.betterseqta.org/architecture/)
|
||||
- [Contribution guidelines (repository)](../CONTRIBUTING.md)
|
||||
- [Troubleshooting](https://docs.betterseqta.org/troubleshooting/)
|
||||
|
||||
@@ -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
@@ -119,12 +119,13 @@ git checkout -b feature/my-new-feature
|
||||
|
||||
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
|
||||
npm test
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
5. **Submit Your PR**
|
||||
@@ -139,6 +140,25 @@ git checkout -b feature/my-new-feature
|
||||
|
||||
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
|
||||
|
||||
We follow TypeScript best practices and have a consistent code style:
|
||||
|
||||
+5
-1
@@ -17,7 +17,8 @@
|
||||
"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",
|
||||
"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",
|
||||
"zip": "bedframe zip"
|
||||
},
|
||||
@@ -45,9 +46,12 @@
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
"cross-env": "^10.0.0",
|
||||
"dependency-cruiser": "^17.0.1",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"glob": "^11.0.1",
|
||||
"mime-types": "^3.0.1",
|
||||
"prettier": "^3.5.3",
|
||||
|
||||
+327
-18
@@ -3809,19 +3809,306 @@ div.day-empty {
|
||||
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 {
|
||||
position: fixed;
|
||||
right: max(18px, env(safe-area-inset-right));
|
||||
bottom: max(18px, env(safe-area-inset-bottom));
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
z-index: 48;
|
||||
margin: 0;
|
||||
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-radius: 20px;
|
||||
background: var(--background-primary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
|
||||
animation: themeOfTheMonthCardIn 0.24s ease-out;
|
||||
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 {
|
||||
content: "";
|
||||
@@ -3908,16 +4195,25 @@ div.day-empty {
|
||||
.themeOfTheMonthCardConfirmAccept:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.themeOfTheMonthCardImage {
|
||||
#theme-of-the-month-card .themeOfTheMonthCardImage {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
width: 100% !important;
|
||||
min-width: 100%;
|
||||
height: 150px !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
margin: 0;
|
||||
border-radius: 20px 20px 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
object-fit: cover;
|
||||
object-position: center center;
|
||||
}
|
||||
.themeOfTheMonthCardExpanded .themeOfTheMonthCardGallerySlide img {
|
||||
border-radius: 22px 22px 0 0;
|
||||
}
|
||||
.themeOfTheMonthCardBody {
|
||||
padding: 14px 16px 16px;
|
||||
padding: 14px 16px 12px;
|
||||
}
|
||||
.themeOfTheMonthCardEyebrow {
|
||||
margin: 0 0 6px;
|
||||
@@ -3933,14 +4229,26 @@ div.day-empty {
|
||||
line-height: 1.2;
|
||||
}
|
||||
.themeOfTheMonthCardDescription {
|
||||
display: -webkit-box;
|
||||
margin: 8px 0 14px;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
margin: 8px 0 10px;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -4028,21 +4336,22 @@ div.day-empty {
|
||||
@keyframes themeOfTheMonthCardIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes themeOfTheMonthCardOut {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
}
|
||||
.themeOfTheMonthCardExpanded.themeOfTheMonthCardClosing {
|
||||
animation: themeOfTheMonthCardOut 0.18s ease-in forwards;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.themeOfTheMonthCard {
|
||||
.themeOfTheMonthCard,
|
||||
.themeOfTheMonthBackdrop {
|
||||
z-index: 2147483645;
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+4
@@ -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;
|
||||
@@ -3,6 +3,7 @@
|
||||
import { animate } from "motion";
|
||||
import { delay } from "@/seqta/utils/delay.ts";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
import CloudPfpAvatar from "@/interface/components/CloudPfpAvatar.svelte";
|
||||
|
||||
const { hidePanel } = $props<{
|
||||
hidePanel: () => void;
|
||||
@@ -105,12 +106,12 @@
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if cloudState.user?.pfpUrl}
|
||||
<img
|
||||
src={cloudState.user.pfpUrl}
|
||||
alt=""
|
||||
<CloudPfpAvatar
|
||||
user={cloudState.user}
|
||||
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">
|
||||
{getInitials()}
|
||||
</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">
|
||||
import { onMount } from "svelte";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
import CloudPfpAvatar from "@/interface/components/CloudPfpAvatar.svelte";
|
||||
|
||||
let { alwaysShowUserName = false, onClick = undefined } = $props<{
|
||||
alwaysShowUserName?: boolean;
|
||||
@@ -72,12 +73,12 @@
|
||||
>
|
||||
{#if cloudState.isLoggedIn}
|
||||
{#if cloudState.user?.pfpUrl}
|
||||
<img
|
||||
src={cloudState.user.pfpUrl}
|
||||
alt=""
|
||||
<CloudPfpAvatar
|
||||
user={cloudState.user}
|
||||
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]">
|
||||
{getInitials()}
|
||||
</div>
|
||||
@@ -111,12 +112,12 @@
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if cloudState.user?.pfpUrl}
|
||||
<img
|
||||
src={cloudState.user.pfpUrl}
|
||||
alt=""
|
||||
<CloudPfpAvatar
|
||||
user={cloudState.user}
|
||||
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">
|
||||
{getInitials()}
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
import CloudPanel from "../components/CloudPanel.svelte";
|
||||
import DisclaimerModal from "../components/DisclaimerModal.svelte";
|
||||
import { settingsPopup } from "../hooks/SettingsPopup";
|
||||
import {
|
||||
checkGithubReleaseUpdate,
|
||||
dismissNightlyUpdate,
|
||||
isGhReleaseUpdateCheckEnabled,
|
||||
type GhReleaseUpdateInfo,
|
||||
} from "@/utils/githubReleaseUpdate";
|
||||
|
||||
let devModeSequence = "";
|
||||
let settingsActiveTab = $state(0);
|
||||
@@ -26,6 +32,18 @@
|
||||
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
|
||||
let disclaimerTitle = $state("Confirm");
|
||||
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 handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -98,6 +116,12 @@
|
||||
if (standalone) {
|
||||
StandaloneStore.setStandalone(true);
|
||||
}
|
||||
|
||||
if (ghReleaseUpdateEnabled) {
|
||||
void checkGithubReleaseUpdate().then((info) => {
|
||||
ghReleaseUpdate = info;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -134,7 +158,25 @@
|
||||
/>
|
||||
|
||||
{#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
|
||||
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"
|
||||
@@ -156,6 +198,7 @@
|
||||
>
|
||||
{"\uecba"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- <button
|
||||
onclick={openMinecraftServer}
|
||||
|
||||
@@ -585,6 +585,21 @@
|
||||
{/if}
|
||||
</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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -15,11 +15,12 @@ import {
|
||||
letterToNumber,
|
||||
parseAssessments,
|
||||
processAssessments,
|
||||
type WeightingEntry,
|
||||
} from "./utils.ts";
|
||||
import { injectRubricCopyButtons } from "./rubricCopy.ts";
|
||||
|
||||
interface weightingsStorage {
|
||||
weightings: Record<string, string>;
|
||||
weightings: Record<string, WeightingEntry>;
|
||||
assessments: Record<string, string>;
|
||||
weightingOverrides: Record<string, string>;
|
||||
}
|
||||
@@ -61,8 +62,8 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
||||
1000,
|
||||
);
|
||||
|
||||
await parseAssessments(api);
|
||||
await renderSubjectAverage(api);
|
||||
// Wire listeners first so the very first re-render triggered by a
|
||||
// background handleWeightings completion can find them.
|
||||
overrideListenerController?.abort();
|
||||
overrideListenerController = new AbortController();
|
||||
document.addEventListener(
|
||||
@@ -70,6 +71,21 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
||||
() => renderSubjectAverage(api),
|
||||
{ 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");
|
||||
if (wrapper) {
|
||||
const observer = new MutationObserver(() => {
|
||||
@@ -87,8 +103,15 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
||||
};
|
||||
|
||||
let renderInFlight = false;
|
||||
let renderQueued = false;
|
||||
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;
|
||||
|
||||
try {
|
||||
@@ -141,8 +164,13 @@ async function renderSubjectAverage(api: any) {
|
||||
?.textContent?.includes("Subject Average"),
|
||||
);
|
||||
|
||||
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
|
||||
await processAssessments(api, assessmentItems);
|
||||
const {
|
||||
weightedTotal,
|
||||
totalWeight,
|
||||
hasInaccurateWeighting,
|
||||
hasRefreshingWeighting,
|
||||
count,
|
||||
} = await processAssessments(api, assessmentItems);
|
||||
if (!count || totalWeight === 0) return;
|
||||
|
||||
const thermoscoreElement = document.querySelector(
|
||||
@@ -176,11 +204,22 @@ async function renderSubjectAverage(api: any) {
|
||||
let warningHTML = "";
|
||||
if (hasInaccurateWeighting) {
|
||||
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
|
||||
</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(
|
||||
stringToHTML(/* html */ `
|
||||
<div class="${assessmentItemClass}">
|
||||
@@ -194,7 +233,7 @@ async function renderSubjectAverage(api: any) {
|
||||
</div>
|
||||
<div class="${thermoscoreClass}">
|
||||
<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>
|
||||
@@ -204,6 +243,10 @@ async function renderSubjectAverage(api: any) {
|
||||
applySubjectColourToOverallResult();
|
||||
} finally {
|
||||
renderInFlight = false;
|
||||
if (renderQueued) {
|
||||
renderQueued = false;
|
||||
void renderSubjectAverage(api);
|
||||
}
|
||||
}
|
||||
}
|
||||
function applySubjectColourToOverallResult() {
|
||||
|
||||
@@ -14,6 +14,59 @@ import * as pdfjs from "pdfjs-dist";
|
||||
|
||||
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) {
|
||||
await api.storage.loaded;
|
||||
|
||||
@@ -26,19 +79,34 @@ export async function initStorage(api: any) {
|
||||
if (!api.storage.weightingOverrides) {
|
||||
api.storage.weightingOverrides = {};
|
||||
}
|
||||
|
||||
migrateWeightings(api);
|
||||
}
|
||||
|
||||
export function clearStuck(api: any) {
|
||||
let hasStuckProcessing = false;
|
||||
for (const key in api.storage.weightings) {
|
||||
if (api.storage.weightings[key] === "processing") {
|
||||
delete api.storage.weightings[key];
|
||||
hasStuckProcessing = true;
|
||||
const map = (api.storage.weightings ?? {}) as WeightingsMap;
|
||||
let dirty = false;
|
||||
const out: WeightingsMap = {};
|
||||
for (const [key, entry] of Object.entries(map)) {
|
||||
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 (hasStuckProcessing) {
|
||||
api.storage.weightings = { ...api.storage.weightings };
|
||||
if (entry.refreshing) {
|
||||
const { refreshing: _ignored, ...rest } = entry;
|
||||
out[key] = rest;
|
||||
dirty = true;
|
||||
continue;
|
||||
}
|
||||
out[key] = entry;
|
||||
}
|
||||
if (dirty) api.storage.weightings = out;
|
||||
}
|
||||
|
||||
// Helper function to find actual class names by their base pattern
|
||||
@@ -137,6 +205,7 @@ function updateWeightLabelContent(
|
||||
weighting: string | undefined,
|
||||
assessmentID: string | undefined,
|
||||
api: any,
|
||||
refreshing = false,
|
||||
) {
|
||||
const existingInput = weightLabel.querySelector(
|
||||
".betterseqta-weight-input",
|
||||
@@ -178,10 +247,15 @@ function updateWeightLabelContent(
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.className = "betterseqta-weight-value";
|
||||
span.textContent =
|
||||
const baseText =
|
||||
weighting && weighting !== "N/A"
|
||||
? formatWeightDisplay(weighting)
|
||||
: "N/A";
|
||||
span.textContent = refreshing ? `${baseText} ↻` : baseText;
|
||||
if (refreshing) {
|
||||
span.style.opacity = "0.7";
|
||||
weightLabel.title = "Re-checking weighting…";
|
||||
}
|
||||
weightLabel.appendChild(span);
|
||||
}
|
||||
|
||||
@@ -189,6 +263,7 @@ function createWeightLabel(
|
||||
assessmentItem: Element,
|
||||
weighting: string | undefined,
|
||||
api: any,
|
||||
refreshing = false,
|
||||
) {
|
||||
let statsContainer = assessmentItem.querySelector(
|
||||
`[class*='AssessmentItem__stats___'], .betterseqta-stats-container`,
|
||||
@@ -224,7 +299,13 @@ function createWeightLabel(
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (existingLabel) {
|
||||
updateWeightLabelContent(existingLabel, weighting, assessmentID, api);
|
||||
updateWeightLabelContent(
|
||||
existingLabel,
|
||||
weighting,
|
||||
assessmentID,
|
||||
api,
|
||||
refreshing,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -256,7 +337,13 @@ function createWeightLabel(
|
||||
const innerTextDiv = weightLabel.querySelector(`[class*='Label__innerText___']`);
|
||||
if (innerTextDiv) innerTextDiv.textContent = "Weight";
|
||||
|
||||
updateWeightLabelContent(weightLabel, weighting, assessmentID, api);
|
||||
updateWeightLabelContent(
|
||||
weightLabel,
|
||||
weighting,
|
||||
assessmentID,
|
||||
api,
|
||||
refreshing,
|
||||
);
|
||||
statsContainer.appendChild(weightLabel);
|
||||
}
|
||||
|
||||
@@ -563,16 +650,41 @@ async function handleWeightings(mark: any, api: any) {
|
||||
const metaclassID = mark.metaclassID;
|
||||
const title = mark.title;
|
||||
|
||||
if (
|
||||
api.storage.weightings[assessmentID] != undefined &&
|
||||
api.storage.weightings[assessmentID] !== "processing"
|
||||
) {
|
||||
return;
|
||||
const fingerprint = computeFingerprint(mark);
|
||||
const existing = api.storage.weightings[assessmentID] as
|
||||
| WeightingEntry
|
||||
| undefined;
|
||||
|
||||
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,
|
||||
[assessmentID]: "processing",
|
||||
[assessmentID]: placeholder,
|
||||
};
|
||||
|
||||
api.storage.assessments = {
|
||||
@@ -580,6 +692,10 @@ async function handleWeightings(mark: any, api: any) {
|
||||
[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 {
|
||||
let pdfUrl: string;
|
||||
|
||||
@@ -655,14 +771,24 @@ async function handleWeightings(mark: any, api: any) {
|
||||
|
||||
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) {
|
||||
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) {
|
||||
@@ -684,6 +810,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
||||
let weightedTotal = 0;
|
||||
let totalWeight = 0;
|
||||
let hasInaccurateWeighting = false;
|
||||
let hasRefreshingWeighting = false;
|
||||
let count = 0;
|
||||
|
||||
for (const assessmentItem of assessmentItems) {
|
||||
@@ -696,15 +823,17 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
||||
if (!title) continue;
|
||||
|
||||
const assessmentID = api.storage.assessments?.[title];
|
||||
const autoWeighting = assessmentID
|
||||
? api.storage.weightings?.[assessmentID]
|
||||
const entry = assessmentID
|
||||
? (api.storage.weightings?.[assessmentID] as WeightingEntry | undefined)
|
||||
: undefined;
|
||||
const autoWeighting = entry?.weight;
|
||||
const override = assessmentID
|
||||
? api.storage.weightingOverrides?.[assessmentID]
|
||||
: undefined;
|
||||
const weighting = override ?? autoWeighting;
|
||||
const refreshing = !override && Boolean(entry?.refreshing);
|
||||
|
||||
createWeightLabel(assessmentItem, weighting, api);
|
||||
createWeightLabel(assessmentItem, weighting, api, refreshing);
|
||||
|
||||
const gradeElement = assessmentItem.querySelector(
|
||||
`[class*='Thermoscore__text___']`,
|
||||
@@ -727,6 +856,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
||||
if (!isNaN(weight) && weight >= 0) {
|
||||
weightedTotal += grade * weight;
|
||||
totalWeight += weight;
|
||||
if (refreshing) hasRefreshingWeighting = true;
|
||||
} else {
|
||||
weightedTotal += grade;
|
||||
totalWeight += 1;
|
||||
@@ -740,6 +870,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
||||
weightedTotal,
|
||||
totalWeight,
|
||||
hasInaccurateWeighting,
|
||||
hasRefreshingWeighting,
|
||||
count,
|
||||
};
|
||||
}
|
||||
@@ -811,9 +942,10 @@ function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
|
||||
const title = titleEl?.textContent?.trim();
|
||||
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
|
||||
|
||||
const rawWeight = assessmentID
|
||||
? api.storage.weightings?.[assessmentID]
|
||||
const entry = assessmentID
|
||||
? (api.storage.weightings?.[assessmentID] as WeightingEntry | undefined)
|
||||
: undefined;
|
||||
const rawWeight = entry?.weight;
|
||||
|
||||
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;
|
||||
@@ -8,6 +8,7 @@ import ProfilePictureSetting from "./ProfilePictureSetting.svelte";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import browser from "webextension-polyfill";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
import { resolveCloudPfp } from "@/seqta/utils/cloudPfpCache";
|
||||
import styles from "./styles.css?inline";
|
||||
import localforage from "localforage";
|
||||
|
||||
@@ -65,15 +66,18 @@ const profilePicturePlugin: Plugin<typeof settings> = {
|
||||
const useCloud = api.settings.useCloudPfp;
|
||||
const pfpUrl = cloudAuth.state.user?.pfpUrl;
|
||||
|
||||
if (useCloud && pfpUrl) {
|
||||
if (useCloud && pfpUrl && cloudAuth.state.user?.id) {
|
||||
const resolved = await resolveCloudPfp(cloudAuth.state.user.id, pfpUrl);
|
||||
if (resolved) {
|
||||
currentBlobUrl = resolved.src;
|
||||
img = document.createElement("img");
|
||||
img.className = "userInfoImg";
|
||||
const base = pfpUrl.split("?")[0]!;
|
||||
img.src = `${base}?v=${Date.now()}`;
|
||||
img.src = resolved.src;
|
||||
if (svg) svg.style.display = "none";
|
||||
container.appendChild(img);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await store.getItem<Blob>("profile-picture");
|
||||
if (blob && blob instanceof Blob) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import profilePicturePlugin from "./built-in/profilePicture";
|
||||
import assessmentsOverviewPlugin from "./built-in/assessmentsOverview";
|
||||
import backgroundMusicPlugin from "./built-in/backgroundMusic";
|
||||
import messageFoldersPlugin from "./built-in/messageFolders";
|
||||
import enhancedNavigationPlugin from "./built-in/enhancedNavigation";
|
||||
//import testPlugin from './built-in/test';
|
||||
|
||||
// Heavy plugins (lazy-loaded only when enabled)
|
||||
@@ -31,6 +32,7 @@ pluginManager.registerPlugin(profilePicturePlugin);
|
||||
pluginManager.registerPlugin(assessmentsOverviewPlugin);
|
||||
pluginManager.registerPlugin(backgroundMusicPlugin);
|
||||
pluginManager.registerPlugin(messageFoldersPlugin);
|
||||
pluginManager.registerPlugin(enhancedNavigationPlugin);
|
||||
//pluginManager.registerPlugin(testPlugin);
|
||||
|
||||
// Register heavy plugins with lazy loading
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import { clearCloudPfpCache } from "@/seqta/utils/cloudPfpCache";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
|
||||
const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback";
|
||||
@@ -16,6 +17,7 @@ export type CloudUser = {
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
pfpUrl?: string;
|
||||
pfpHash?: string | null;
|
||||
admin_level?: number;
|
||||
};
|
||||
|
||||
@@ -201,6 +203,8 @@ class CloudAuthService {
|
||||
}
|
||||
|
||||
public async logout(): Promise<void> {
|
||||
const userId = this._state.user?.id;
|
||||
if (userId) await clearCloudPfpCache(userId);
|
||||
await browser.storage.local.remove([
|
||||
STORAGE_KEYS.accessToken,
|
||||
STORAGE_KEYS.refreshToken,
|
||||
|
||||
@@ -5,11 +5,13 @@ import { closePopup } from "./PopupManager";
|
||||
import { getApiBase } from "../DevApiBase";
|
||||
import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight";
|
||||
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 {
|
||||
id: string;
|
||||
month: string;
|
||||
@@ -22,12 +24,6 @@ export interface ThemeOfTheMonthEntry {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current month's Theme of the Month entry from the API.
|
||||
* Returns `null` when no entry is configured for this month, or when the
|
||||
* request fails (we never want a network problem to block other startup
|
||||
* popups).
|
||||
*/
|
||||
export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | null> {
|
||||
try {
|
||||
const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, {
|
||||
@@ -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 {
|
||||
if (!entry || settingsState.themeOfTheMonthDisabled) return false;
|
||||
return settingsState.themeOfTheMonthDismissedMonth !== entry.month;
|
||||
@@ -67,7 +62,6 @@ function formatMonthLabel(month: string): string {
|
||||
return date.toLocaleDateString("en-US", { year: "numeric", month: "long" });
|
||||
}
|
||||
|
||||
/** Same priority as the theme store: marquee, then cover/banner. */
|
||||
function heroUrlFromStoreTheme(theme: {
|
||||
marqueeImage?: string | null;
|
||||
coverImage?: string | null;
|
||||
@@ -76,41 +70,484 @@ function heroUrlFromStoreTheme(theme: {
|
||||
return url || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads hero image for a store theme via the background script (same path as
|
||||
* {@link ThemeSelector} / theme store detail fetches).
|
||||
*/
|
||||
export async function fetchThemeStoreHeroImage(themeId: string): Promise<string | null> {
|
||||
export async function fetchThemeStoreTheme(themeId: string): Promise<Theme | null> {
|
||||
try {
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
const res = (await browser.runtime.sendMessage({
|
||||
type: "fetchThemeDetails",
|
||||
themeId,
|
||||
token: token ?? undefined,
|
||||
})) as { success?: boolean; data?: { theme?: { marqueeImage?: string; coverImage?: string } } };
|
||||
})) as { success?: boolean; data?: { theme?: Record<string, unknown> } };
|
||||
|
||||
if (!res?.success || !res?.data?.theme) return null;
|
||||
return heroUrlFromStoreTheme(res.data.theme);
|
||||
return normalizeStoreTheme(res.data.theme);
|
||||
} catch (err) {
|
||||
console.warn("[ThemeOfTheMonth] Failed to fetch theme store image:", err);
|
||||
console.warn("[ThemeOfTheMonth] Failed to fetch theme store details:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Linked theme store image, else optional admin-uploaded cover. */
|
||||
async function resolvePopupHeroImageUrl(entry: ThemeOfTheMonthEntry): Promise<string | null> {
|
||||
const themeId = entry.theme_id ?? entry.theme?.id;
|
||||
if (themeId) {
|
||||
const fromStore = await fetchThemeStoreHeroImage(themeId);
|
||||
if (fromStore) return fromStore;
|
||||
}
|
||||
const fallback = entry.cover_image?.trim();
|
||||
return fallback || null;
|
||||
export async function fetchThemeStoreHeroImage(themeId: string): Promise<string | null> {
|
||||
const theme = await fetchThemeStoreTheme(themeId);
|
||||
return theme ? heroUrlFromStoreTheme(theme) : null;
|
||||
}
|
||||
|
||||
function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void) {
|
||||
type PopupGallerySlide = { imageUrl: string; caption: string };
|
||||
|
||||
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");
|
||||
window.setTimeout(() => {
|
||||
card.remove();
|
||||
@@ -118,38 +555,127 @@ function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void) {
|
||||
}, 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(
|
||||
entry: ThemeOfTheMonthEntry,
|
||||
onDismissed?: () => void,
|
||||
) {
|
||||
): Promise<void> {
|
||||
document.getElementById("theme-of-the-month-card")?.remove();
|
||||
document.getElementById("theme-of-the-month-backdrop")?.remove();
|
||||
|
||||
const monthLabel = formatMonthLabel(entry.month);
|
||||
const heroUrl = await resolvePopupHeroImageUrl(entry);
|
||||
const description = escapeHTML(entry.description).replace(/\n/g, " ");
|
||||
const linkedThemeId = entry.theme_id ?? entry.theme?.id;
|
||||
const 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 */ `
|
||||
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard" role="dialog" aria-label="Theme of the Month">
|
||||
${
|
||||
heroUrl
|
||||
? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />`
|
||||
: ""
|
||||
}
|
||||
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard${settingsState.animations ? "" : " themeOfTheMonthCardReducedMotion"}" role="dialog" aria-label="Theme of the Month">
|
||||
<div class="themeOfTheMonthCardMedia">
|
||||
<button type="button" class="themeOfTheMonthCardPopout" aria-label="Expand" title="Expand"${hasExpandableContent ? "" : " hidden"}>
|
||||
<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">
|
||||
<p class="themeOfTheMonthCardEyebrow">Theme of the Month · ${escapeHTML(monthLabel)}</p>
|
||||
<h2>${escapeHTML(entry.title)}</h2>
|
||||
<p class="themeOfTheMonthCardDescription">${description}</p>
|
||||
<p class="themeOfTheMonthCardDescription themeOfTheMonthCardDescriptionClipped">${descriptionHtml}</p>
|
||||
<div class="themeOfTheMonthCardActions">
|
||||
<div class="themeOfTheMonthCardActionsStart">
|
||||
${
|
||||
linkedThemeId
|
||||
? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>`
|
||||
: ""
|
||||
}
|
||||
${linkedThemeId ? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>` : ""}
|
||||
</div>
|
||||
<div class="themeOfTheMonthCardActionsEnd">
|
||||
<button type="button" class="themeOfTheMonthCardSecondary">Close</button>
|
||||
@@ -170,32 +696,97 @@ export async function OpenThemeOfTheMonthPopup(
|
||||
</aside>
|
||||
`).firstChild as HTMLElement;
|
||||
|
||||
const autoCloseTimeout = window.setTimeout(() => {
|
||||
closeThemeOfTheMonthCard(card, onDismissed);
|
||||
}, 30_000);
|
||||
let isExpanded = false;
|
||||
let expandAnimating = false;
|
||||
|
||||
const dismiss = () => {
|
||||
window.clearTimeout(autoCloseTimeout);
|
||||
const applyExpandedState = async (expanded: boolean): Promise<void> => {
|
||||
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);
|
||||
};
|
||||
|
||||
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 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", () => {
|
||||
settingsState.themeOfTheMonthDismissedMonth = entry.month;
|
||||
dismiss();
|
||||
dismissWithCleanup();
|
||||
});
|
||||
|
||||
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
|
||||
settingsState.themeOfTheMonthDismissedMonth = entry.month;
|
||||
dismiss();
|
||||
dismissWithCleanup();
|
||||
openThemeStoreWithHighlight(linkedThemeId!);
|
||||
});
|
||||
|
||||
const openDontShowConfirm = () => {
|
||||
window.clearTimeout(autoCloseTimeout);
|
||||
pauseAutoClose();
|
||||
if (!confirmEl) return;
|
||||
confirmEl.hidden = false;
|
||||
requestAnimationFrame(() => confirmEl.classList.add("themeOfTheMonthCardConfirmVisible"));
|
||||
@@ -206,23 +797,37 @@ export async function OpenThemeOfTheMonthPopup(
|
||||
card.querySelector(".themeOfTheMonthCardConfirmCancel")?.addEventListener("click", () => {
|
||||
if (!confirmEl) return;
|
||||
confirmEl.classList.remove("themeOfTheMonthCardConfirmVisible");
|
||||
window.setTimeout(() => {
|
||||
confirmEl.hidden = true;
|
||||
}, 160);
|
||||
window.setTimeout(() => { confirmEl.hidden = true; }, 160);
|
||||
});
|
||||
|
||||
card.querySelector(".themeOfTheMonthCardConfirmAccept")?.addEventListener("click", () => {
|
||||
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> {
|
||||
const entry = await fetchThemeOfTheMonth();
|
||||
if (!entry) {
|
||||
|
||||
@@ -42,7 +42,8 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
|
||||
const text = stringToHTML(/* html */ `
|
||||
<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 distribution auto-detects your school’s letter scale from released marks for analytics page.</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 rubric copy on assessment detail pages.</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>Tweak Theme of the Month popup making it more clear about dismissals and respecting “Don’t show again”.</li>
|
||||
<li>Fixed duplicate-result fixes.</li>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import localforage from "localforage";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
import { clearCloudPfpCache, pfpUrlWithHash } from "@/seqta/utils/cloudPfpCache";
|
||||
|
||||
const ACCOUNTS_BASE = "https://accounts.betterseqta.org";
|
||||
const PLUGIN_SETTINGS_KEY = "plugin.profile-picture.settings";
|
||||
@@ -10,9 +11,32 @@ const profileStore = localforage.createInstance({
|
||||
storeName: "profilePicture",
|
||||
});
|
||||
|
||||
function cacheBustPfpUrl(url: string): string {
|
||||
const base = url.split("?")[0]!;
|
||||
return `${base}?v=${Date.now()}`;
|
||||
/** Downscale before upload to reduce ingress (server still normalizes). */
|
||||
async function downscaleForUpload(blob: Blob, maxEdge = 512): Promise<Blob> {
|
||||
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> {
|
||||
@@ -41,6 +65,9 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
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");
|
||||
|
||||
try {
|
||||
@@ -57,10 +84,10 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
|
||||
if (!res.ok) {
|
||||
return { success: false, error: (data.error as string) ?? `Clear failed (${res.status})` };
|
||||
}
|
||||
const user = cloudAuth.state.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 };
|
||||
}
|
||||
|
||||
@@ -71,8 +98,9 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
|
||||
return { success: false, error: "File too large (max 5MB)" };
|
||||
}
|
||||
|
||||
const uploadBlob = await downscaleForUpload(blob);
|
||||
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`, {
|
||||
method: "POST",
|
||||
@@ -85,10 +113,15 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
|
||||
}
|
||||
|
||||
const pfpUrl = data.pfpUrl as string | undefined;
|
||||
const user = cloudAuth.state.user;
|
||||
const pfpHash = (data.pfpHash as string | null | undefined) ?? null;
|
||||
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 };
|
||||
} catch (err) {
|
||||
return {
|
||||
|
||||
@@ -49,6 +49,10 @@ export interface SettingsState {
|
||||
animations: boolean;
|
||||
defaultPage: string;
|
||||
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;
|
||||
newsSource?: string;
|
||||
mockNotices?: boolean;
|
||||
@@ -71,7 +75,7 @@ export interface SettingsState {
|
||||
bsplus_client_id?: string;
|
||||
bsplus_token?: string;
|
||||
bsplus_refresh_token?: string;
|
||||
bsplus_user?: { id: string; email?: string; username?: string; displayName?: string; pfpUrl?: string; admin_level?: number };
|
||||
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). */
|
||||
autoCloudSettingsSync?: boolean;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -35,6 +35,7 @@
|
||||
"src/**/*.js",
|
||||
"src/**/*.svelte",
|
||||
"src/interface/+layout.svelte",
|
||||
"src/env.d.ts",
|
||||
"declarations.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -28,6 +28,16 @@ const mode = process.env.MODE || "chrome"; // Check the environment variable to
|
||||
const useMillion = mode.toLowerCase() !== "firefox";
|
||||
|
||||
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: [
|
||||
base64Loader,
|
||||
InlineWorkerPlugin(),
|
||||
|
||||
Reference in New Issue
Block a user