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,
|
"es2021": true,
|
||||||
"node": true
|
"node": true
|
||||||
},
|
},
|
||||||
"extends": "eslint:recommended",
|
"extends": ["eslint:recommended"],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"ecmaVersion": "latest",
|
"ecmaVersion": "latest",
|
||||||
"sourceType": "module"
|
"sourceType": "module"
|
||||||
},
|
},
|
||||||
"rules": {
|
"plugins": ["@typescript-eslint", "import"],
|
||||||
// allow importing ts extensions
|
"ignorePatterns": ["**/*.d.ts"],
|
||||||
"sort-imports": [
|
"globals": {
|
||||||
"error",
|
"__ENABLE_GH_RELEASE_UPDATE_CHECK__": "readonly",
|
||||||
{
|
"__GH_RELEASE_REPO__": "readonly",
|
||||||
"ignoreCase": true,
|
"__UPDATE_CHANNEL__": "readonly",
|
||||||
"ignoreDeclarationSort": true,
|
"__BUILD_LABEL__": "readonly"
|
||||||
"ignoreMemberSort": false,
|
|
||||||
"memberSyntaxSortOrder": ["none", "all", "multiple", "single"]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"import/extensions": [
|
|
||||||
"error",
|
|
||||||
"ignorePackages",
|
|
||||||
{
|
|
||||||
"js": "never",
|
|
||||||
"ts": "never",
|
|
||||||
"tsx": "never"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"plugins": ["import"]
|
"rules": {
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"no-undef": "off",
|
||||||
|
"no-useless-escape": "off",
|
||||||
|
"no-prototype-builtins": "off",
|
||||||
|
"no-empty": "off",
|
||||||
|
"no-case-declarations": "off",
|
||||||
|
"no-irregular-whitespace": "off",
|
||||||
|
"sort-imports": "off",
|
||||||
|
"import/extensions": "off",
|
||||||
|
"no-async-promise-executor": "off"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
name: Build Extension
|
||||||
|
description: Install dependencies, build Chrome and Firefox extensions, and package zips.
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
gh_release_update_check:
|
||||||
|
description: Enable GitHub release update checker in the built extension.
|
||||||
|
required: false
|
||||||
|
default: "false"
|
||||||
|
update_channel:
|
||||||
|
description: Update channel baked into the build (stable or nightly).
|
||||||
|
required: false
|
||||||
|
default: stable
|
||||||
|
build_label:
|
||||||
|
description: Optional build label for nightly display (e.g. run number).
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
release_repo:
|
||||||
|
description: GitHub repo slug for the update checker (owner/name).
|
||||||
|
required: false
|
||||||
|
default: BetterSEQTA/BetterSEQTA-Plus
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
version:
|
||||||
|
description: Version from package.json
|
||||||
|
value: ${{ steps.version.outputs.version }}
|
||||||
|
chrome_zip:
|
||||||
|
description: Path to the Chrome extension zip
|
||||||
|
value: ${{ steps.zip.outputs.chrome_zip }}
|
||||||
|
firefox_zip:
|
||||||
|
description: Path to the Firefox extension zip
|
||||||
|
value: ${{ steps.zip.outputs.firefox_zip }}
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Use Node.js 20.x
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20.x
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
shell: bash
|
||||||
|
run: npm install --legacy-peer-deps
|
||||||
|
|
||||||
|
- name: Read version
|
||||||
|
id: version
|
||||||
|
shell: bash
|
||||||
|
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build extension
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_RELEASE_UPDATE_CHECK: ${{ inputs.gh_release_update_check }}
|
||||||
|
UPDATE_CHANNEL: ${{ inputs.update_channel }}
|
||||||
|
GH_RELEASE_REPO: ${{ inputs.release_repo }}
|
||||||
|
BUILD_LABEL: ${{ inputs.build_label }}
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Package zips
|
||||||
|
id: zip
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
(cd dist/chrome && zip -r "../betterseqtaplus-${VERSION}-chrome.zip" .)
|
||||||
|
(cd dist/firefox && zip -r "../betterseqtaplus-${VERSION}-firefox.zip" .)
|
||||||
|
echo "chrome_zip=dist/betterseqtaplus-${VERSION}-chrome.zip" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "firefox_zip=dist/betterseqtaplus-${VERSION}-firefox.zip" >> "$GITHUB_OUTPUT"
|
||||||
@@ -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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["main"]
|
branches: ["main"]
|
||||||
pull_request:
|
|
||||||
branches: ["main"]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
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/)
|
- [Documentation home](https://docs.betterseqta.org/)
|
||||||
- [Installation](https://docs.betterseqta.org/install/)
|
- [Installation](https://docs.betterseqta.org/install/)
|
||||||
- [Contributing](https://docs.betterseqta.org/contributing/)
|
- [Contributing](https://docs.betterseqta.org/contributing/)
|
||||||
|
- [GitHub releases & CI](RELEASES.md) — workflows, nightly builds, update detector
|
||||||
- [Architecture](https://docs.betterseqta.org/architecture/)
|
- [Architecture](https://docs.betterseqta.org/architecture/)
|
||||||
- [Contribution guidelines (repository)](../CONTRIBUTING.md)
|
- [Contribution guidelines (repository)](../CONTRIBUTING.md)
|
||||||
- [Troubleshooting](https://docs.betterseqta.org/troubleshooting/)
|
- [Troubleshooting](https://docs.betterseqta.org/troubleshooting/)
|
||||||
|
|||||||
@@ -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.
|
If your changes require documentation updates, include them in the same PR.
|
||||||
|
|
||||||
4. **Run Tests**
|
4. **Run CI checks locally**
|
||||||
|
|
||||||
Make sure all tests pass before submitting your PR:
|
Pull requests trigger **PR CI**, which lints and builds in a clean environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm run lint
|
||||||
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Submit Your PR**
|
5. **Submit Your PR**
|
||||||
@@ -139,6 +140,25 @@ git checkout -b feature/my-new-feature
|
|||||||
|
|
||||||
Once approved, a maintainer will merge your PR.
|
Once approved, a maintainer will merge your PR.
|
||||||
|
|
||||||
|
### GitHub Actions workflows
|
||||||
|
|
||||||
|
See [RELEASES.md](RELEASES.md) for a full guide (workflows, sideload installs, and update detector).
|
||||||
|
|
||||||
|
| Workflow | Trigger | Purpose |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `pr-ci.yml` | Every PR to `main` | Typecheck, lint, and build (no release) |
|
||||||
|
| `mvp.yml` | Push to `main` | Build and upload `dist.zip` artifact |
|
||||||
|
| `release.yml` | Manual (`workflow_dispatch`) | Stable release on [BetterSEQTA/BetterSEQTA-Plus](https://github.com/BetterSEQTA/BetterSEQTA-Plus) tagged with `package.json` version |
|
||||||
|
| `nightly.yml` | Daily cron + manual | Updates the fixed `nightly` prerelease with latest `main` builds |
|
||||||
|
|
||||||
|
**Releasing a stable version:** bump `version` in `package.json` on `main`, then manually run the **Release** workflow and confirm the version checkbox. Edit the release title and notes on GitHub after the first publish. Re-running for the same tag only updates the zip files. Assets are `betterseqtaplus-{version}-chrome.zip` and `-firefox.zip`.
|
||||||
|
|
||||||
|
**Nightly builds** replace assets on the same `nightly` release. The release body warns that builds are experimental and must not be uploaded to extension stores.
|
||||||
|
|
||||||
|
**GitHub release builds** include an in-extension update checker (settings header badge). **PR CI and local dev builds do not.**
|
||||||
|
|
||||||
|
Release workflows are dispatched only on the main **BetterSEQTA/BetterSEQTA-Plus** repository and use the default `GITHUB_TOKEN`.
|
||||||
|
|
||||||
### Coding Standards
|
### Coding Standards
|
||||||
|
|
||||||
We follow TypeScript best practices and have a consistent code style:
|
We follow TypeScript best practices and have a consistent code style:
|
||||||
|
|||||||
+5
-1
@@ -17,7 +17,8 @@
|
|||||||
"build:dev": "cross-env MODE=chrome SOURCEMAP=true vite build && cross-env MODE=firefox SOURCEMAP=true vite build",
|
"build:dev": "cross-env MODE=chrome SOURCEMAP=true vite build && cross-env MODE=firefox SOURCEMAP=true vite build",
|
||||||
"convert:safari": "xcrun safari-web-extension-converter dist/safari --project-location . --app-name $npm_package_name-safari",
|
"convert:safari": "xcrun safari-web-extension-converter dist/safari --project-location . --app-name $npm_package_name-safari",
|
||||||
"dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg",
|
"dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg",
|
||||||
"release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes",
|
"lint": "cross-env ESLINT_USE_FLAT_CONFIG=false eslint \"src/**/*.{js,ts}\"",
|
||||||
|
"release": "gh release create $npm_package_version --repo BetterSEQTA/BetterSEQTA-Plus ./dist/*.zip --generate-notes",
|
||||||
"publish": "bun lib/publish.js --b",
|
"publish": "bun lib/publish.js --b",
|
||||||
"zip": "bedframe zip"
|
"zip": "bedframe zip"
|
||||||
},
|
},
|
||||||
@@ -45,9 +46,12 @@
|
|||||||
"@types/mime-types": "^3.0.1",
|
"@types/mime-types": "^3.0.1",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||||
|
"@typescript-eslint/parser": "^8.60.1",
|
||||||
"cross-env": "^10.0.0",
|
"cross-env": "^10.0.0",
|
||||||
"dependency-cruiser": "^17.0.1",
|
"dependency-cruiser": "^17.0.1",
|
||||||
"eslint": "^9.33.0",
|
"eslint": "^9.33.0",
|
||||||
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"glob": "^11.0.1",
|
"glob": "^11.0.1",
|
||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
|
|||||||
+327
-18
@@ -3809,19 +3809,306 @@ div.day-empty {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.themeOfTheMonthBackdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 47;
|
||||||
|
background: color-mix(in srgb, #000 52%, transparent);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
-webkit-backdrop-filter: blur(5px);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.55s cubic-bezier(0.76, 0, 0.24, 1);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthBackdropVisible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
.themeOfTheMonthCard {
|
.themeOfTheMonthCard {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: max(18px, env(safe-area-inset-right));
|
top: 0;
|
||||||
bottom: max(18px, env(safe-area-inset-bottom));
|
left: 0;
|
||||||
|
right: auto;
|
||||||
|
bottom: auto;
|
||||||
z-index: 48;
|
z-index: 48;
|
||||||
|
margin: 0;
|
||||||
width: min(360px, calc(100vw - 36px));
|
width: min(360px, calc(100vw - 36px));
|
||||||
overflow: visible;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
|
border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background: var(--background-primary);
|
background: var(--background-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
|
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
|
||||||
animation: themeOfTheMonthCardIn 0.24s ease-out;
|
transform-origin: bottom right;
|
||||||
|
transition: none;
|
||||||
|
animation: themeOfTheMonthCardIn 0.28s ease-out;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardExpanded {
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
/* translate(x,y) is set inline; transition enabled after mount */
|
||||||
|
.themeOfTheMonthCardMorphReady:not(.themeOfTheMonthCardReducedMotion) {
|
||||||
|
transition:
|
||||||
|
transform 0.55s cubic-bezier(0.76, 0, 0.24, 1),
|
||||||
|
width 0.55s cubic-bezier(0.76, 0, 0.24, 1),
|
||||||
|
height 0.55s cubic-bezier(0.76, 0, 0.24, 1),
|
||||||
|
max-height 0.55s cubic-bezier(0.76, 0, 0.24, 1),
|
||||||
|
border-radius 0.55s cubic-bezier(0.76, 0, 0.24, 1);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardAnchoredBottom,
|
||||||
|
.themeOfTheMonthCardCollapsing {
|
||||||
|
transform-origin: 100% 100% !important;
|
||||||
|
}
|
||||||
|
/* Expanded: fixed shell; copy scrolls; actions pinned to the bottom. */
|
||||||
|
.themeOfTheMonthCardExpanded.themeOfTheMonthCardExpandedShell .themeOfTheMonthCardBody {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 14px 16px 16px;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardExpanded.themeOfTheMonthCardExpandedShell .themeOfTheMonthCardDescription {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardExpanded.themeOfTheMonthCardExpandedShell .themeOfTheMonthCardActions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardReducedMotion {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardMedia {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
#theme-of-the-month-card .themeOfTheMonthCardPopout {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
z-index: 6;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50% !important;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: color-mix(in srgb, var(--background-primary) 80%, transparent);
|
||||||
|
box-shadow:
|
||||||
|
0 2px 10px rgba(0, 0, 0, 0.24),
|
||||||
|
inset 0 0 0 1px color-mix(in srgb, var(--text-primary) 14%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
transition: background 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardPopoutIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardPopout:hover {
|
||||||
|
background: color-mix(in srgb, var(--background-primary) 95%, transparent);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardPopout:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardPopout[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardCompactMedia {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardExpanded.themeOfTheMonthCardShowGallery .themeOfTheMonthCardCompactMedia {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardExpandedPanel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardExpandedPanel[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardExpanded.themeOfTheMonthCardShowGallery
|
||||||
|
.themeOfTheMonthCardExpandedPanel:not([hidden]) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardGallery {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardHeroEmboss {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 4;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardHeroEmbossScrim {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
transparent 0%,
|
||||||
|
color-mix(in srgb, #000 8%, transparent) 40%,
|
||||||
|
color-mix(in srgb, #000 55%, transparent) 72%,
|
||||||
|
color-mix(in srgb, #000 82%, transparent) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardHeroEmbossContent {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmboss {
|
||||||
|
display: flex;
|
||||||
|
padding: 14px 16px 16px;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow:
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.55),
|
||||||
|
0 2px 14px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossAuthor {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: color-mix(in srgb, #fff 88%, transparent);
|
||||||
|
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossDescription {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: color-mix(in srgb, #fff 92%, transparent);
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossVariants {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: color-mix(in srgb, #fff 72%, transparent);
|
||||||
|
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardExpanded.themeOfTheMonthCardShowGallery
|
||||||
|
.themeOfTheMonthCardGallerySlide
|
||||||
|
figcaption {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardGalleryTrack {
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardGalleryTrack::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardGallerySlide {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
margin: 0;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardGallerySlide img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: min(42vh, 280px);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardGallerySlide figcaption {
|
||||||
|
padding: 8px 14px 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: color-mix(in srgb, var(--text-primary) 68%, transparent);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardGalleryPrev,
|
||||||
|
.themeOfTheMonthCardGalleryNext {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
z-index: 2;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
margin-top: -17px;
|
||||||
|
padding: 0;
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 9999px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: color-mix(in srgb, var(--background-primary) 88%, transparent);
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardGalleryPrev {
|
||||||
|
left: 10px;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardGalleryNext {
|
||||||
|
right: 10px;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardGalleryDots {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px 0;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardGalleryDot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
padding: 0;
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 9999px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: color-mix(in srgb, var(--text-primary) 28%, transparent);
|
||||||
|
transition: background 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardGalleryDotActive {
|
||||||
|
background: var(--better-pri, #6366f1);
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardDescriptionTyping {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: unset;
|
||||||
}
|
}
|
||||||
.themeOfTheMonthCard::before {
|
.themeOfTheMonthCard::before {
|
||||||
content: "";
|
content: "";
|
||||||
@@ -3908,16 +4195,25 @@ div.day-empty {
|
|||||||
.themeOfTheMonthCardConfirmAccept:active {
|
.themeOfTheMonthCardConfirmAccept:active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
.themeOfTheMonthCardImage {
|
#theme-of-the-month-card .themeOfTheMonthCardImage {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100% !important;
|
||||||
height: 150px;
|
min-width: 100%;
|
||||||
|
height: 150px !important;
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-radius: 20px 20px 0 0;
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
object-position: center center;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardExpanded .themeOfTheMonthCardGallerySlide img {
|
||||||
|
border-radius: 22px 22px 0 0;
|
||||||
}
|
}
|
||||||
.themeOfTheMonthCardBody {
|
.themeOfTheMonthCardBody {
|
||||||
padding: 14px 16px 16px;
|
padding: 14px 16px 12px;
|
||||||
}
|
}
|
||||||
.themeOfTheMonthCardEyebrow {
|
.themeOfTheMonthCardEyebrow {
|
||||||
margin: 0 0 6px;
|
margin: 0 0 6px;
|
||||||
@@ -3933,14 +4229,26 @@ div.day-empty {
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
.themeOfTheMonthCardDescription {
|
.themeOfTheMonthCardDescription {
|
||||||
display: -webkit-box;
|
margin: 8px 0 10px;
|
||||||
margin: 8px 0 14px;
|
|
||||||
overflow: hidden;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
|
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardDescriptionClipped {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardDescriptionExpanded {
|
||||||
|
display: block;
|
||||||
|
-webkit-line-clamp: unset;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardExpanding:not(.themeOfTheMonthCardExpandedShell)
|
||||||
|
.themeOfTheMonthCardDescriptionExpanded {
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.themeOfTheMonthCardActions {
|
.themeOfTheMonthCardActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -4028,21 +4336,22 @@ div.day-empty {
|
|||||||
@keyframes themeOfTheMonthCardIn {
|
@keyframes themeOfTheMonthCardIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(18px) scale(0.98);
|
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0) scale(1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@keyframes themeOfTheMonthCardOut {
|
@keyframes themeOfTheMonthCardOut {
|
||||||
to {
|
to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(12px) scale(0.98);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.themeOfTheMonthCardExpanded.themeOfTheMonthCardClosing {
|
||||||
|
animation: themeOfTheMonthCardOut 0.18s ease-in forwards;
|
||||||
|
}
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.themeOfTheMonthCard {
|
.themeOfTheMonthCard,
|
||||||
|
.themeOfTheMonthBackdrop {
|
||||||
z-index: 2147483645;
|
z-index: 2147483645;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 { animate } from "motion";
|
||||||
import { delay } from "@/seqta/utils/delay.ts";
|
import { delay } from "@/seqta/utils/delay.ts";
|
||||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||||
|
import CloudPfpAvatar from "@/interface/components/CloudPfpAvatar.svelte";
|
||||||
|
|
||||||
const { hidePanel } = $props<{
|
const { hidePanel } = $props<{
|
||||||
hidePanel: () => void;
|
hidePanel: () => void;
|
||||||
@@ -105,12 +106,12 @@
|
|||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
{#if cloudState.user?.pfpUrl}
|
{#if cloudState.user?.pfpUrl}
|
||||||
<img
|
<CloudPfpAvatar
|
||||||
src={cloudState.user.pfpUrl}
|
user={cloudState.user}
|
||||||
alt=""
|
|
||||||
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
|
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{/if}
|
||||||
|
{#if !cloudState.user?.pfpUrl}
|
||||||
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
|
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
|
||||||
{getInitials()}
|
{getInitials()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolveCloudPfp } from "@/seqta/utils/cloudPfpCache";
|
||||||
|
import type { CloudUser } from "@/seqta/utils/CloudAuth";
|
||||||
|
|
||||||
|
const { user, class: className = "" } = $props<{
|
||||||
|
user: CloudUser | null | undefined;
|
||||||
|
class?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let avatarSrc = $state<string | undefined>(undefined);
|
||||||
|
let revokeUrl: string | undefined;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const u = user;
|
||||||
|
if (revokeUrl) {
|
||||||
|
URL.revokeObjectURL(revokeUrl);
|
||||||
|
revokeUrl = undefined;
|
||||||
|
}
|
||||||
|
avatarSrc = undefined;
|
||||||
|
|
||||||
|
if (!u?.pfpUrl || !u.id) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
void resolveCloudPfp(u.id, u.pfpUrl).then((resolved) => {
|
||||||
|
if (cancelled || !resolved) return;
|
||||||
|
if (resolved.fromCache) {
|
||||||
|
revokeUrl = resolved.src;
|
||||||
|
}
|
||||||
|
avatarSrc = resolved.src;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (revokeUrl) {
|
||||||
|
URL.revokeObjectURL(revokeUrl);
|
||||||
|
revokeUrl = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if avatarSrc}
|
||||||
|
<img src={avatarSrc} alt="" class={className} />
|
||||||
|
{/if}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||||
|
import CloudPfpAvatar from "@/interface/components/CloudPfpAvatar.svelte";
|
||||||
|
|
||||||
let { alwaysShowUserName = false, onClick = undefined } = $props<{
|
let { alwaysShowUserName = false, onClick = undefined } = $props<{
|
||||||
alwaysShowUserName?: boolean;
|
alwaysShowUserName?: boolean;
|
||||||
@@ -72,12 +73,12 @@
|
|||||||
>
|
>
|
||||||
{#if cloudState.isLoggedIn}
|
{#if cloudState.isLoggedIn}
|
||||||
{#if cloudState.user?.pfpUrl}
|
{#if cloudState.user?.pfpUrl}
|
||||||
<img
|
<CloudPfpAvatar
|
||||||
src={cloudState.user.pfpUrl}
|
user={cloudState.user}
|
||||||
alt=""
|
|
||||||
class="w-5 h-5 rounded-full object-cover ring-1 ring-zinc-200 dark:ring-zinc-600"
|
class="w-5 h-5 rounded-full object-cover ring-1 ring-zinc-200 dark:ring-zinc-600"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{/if}
|
||||||
|
{#if !cloudState.user?.pfpUrl}
|
||||||
<div class="flex items-center justify-center w-5 h-5 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-[0.6rem]">
|
<div class="flex items-center justify-center w-5 h-5 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-[0.6rem]">
|
||||||
{getInitials()}
|
{getInitials()}
|
||||||
</div>
|
</div>
|
||||||
@@ -111,12 +112,12 @@
|
|||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
{#if cloudState.user?.pfpUrl}
|
{#if cloudState.user?.pfpUrl}
|
||||||
<img
|
<CloudPfpAvatar
|
||||||
src={cloudState.user.pfpUrl}
|
user={cloudState.user}
|
||||||
alt=""
|
|
||||||
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
|
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{/if}
|
||||||
|
{#if !cloudState.user?.pfpUrl}
|
||||||
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
|
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
|
||||||
{getInitials()}
|
{getInitials()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,12 @@
|
|||||||
import CloudPanel from "../components/CloudPanel.svelte";
|
import CloudPanel from "../components/CloudPanel.svelte";
|
||||||
import DisclaimerModal from "../components/DisclaimerModal.svelte";
|
import DisclaimerModal from "../components/DisclaimerModal.svelte";
|
||||||
import { settingsPopup } from "../hooks/SettingsPopup";
|
import { settingsPopup } from "../hooks/SettingsPopup";
|
||||||
|
import {
|
||||||
|
checkGithubReleaseUpdate,
|
||||||
|
dismissNightlyUpdate,
|
||||||
|
isGhReleaseUpdateCheckEnabled,
|
||||||
|
type GhReleaseUpdateInfo,
|
||||||
|
} from "@/utils/githubReleaseUpdate";
|
||||||
|
|
||||||
let devModeSequence = "";
|
let devModeSequence = "";
|
||||||
let settingsActiveTab = $state(0);
|
let settingsActiveTab = $state(0);
|
||||||
@@ -26,6 +32,18 @@
|
|||||||
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
|
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
|
||||||
let disclaimerTitle = $state("Confirm");
|
let disclaimerTitle = $state("Confirm");
|
||||||
let disclaimerMessage = $state("");
|
let disclaimerMessage = $state("");
|
||||||
|
const ghReleaseUpdateEnabled = isGhReleaseUpdateCheckEnabled();
|
||||||
|
let ghReleaseUpdate = $state<GhReleaseUpdateInfo | null>(null);
|
||||||
|
|
||||||
|
const openGhRelease = () => {
|
||||||
|
const url = ghReleaseUpdate?.url
|
||||||
|
?? "https://github.com/BetterSEQTA/BetterSEQTA-Plus/releases";
|
||||||
|
if (ghReleaseUpdate?.available) {
|
||||||
|
dismissNightlyUpdate();
|
||||||
|
}
|
||||||
|
window.open(url, "_blank");
|
||||||
|
closeExtensionPopup();
|
||||||
|
};
|
||||||
|
|
||||||
const handleDevModeToggle = () => {
|
const handleDevModeToggle = () => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -98,6 +116,12 @@
|
|||||||
if (standalone) {
|
if (standalone) {
|
||||||
StandaloneStore.setStandalone(true);
|
StandaloneStore.setStandalone(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ghReleaseUpdateEnabled) {
|
||||||
|
void checkGithubReleaseUpdate().then((info) => {
|
||||||
|
ghReleaseUpdate = info;
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -134,7 +158,25 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{#if !standalone}
|
{#if !standalone}
|
||||||
<div class="flex absolute top-1 right-1 gap-1 items-center">
|
<div class="flex absolute top-1 right-1 gap-1 items-start">
|
||||||
|
{#if ghReleaseUpdateEnabled}
|
||||||
|
<div class="flex flex-col items-end gap-0.5 max-w-[9rem] mr-0.5">
|
||||||
|
{#if ghReleaseUpdate?.available}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={openGhRelease}
|
||||||
|
class="px-1.5 py-0.5 text-[10px] font-semibold leading-tight text-white rounded-full bg-amber-500 hover:bg-amber-600 dark:bg-amber-600 dark:hover:bg-amber-500"
|
||||||
|
title="Open GitHub release"
|
||||||
|
>
|
||||||
|
Update available — {ghReleaseUpdate.label}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<p class="text-[9px] leading-tight text-right text-zinc-500 dark:text-zinc-400">
|
||||||
|
GitHub release build — do not upload to extension stores.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-1 items-center">
|
||||||
<button
|
<button
|
||||||
onclick={openAbout}
|
onclick={openAbout}
|
||||||
class="flex justify-center items-center w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700"
|
class="flex justify-center items-center w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700"
|
||||||
@@ -156,6 +198,7 @@
|
|||||||
>
|
>
|
||||||
{"\uecba"}
|
{"\uecba"}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- <button
|
<!-- <button
|
||||||
onclick={openMinecraftServer}
|
onclick={openMinecraftServer}
|
||||||
|
|||||||
@@ -585,6 +585,21 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-col gap-2 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-sm font-bold">GitHub latest version override</h2>
|
||||||
|
<p class="text-xs">Pretend a newer GitHub release exists to test the update badge. Only applies when dev mode is on.</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. 9.9.9"
|
||||||
|
value={$settingsState.devGhReleaseVersionOverride ?? ""}
|
||||||
|
oninput={(e) => {
|
||||||
|
settingsState.devGhReleaseVersionOverride = e.currentTarget.value;
|
||||||
|
}}
|
||||||
|
class="px-2 py-1 text-xs rounded border bg-white dark:bg-zinc-800 border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ import {
|
|||||||
letterToNumber,
|
letterToNumber,
|
||||||
parseAssessments,
|
parseAssessments,
|
||||||
processAssessments,
|
processAssessments,
|
||||||
|
type WeightingEntry,
|
||||||
} from "./utils.ts";
|
} from "./utils.ts";
|
||||||
import { injectRubricCopyButtons } from "./rubricCopy.ts";
|
import { injectRubricCopyButtons } from "./rubricCopy.ts";
|
||||||
|
|
||||||
interface weightingsStorage {
|
interface weightingsStorage {
|
||||||
weightings: Record<string, string>;
|
weightings: Record<string, WeightingEntry>;
|
||||||
assessments: Record<string, string>;
|
assessments: Record<string, string>;
|
||||||
weightingOverrides: Record<string, string>;
|
weightingOverrides: Record<string, string>;
|
||||||
}
|
}
|
||||||
@@ -61,8 +62,8 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
|||||||
1000,
|
1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
await parseAssessments(api);
|
// Wire listeners first so the very first re-render triggered by a
|
||||||
await renderSubjectAverage(api);
|
// background handleWeightings completion can find them.
|
||||||
overrideListenerController?.abort();
|
overrideListenerController?.abort();
|
||||||
overrideListenerController = new AbortController();
|
overrideListenerController = new AbortController();
|
||||||
document.addEventListener(
|
document.addEventListener(
|
||||||
@@ -70,6 +71,21 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
|||||||
() => renderSubjectAverage(api),
|
() => renderSubjectAverage(api),
|
||||||
{ signal: overrideListenerController.signal },
|
{ signal: overrideListenerController.signal },
|
||||||
);
|
);
|
||||||
|
document.addEventListener(
|
||||||
|
"betterseqta:weightingsChanged",
|
||||||
|
() => renderSubjectAverage(api),
|
||||||
|
{ signal: overrideListenerController.signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render immediately with whatever is already cached. Fresh entries
|
||||||
|
// and stale-with-previous-value entries both contribute their numeric
|
||||||
|
// weights, so the subject average appears without waiting on any
|
||||||
|
// background PDF refetches.
|
||||||
|
await renderSubjectAverage(api);
|
||||||
|
|
||||||
|
// Kick off indexing in the background. Each completion dispatches
|
||||||
|
// betterseqta:weightingsChanged, which triggers a fresh render.
|
||||||
|
void parseAssessments(api);
|
||||||
const wrapper = document.querySelector(".assessmentsWrapper");
|
const wrapper = document.querySelector(".assessmentsWrapper");
|
||||||
if (wrapper) {
|
if (wrapper) {
|
||||||
const observer = new MutationObserver(() => {
|
const observer = new MutationObserver(() => {
|
||||||
@@ -87,8 +103,15 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let renderInFlight = false;
|
let renderInFlight = false;
|
||||||
|
let renderQueued = false;
|
||||||
async function renderSubjectAverage(api: any) {
|
async function renderSubjectAverage(api: any) {
|
||||||
if (renderInFlight) return;
|
if (renderInFlight) {
|
||||||
|
// Coalesce: remember that fresh data arrived during this render and
|
||||||
|
// re-run once the current pass finishes, so the UI catches up to the
|
||||||
|
// latest storage state instead of silently dropping the event.
|
||||||
|
renderQueued = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
renderInFlight = true;
|
renderInFlight = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -141,8 +164,13 @@ async function renderSubjectAverage(api: any) {
|
|||||||
?.textContent?.includes("Subject Average"),
|
?.textContent?.includes("Subject Average"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
|
const {
|
||||||
await processAssessments(api, assessmentItems);
|
weightedTotal,
|
||||||
|
totalWeight,
|
||||||
|
hasInaccurateWeighting,
|
||||||
|
hasRefreshingWeighting,
|
||||||
|
count,
|
||||||
|
} = await processAssessments(api, assessmentItems);
|
||||||
if (!count || totalWeight === 0) return;
|
if (!count || totalWeight === 0) return;
|
||||||
|
|
||||||
const thermoscoreElement = document.querySelector(
|
const thermoscoreElement = document.querySelector(
|
||||||
@@ -176,11 +204,22 @@ async function renderSubjectAverage(api: any) {
|
|||||||
let warningHTML = "";
|
let warningHTML = "";
|
||||||
if (hasInaccurateWeighting) {
|
if (hasInaccurateWeighting) {
|
||||||
warningHTML = /* html */ `
|
warningHTML = /* html */ `
|
||||||
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3;">
|
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3; white-space: nowrap;">
|
||||||
⚠ Some weightings unavailable
|
⚠ Some weightings unavailable
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
} else if (hasRefreshingWeighting) {
|
||||||
|
warningHTML = /* html */ `
|
||||||
|
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.55); opacity: 0.8; line-height: 1.3; white-space: nowrap;" title="Some weightings are being re-checked; the average may change shortly">
|
||||||
|
↻ Refreshing weightings
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
const thermoscoreTitle = hasInaccurateWeighting
|
||||||
|
? `${display} (some weightings unavailable)`
|
||||||
|
: hasRefreshingWeighting
|
||||||
|
? `${display} (re-checking weightings)`
|
||||||
|
: display;
|
||||||
assessmentsList.insertBefore(
|
assessmentsList.insertBefore(
|
||||||
stringToHTML(/* html */ `
|
stringToHTML(/* html */ `
|
||||||
<div class="${assessmentItemClass}">
|
<div class="${assessmentItemClass}">
|
||||||
@@ -194,7 +233,7 @@ async function renderSubjectAverage(api: any) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="${thermoscoreClass}">
|
<div class="${thermoscoreClass}">
|
||||||
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
|
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
|
||||||
<div class="${textClass}" title="${hasInaccurateWeighting ? display + " (some weightings unavailable)" : display}">${display}</div>
|
<div class="${textClass}" title="${thermoscoreTitle}">${display}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,6 +243,10 @@ async function renderSubjectAverage(api: any) {
|
|||||||
applySubjectColourToOverallResult();
|
applySubjectColourToOverallResult();
|
||||||
} finally {
|
} finally {
|
||||||
renderInFlight = false;
|
renderInFlight = false;
|
||||||
|
if (renderQueued) {
|
||||||
|
renderQueued = false;
|
||||||
|
void renderSubjectAverage(api);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function applySubjectColourToOverallResult() {
|
function applySubjectColourToOverallResult() {
|
||||||
|
|||||||
@@ -14,6 +14,59 @@ import * as pdfjs from "pdfjs-dist";
|
|||||||
|
|
||||||
ensurePdfjsWorker();
|
ensurePdfjsWorker();
|
||||||
|
|
||||||
|
export const WEIGHTING_SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
|
export interface WeightingEntry {
|
||||||
|
weight: string;
|
||||||
|
fingerprint: string;
|
||||||
|
pluginVersion: number;
|
||||||
|
refreshing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WeightingsMap = Record<string, WeightingEntry>;
|
||||||
|
|
||||||
|
export function computeFingerprint(mark: any): string {
|
||||||
|
const score =
|
||||||
|
mark?.results?.percentage ?? mark?.results?.score ?? null;
|
||||||
|
return JSON.stringify([
|
||||||
|
mark?.status ?? "",
|
||||||
|
Boolean(mark?.graded),
|
||||||
|
mark?.availability ?? "",
|
||||||
|
score,
|
||||||
|
mark?.due ?? "",
|
||||||
|
mark?.title ?? "",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateWeightings(api: any) {
|
||||||
|
const w = api.storage.weightings ?? {};
|
||||||
|
let dirty = false;
|
||||||
|
const out: WeightingsMap = {};
|
||||||
|
for (const [id, v] of Object.entries(w)) {
|
||||||
|
if (typeof v === "string") {
|
||||||
|
out[id] = { weight: v, fingerprint: "", pluginVersion: 0 };
|
||||||
|
dirty = true;
|
||||||
|
} else if (v && typeof v === "object") {
|
||||||
|
const entry = v as Partial<WeightingEntry>;
|
||||||
|
if (
|
||||||
|
typeof entry.weight === "string" &&
|
||||||
|
typeof entry.fingerprint === "string" &&
|
||||||
|
typeof entry.pluginVersion === "number"
|
||||||
|
) {
|
||||||
|
out[id] = entry as WeightingEntry;
|
||||||
|
} else {
|
||||||
|
out[id] = {
|
||||||
|
weight: String(entry.weight ?? "N/A"),
|
||||||
|
fingerprint: "",
|
||||||
|
pluginVersion: 0,
|
||||||
|
};
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dirty) api.storage.weightings = out;
|
||||||
|
}
|
||||||
|
|
||||||
export async function initStorage(api: any) {
|
export async function initStorage(api: any) {
|
||||||
await api.storage.loaded;
|
await api.storage.loaded;
|
||||||
|
|
||||||
@@ -26,19 +79,34 @@ export async function initStorage(api: any) {
|
|||||||
if (!api.storage.weightingOverrides) {
|
if (!api.storage.weightingOverrides) {
|
||||||
api.storage.weightingOverrides = {};
|
api.storage.weightingOverrides = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
migrateWeightings(api);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearStuck(api: any) {
|
export function clearStuck(api: any) {
|
||||||
let hasStuckProcessing = false;
|
const map = (api.storage.weightings ?? {}) as WeightingsMap;
|
||||||
for (const key in api.storage.weightings) {
|
let dirty = false;
|
||||||
if (api.storage.weightings[key] === "processing") {
|
const out: WeightingsMap = {};
|
||||||
delete api.storage.weightings[key];
|
for (const [key, entry] of Object.entries(map)) {
|
||||||
hasStuckProcessing = true;
|
if (!entry || typeof entry !== "object") {
|
||||||
|
dirty = true;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
if (entry.weight === "processing") {
|
||||||
|
// Stuck mid-fetch from a previous session: drop it so the next
|
||||||
|
// page load can re-run handleWeightings from scratch.
|
||||||
|
dirty = true;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if (hasStuckProcessing) {
|
if (entry.refreshing) {
|
||||||
api.storage.weightings = { ...api.storage.weightings };
|
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
|
// Helper function to find actual class names by their base pattern
|
||||||
@@ -137,6 +205,7 @@ function updateWeightLabelContent(
|
|||||||
weighting: string | undefined,
|
weighting: string | undefined,
|
||||||
assessmentID: string | undefined,
|
assessmentID: string | undefined,
|
||||||
api: any,
|
api: any,
|
||||||
|
refreshing = false,
|
||||||
) {
|
) {
|
||||||
const existingInput = weightLabel.querySelector(
|
const existingInput = weightLabel.querySelector(
|
||||||
".betterseqta-weight-input",
|
".betterseqta-weight-input",
|
||||||
@@ -178,10 +247,15 @@ function updateWeightLabelContent(
|
|||||||
|
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
span.className = "betterseqta-weight-value";
|
span.className = "betterseqta-weight-value";
|
||||||
span.textContent =
|
const baseText =
|
||||||
weighting && weighting !== "N/A"
|
weighting && weighting !== "N/A"
|
||||||
? formatWeightDisplay(weighting)
|
? formatWeightDisplay(weighting)
|
||||||
: "N/A";
|
: "N/A";
|
||||||
|
span.textContent = refreshing ? `${baseText} ↻` : baseText;
|
||||||
|
if (refreshing) {
|
||||||
|
span.style.opacity = "0.7";
|
||||||
|
weightLabel.title = "Re-checking weighting…";
|
||||||
|
}
|
||||||
weightLabel.appendChild(span);
|
weightLabel.appendChild(span);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +263,7 @@ function createWeightLabel(
|
|||||||
assessmentItem: Element,
|
assessmentItem: Element,
|
||||||
weighting: string | undefined,
|
weighting: string | undefined,
|
||||||
api: any,
|
api: any,
|
||||||
|
refreshing = false,
|
||||||
) {
|
) {
|
||||||
let statsContainer = assessmentItem.querySelector(
|
let statsContainer = assessmentItem.querySelector(
|
||||||
`[class*='AssessmentItem__stats___'], .betterseqta-stats-container`,
|
`[class*='AssessmentItem__stats___'], .betterseqta-stats-container`,
|
||||||
@@ -224,7 +299,13 @@ function createWeightLabel(
|
|||||||
) as HTMLElement | null;
|
) as HTMLElement | null;
|
||||||
|
|
||||||
if (existingLabel) {
|
if (existingLabel) {
|
||||||
updateWeightLabelContent(existingLabel, weighting, assessmentID, api);
|
updateWeightLabelContent(
|
||||||
|
existingLabel,
|
||||||
|
weighting,
|
||||||
|
assessmentID,
|
||||||
|
api,
|
||||||
|
refreshing,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +337,13 @@ function createWeightLabel(
|
|||||||
const innerTextDiv = weightLabel.querySelector(`[class*='Label__innerText___']`);
|
const innerTextDiv = weightLabel.querySelector(`[class*='Label__innerText___']`);
|
||||||
if (innerTextDiv) innerTextDiv.textContent = "Weight";
|
if (innerTextDiv) innerTextDiv.textContent = "Weight";
|
||||||
|
|
||||||
updateWeightLabelContent(weightLabel, weighting, assessmentID, api);
|
updateWeightLabelContent(
|
||||||
|
weightLabel,
|
||||||
|
weighting,
|
||||||
|
assessmentID,
|
||||||
|
api,
|
||||||
|
refreshing,
|
||||||
|
);
|
||||||
statsContainer.appendChild(weightLabel);
|
statsContainer.appendChild(weightLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,16 +650,41 @@ async function handleWeightings(mark: any, api: any) {
|
|||||||
const metaclassID = mark.metaclassID;
|
const metaclassID = mark.metaclassID;
|
||||||
const title = mark.title;
|
const title = mark.title;
|
||||||
|
|
||||||
if (
|
const fingerprint = computeFingerprint(mark);
|
||||||
api.storage.weightings[assessmentID] != undefined &&
|
const existing = api.storage.weightings[assessmentID] as
|
||||||
api.storage.weightings[assessmentID] !== "processing"
|
| WeightingEntry
|
||||||
) {
|
| undefined;
|
||||||
return;
|
|
||||||
|
const isFresh =
|
||||||
|
existing &&
|
||||||
|
existing.weight !== "processing" &&
|
||||||
|
existing.fingerprint === fingerprint &&
|
||||||
|
existing.pluginVersion === WEIGHTING_SCHEMA_VERSION;
|
||||||
|
|
||||||
|
if (isFresh) return;
|
||||||
|
|
||||||
|
// If we have a previous usable value, keep showing it while we refetch
|
||||||
|
// by marking the entry as refreshing instead of wiping it. We claim the
|
||||||
|
// new fingerprint + version on the placeholder so a second parseAssessments
|
||||||
|
// pass (e.g. a fast re-mount of the wrapper) doesn't kick off a duplicate
|
||||||
|
// refetch for the same id while this one is still in flight.
|
||||||
|
const placeholder: WeightingEntry =
|
||||||
|
existing && existing.weight !== "processing"
|
||||||
|
? {
|
||||||
|
...existing,
|
||||||
|
fingerprint,
|
||||||
|
pluginVersion: WEIGHTING_SCHEMA_VERSION,
|
||||||
|
refreshing: true,
|
||||||
}
|
}
|
||||||
|
: {
|
||||||
|
weight: "processing",
|
||||||
|
fingerprint,
|
||||||
|
pluginVersion: WEIGHTING_SCHEMA_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
api.storage.weightings = {
|
api.storage.weightings = {
|
||||||
...api.storage.weightings,
|
...api.storage.weightings,
|
||||||
[assessmentID]: "processing",
|
[assessmentID]: placeholder,
|
||||||
};
|
};
|
||||||
|
|
||||||
api.storage.assessments = {
|
api.storage.assessments = {
|
||||||
@@ -580,6 +692,10 @@ async function handleWeightings(mark: any, api: any) {
|
|||||||
[title.trim()]: assessmentID,
|
[title.trim()]: assessmentID,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Surface the refreshing indicator on the affected row immediately,
|
||||||
|
// without waiting for the PDF fetch to finish.
|
||||||
|
document.dispatchEvent(new CustomEvent("betterseqta:weightingsChanged"));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let pdfUrl: string;
|
let pdfUrl: string;
|
||||||
|
|
||||||
@@ -655,14 +771,24 @@ async function handleWeightings(mark: any, api: any) {
|
|||||||
|
|
||||||
api.storage.weightings = {
|
api.storage.weightings = {
|
||||||
...api.storage.weightings,
|
...api.storage.weightings,
|
||||||
[assessmentID]: match ? match[1] : "N/A",
|
[assessmentID]: {
|
||||||
|
weight: match ? match[1] : "N/A",
|
||||||
|
fingerprint,
|
||||||
|
pluginVersion: WEIGHTING_SCHEMA_VERSION,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
api.storage.weightings = {
|
api.storage.weightings = {
|
||||||
...api.storage.weightings,
|
...api.storage.weightings,
|
||||||
[assessmentID]: "N/A",
|
[assessmentID]: {
|
||||||
|
weight: "N/A",
|
||||||
|
fingerprint,
|
||||||
|
pluginVersion: WEIGHTING_SCHEMA_VERSION,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.dispatchEvent(new CustomEvent("betterseqta:weightingsChanged"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseAssessments(api: any) {
|
export async function parseAssessments(api: any) {
|
||||||
@@ -684,6 +810,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
|||||||
let weightedTotal = 0;
|
let weightedTotal = 0;
|
||||||
let totalWeight = 0;
|
let totalWeight = 0;
|
||||||
let hasInaccurateWeighting = false;
|
let hasInaccurateWeighting = false;
|
||||||
|
let hasRefreshingWeighting = false;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
for (const assessmentItem of assessmentItems) {
|
for (const assessmentItem of assessmentItems) {
|
||||||
@@ -696,15 +823,17 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
|||||||
if (!title) continue;
|
if (!title) continue;
|
||||||
|
|
||||||
const assessmentID = api.storage.assessments?.[title];
|
const assessmentID = api.storage.assessments?.[title];
|
||||||
const autoWeighting = assessmentID
|
const entry = assessmentID
|
||||||
? api.storage.weightings?.[assessmentID]
|
? (api.storage.weightings?.[assessmentID] as WeightingEntry | undefined)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const autoWeighting = entry?.weight;
|
||||||
const override = assessmentID
|
const override = assessmentID
|
||||||
? api.storage.weightingOverrides?.[assessmentID]
|
? api.storage.weightingOverrides?.[assessmentID]
|
||||||
: undefined;
|
: undefined;
|
||||||
const weighting = override ?? autoWeighting;
|
const weighting = override ?? autoWeighting;
|
||||||
|
const refreshing = !override && Boolean(entry?.refreshing);
|
||||||
|
|
||||||
createWeightLabel(assessmentItem, weighting, api);
|
createWeightLabel(assessmentItem, weighting, api, refreshing);
|
||||||
|
|
||||||
const gradeElement = assessmentItem.querySelector(
|
const gradeElement = assessmentItem.querySelector(
|
||||||
`[class*='Thermoscore__text___']`,
|
`[class*='Thermoscore__text___']`,
|
||||||
@@ -727,6 +856,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
|||||||
if (!isNaN(weight) && weight >= 0) {
|
if (!isNaN(weight) && weight >= 0) {
|
||||||
weightedTotal += grade * weight;
|
weightedTotal += grade * weight;
|
||||||
totalWeight += weight;
|
totalWeight += weight;
|
||||||
|
if (refreshing) hasRefreshingWeighting = true;
|
||||||
} else {
|
} else {
|
||||||
weightedTotal += grade;
|
weightedTotal += grade;
|
||||||
totalWeight += 1;
|
totalWeight += 1;
|
||||||
@@ -740,6 +870,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
|||||||
weightedTotal,
|
weightedTotal,
|
||||||
totalWeight,
|
totalWeight,
|
||||||
hasInaccurateWeighting,
|
hasInaccurateWeighting,
|
||||||
|
hasRefreshingWeighting,
|
||||||
count,
|
count,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -811,9 +942,10 @@ function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
|
|||||||
const title = titleEl?.textContent?.trim();
|
const title = titleEl?.textContent?.trim();
|
||||||
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
|
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
|
||||||
|
|
||||||
const rawWeight = assessmentID
|
const entry = assessmentID
|
||||||
? api.storage.weightings?.[assessmentID]
|
? (api.storage.weightings?.[assessmentID] as WeightingEntry | undefined)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const rawWeight = entry?.weight;
|
||||||
|
|
||||||
const weightingUnavailable = rawWeight === "N/A";
|
const weightingUnavailable = rawWeight === "N/A";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import type { Plugin } from "@/plugins/core/types";
|
||||||
|
import { BasePlugin } from "@/plugins/core/settings";
|
||||||
|
import {
|
||||||
|
booleanSetting,
|
||||||
|
defineSettings,
|
||||||
|
Setting,
|
||||||
|
} from "@/plugins/core/settingsHelpers";
|
||||||
|
|
||||||
|
const settings = defineSettings({
|
||||||
|
autoScrollOnClick: booleanSetting({
|
||||||
|
default: false,
|
||||||
|
title: "Auto-scroll navigator on click",
|
||||||
|
description:
|
||||||
|
"When you click a lesson directly in the side panel, automatically scroll it to the centre. The prev/next arrows always centre the selected lesson regardless of this setting.",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
class EnhancedNavigationSettings extends BasePlugin<typeof settings> {
|
||||||
|
@Setting(settings.autoScrollOnClick)
|
||||||
|
autoScrollOnClick!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsInstance = new EnhancedNavigationSettings();
|
||||||
|
|
||||||
|
const ARROW_CONTAINER_ID = "betterseqta-en-arrows";
|
||||||
|
const STYLE_ID = "betterseqta-en-styles";
|
||||||
|
|
||||||
|
const injectStyles = () => {
|
||||||
|
if (document.getElementById(STYLE_ID)) return;
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = STYLE_ID;
|
||||||
|
style.textContent = `
|
||||||
|
#${ARROW_CONTAINER_ID} {
|
||||||
|
position: fixed;
|
||||||
|
right: 24px;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
z-index: 15;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
body:has(.outside-container:not(.hide)) #${ARROW_CONTAINER_ID},
|
||||||
|
body:has(.bsplus-notifications-panel:not(.hide)) #${ARROW_CONTAINER_ID} {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
body:has(.outside-container:not(.hide)) #${ARROW_CONTAINER_ID} .en-arrow,
|
||||||
|
body:has(.bsplus-notifications-panel:not(.hide)) #${ARROW_CONTAINER_ID} .en-arrow {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#${ARROW_CONTAINER_ID} .en-arrow {
|
||||||
|
pointer-events: auto;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
color: #000;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, transform 0.1s ease;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
#${ARROW_CONTAINER_ID} .en-arrow:hover:not(:disabled) {
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
#${ARROW_CONTAINER_ID} .en-arrow:active:not(:disabled) {
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
#${ARROW_CONTAINER_ID} .en-arrow:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
html.dark #${ARROW_CONTAINER_ID} .en-arrow {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
html.dark #${ARROW_CONTAINER_ID} .en-arrow:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
#${ARROW_CONTAINER_ID} .en-arrow svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
fill: currentColor;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOrderedItems = (navigator: Element): HTMLElement[] => {
|
||||||
|
const items: HTMLElement[] = [];
|
||||||
|
const cover = navigator.querySelector<HTMLElement>("li.cover");
|
||||||
|
if (cover) items.push(cover);
|
||||||
|
const lessons = Array.from(
|
||||||
|
navigator.querySelectorAll<HTMLElement>("li.lesson"),
|
||||||
|
);
|
||||||
|
lessons.sort((a, b) => {
|
||||||
|
const wa = parseInt(a.dataset.week ?? "0", 10);
|
||||||
|
const wb = parseInt(b.dataset.week ?? "0", 10);
|
||||||
|
if (wa !== wb) return wa - wb;
|
||||||
|
const na = parseInt(a.dataset.number ?? "0", 10);
|
||||||
|
const nb = parseInt(b.dataset.number ?? "0", 10);
|
||||||
|
return na - nb;
|
||||||
|
});
|
||||||
|
items.push(...lessons);
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSelected = (navigator: Element): HTMLElement | null =>
|
||||||
|
navigator.querySelector<HTMLElement>("li.selected");
|
||||||
|
|
||||||
|
const findScrollableAncestor = (el: Element | null): HTMLElement | null => {
|
||||||
|
let cur: HTMLElement | null = el as HTMLElement | null;
|
||||||
|
while (cur && cur !== document.body) {
|
||||||
|
const style = getComputedStyle(cur);
|
||||||
|
const oy = style.overflowY;
|
||||||
|
if (
|
||||||
|
(oy === "auto" || oy === "scroll" || oy === "overlay") &&
|
||||||
|
cur.scrollHeight > cur.clientHeight + 1
|
||||||
|
) {
|
||||||
|
return cur;
|
||||||
|
}
|
||||||
|
cur = cur.parentElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollSelectedIntoView = (navigator: Element) => {
|
||||||
|
const selected = getSelected(navigator);
|
||||||
|
if (!selected) return;
|
||||||
|
|
||||||
|
const scroller =
|
||||||
|
findScrollableAncestor(selected) ?? (navigator as HTMLElement);
|
||||||
|
if (!scroller) return;
|
||||||
|
|
||||||
|
const scrollerRect = scroller.getBoundingClientRect();
|
||||||
|
const selectedRect = selected.getBoundingClientRect();
|
||||||
|
const offset =
|
||||||
|
selectedRect.top -
|
||||||
|
scrollerRect.top -
|
||||||
|
scroller.clientHeight / 2 +
|
||||||
|
selectedRect.height / 2;
|
||||||
|
|
||||||
|
scroller.scrollTop = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
scroller.scrollTop + offset,
|
||||||
|
scroller.scrollHeight - scroller.clientHeight,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const positionArrows = () => {
|
||||||
|
const container = document.getElementById(ARROW_CONTAINER_ID);
|
||||||
|
if (!container) return;
|
||||||
|
const ref =
|
||||||
|
document.getElementById("toolbar") ??
|
||||||
|
document.querySelector<HTMLElement>(".course");
|
||||||
|
if (!ref) return;
|
||||||
|
const rect = ref.getBoundingClientRect();
|
||||||
|
const arrowH = container.offsetHeight || 32;
|
||||||
|
const verticalOffset = 4;
|
||||||
|
container.style.top = `${Math.max(0, rect.top + (rect.height - arrowH) / 2 + verticalOffset)}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
let scrollOnNextSelect = false;
|
||||||
|
|
||||||
|
const navigate = (course: HTMLElement, direction: "prev" | "next") => {
|
||||||
|
const nav = course.querySelector(".navigator");
|
||||||
|
if (!nav) return;
|
||||||
|
const items = getOrderedItems(nav);
|
||||||
|
const selected = getSelected(nav);
|
||||||
|
const idx = selected ? items.indexOf(selected) : -1;
|
||||||
|
const target = idx + (direction === "next" ? 1 : -1);
|
||||||
|
if (target < 0 || target >= items.length) return;
|
||||||
|
scrollOnNextSelect = true;
|
||||||
|
items[target].click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateArrowState = (course: HTMLElement) => {
|
||||||
|
const nav = course.querySelector(".navigator");
|
||||||
|
const container = document.getElementById(ARROW_CONTAINER_ID);
|
||||||
|
if (!nav || !container) return;
|
||||||
|
|
||||||
|
const items = getOrderedItems(nav);
|
||||||
|
const selected = getSelected(nav);
|
||||||
|
const idx = selected ? items.indexOf(selected) : -1;
|
||||||
|
|
||||||
|
const prev = container.querySelector<HTMLButtonElement>(
|
||||||
|
'button[data-en-action="prev"]',
|
||||||
|
);
|
||||||
|
const next = container.querySelector<HTMLButtonElement>(
|
||||||
|
'button[data-en-action="next"]',
|
||||||
|
);
|
||||||
|
if (prev) prev.disabled = idx <= 0;
|
||||||
|
if (next) next.disabled = idx === -1 || idx >= items.length - 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureArrows = (course: HTMLElement) => {
|
||||||
|
if (!course.querySelector(".programmeNavigator")) return;
|
||||||
|
|
||||||
|
let container = document.getElementById(ARROW_CONTAINER_ID);
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement("div");
|
||||||
|
container.id = ARROW_CONTAINER_ID;
|
||||||
|
container.innerHTML = `
|
||||||
|
<button type="button" class="en-arrow" data-en-action="prev" title="Previous lesson" aria-label="Previous lesson">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="en-arrow" data-en-action="next" title="Next lesson" aria-label="Next lesson">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M8.59 16.59 10 18l6-6-6-6-1.41 1.41L13.17 12z"/></svg>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
container.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const btn = (e.target as Element).closest<HTMLButtonElement>(
|
||||||
|
"button[data-en-action]",
|
||||||
|
);
|
||||||
|
if (!btn) return;
|
||||||
|
const liveCourse = document.querySelector<HTMLElement>(".course");
|
||||||
|
if (liveCourse)
|
||||||
|
navigate(liveCourse, btn.dataset.enAction as "prev" | "next");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
positionArrows();
|
||||||
|
updateArrowState(course);
|
||||||
|
};
|
||||||
|
|
||||||
|
const watchNavigator = (navigator: Element, onChange: () => void) => {
|
||||||
|
onChange();
|
||||||
|
const observer = new MutationObserver((muts) => {
|
||||||
|
if (
|
||||||
|
muts.some(
|
||||||
|
(m) =>
|
||||||
|
(m.type === "attributes" && m.attributeName === "class") ||
|
||||||
|
m.type === "childList",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
onChange();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(navigator, {
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["class"],
|
||||||
|
childList: true,
|
||||||
|
});
|
||||||
|
return observer;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSlidePane = (pane: Element) => {
|
||||||
|
const navigator = pane.querySelector(".navigator");
|
||||||
|
if (!navigator) return;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => scrollSelectedIntoView(navigator));
|
||||||
|
setTimeout(() => scrollSelectedIntoView(navigator), 50);
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() =>
|
||||||
|
scrollSelectedIntoView(navigator),
|
||||||
|
);
|
||||||
|
observer.observe(navigator, {
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["class"],
|
||||||
|
childList: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanup = new MutationObserver((muts) => {
|
||||||
|
muts.forEach((m) => {
|
||||||
|
m.removedNodes.forEach((n) => {
|
||||||
|
if (n === pane) {
|
||||||
|
observer.disconnect();
|
||||||
|
cleanup.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
cleanup.observe(document.body, { childList: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const enhancedNavigationPlugin: Plugin<typeof settings> = {
|
||||||
|
id: "enhanced-navigation",
|
||||||
|
name: "Enhanced Navigation",
|
||||||
|
description:
|
||||||
|
"Keeps the course navigator focused on the current lesson and adds prev/next lesson arrows.",
|
||||||
|
version: "1.0.0",
|
||||||
|
disableToggle: true,
|
||||||
|
settings: settingsInstance.settings,
|
||||||
|
beta: false,
|
||||||
|
|
||||||
|
run: async (api) => {
|
||||||
|
injectStyles();
|
||||||
|
|
||||||
|
window.addEventListener("resize", positionArrows);
|
||||||
|
window.addEventListener("scroll", positionArrows, true);
|
||||||
|
|
||||||
|
api.seqta.onMount(".course", async (element) => {
|
||||||
|
const course = element as HTMLElement;
|
||||||
|
let navObserver: MutationObserver | null = null;
|
||||||
|
|
||||||
|
const setup = () => {
|
||||||
|
const nav = course.querySelector(".navigator");
|
||||||
|
if (!nav) return false;
|
||||||
|
if (navObserver) return true;
|
||||||
|
|
||||||
|
ensureArrows(course);
|
||||||
|
navObserver = watchNavigator(nav, () => {
|
||||||
|
if (scrollOnNextSelect || api.settings.autoScrollOnClick) {
|
||||||
|
scrollSelectedIntoView(nav);
|
||||||
|
scrollOnNextSelect = false;
|
||||||
|
}
|
||||||
|
ensureArrows(course);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!setup()) {
|
||||||
|
const courseObserver = new MutationObserver(() => {
|
||||||
|
if (setup()) courseObserver.disconnect();
|
||||||
|
});
|
||||||
|
courseObserver.observe(course, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodyObserver = new MutationObserver((muts) => {
|
||||||
|
muts.forEach((m) => {
|
||||||
|
m.addedNodes.forEach((n) => {
|
||||||
|
if (n.nodeType !== 1) return;
|
||||||
|
const el = n as Element;
|
||||||
|
if (el.classList?.contains("uiSlidePane")) handleSlidePane(el);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
bodyObserver.observe(document.body, { childList: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bodyObserver.disconnect();
|
||||||
|
document.getElementById(ARROW_CONTAINER_ID)?.remove();
|
||||||
|
document.getElementById(STYLE_ID)?.remove();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default enhancedNavigationPlugin;
|
||||||
@@ -8,6 +8,7 @@ import ProfilePictureSetting from "./ProfilePictureSetting.svelte";
|
|||||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||||
|
import { resolveCloudPfp } from "@/seqta/utils/cloudPfpCache";
|
||||||
import styles from "./styles.css?inline";
|
import styles from "./styles.css?inline";
|
||||||
import localforage from "localforage";
|
import localforage from "localforage";
|
||||||
|
|
||||||
@@ -65,15 +66,18 @@ const profilePicturePlugin: Plugin<typeof settings> = {
|
|||||||
const useCloud = api.settings.useCloudPfp;
|
const useCloud = api.settings.useCloudPfp;
|
||||||
const pfpUrl = cloudAuth.state.user?.pfpUrl;
|
const pfpUrl = cloudAuth.state.user?.pfpUrl;
|
||||||
|
|
||||||
if (useCloud && pfpUrl) {
|
if (useCloud && pfpUrl && cloudAuth.state.user?.id) {
|
||||||
|
const resolved = await resolveCloudPfp(cloudAuth.state.user.id, pfpUrl);
|
||||||
|
if (resolved) {
|
||||||
|
currentBlobUrl = resolved.src;
|
||||||
img = document.createElement("img");
|
img = document.createElement("img");
|
||||||
img.className = "userInfoImg";
|
img.className = "userInfoImg";
|
||||||
const base = pfpUrl.split("?")[0]!;
|
img.src = resolved.src;
|
||||||
img.src = `${base}?v=${Date.now()}`;
|
|
||||||
if (svg) svg.style.display = "none";
|
if (svg) svg.style.display = "none";
|
||||||
container.appendChild(img);
|
container.appendChild(img);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const blob = await store.getItem<Blob>("profile-picture");
|
const blob = await store.getItem<Blob>("profile-picture");
|
||||||
if (blob && blob instanceof Blob) {
|
if (blob && blob instanceof Blob) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import profilePicturePlugin from "./built-in/profilePicture";
|
|||||||
import assessmentsOverviewPlugin from "./built-in/assessmentsOverview";
|
import assessmentsOverviewPlugin from "./built-in/assessmentsOverview";
|
||||||
import backgroundMusicPlugin from "./built-in/backgroundMusic";
|
import backgroundMusicPlugin from "./built-in/backgroundMusic";
|
||||||
import messageFoldersPlugin from "./built-in/messageFolders";
|
import messageFoldersPlugin from "./built-in/messageFolders";
|
||||||
|
import enhancedNavigationPlugin from "./built-in/enhancedNavigation";
|
||||||
//import testPlugin from './built-in/test';
|
//import testPlugin from './built-in/test';
|
||||||
|
|
||||||
// Heavy plugins (lazy-loaded only when enabled)
|
// Heavy plugins (lazy-loaded only when enabled)
|
||||||
@@ -31,6 +32,7 @@ pluginManager.registerPlugin(profilePicturePlugin);
|
|||||||
pluginManager.registerPlugin(assessmentsOverviewPlugin);
|
pluginManager.registerPlugin(assessmentsOverviewPlugin);
|
||||||
pluginManager.registerPlugin(backgroundMusicPlugin);
|
pluginManager.registerPlugin(backgroundMusicPlugin);
|
||||||
pluginManager.registerPlugin(messageFoldersPlugin);
|
pluginManager.registerPlugin(messageFoldersPlugin);
|
||||||
|
pluginManager.registerPlugin(enhancedNavigationPlugin);
|
||||||
//pluginManager.registerPlugin(testPlugin);
|
//pluginManager.registerPlugin(testPlugin);
|
||||||
|
|
||||||
// Register heavy plugins with lazy loading
|
// Register heavy plugins with lazy loading
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
|
import { clearCloudPfpCache } from "@/seqta/utils/cloudPfpCache";
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
|
|
||||||
const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback";
|
const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback";
|
||||||
@@ -16,6 +17,7 @@ export type CloudUser = {
|
|||||||
username?: string;
|
username?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
pfpUrl?: string;
|
pfpUrl?: string;
|
||||||
|
pfpHash?: string | null;
|
||||||
admin_level?: number;
|
admin_level?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -201,6 +203,8 @@ class CloudAuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async logout(): Promise<void> {
|
public async logout(): Promise<void> {
|
||||||
|
const userId = this._state.user?.id;
|
||||||
|
if (userId) await clearCloudPfpCache(userId);
|
||||||
await browser.storage.local.remove([
|
await browser.storage.local.remove([
|
||||||
STORAGE_KEYS.accessToken,
|
STORAGE_KEYS.accessToken,
|
||||||
STORAGE_KEYS.refreshToken,
|
STORAGE_KEYS.refreshToken,
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import { closePopup } from "./PopupManager";
|
|||||||
import { getApiBase } from "../DevApiBase";
|
import { getApiBase } from "../DevApiBase";
|
||||||
import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight";
|
import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight";
|
||||||
import { cloudAuth } from "../CloudAuth";
|
import { cloudAuth } from "../CloudAuth";
|
||||||
|
import type { Theme } from "@/interface/types/Theme";
|
||||||
|
import {
|
||||||
|
buildModalHeroSlides,
|
||||||
|
normalizeStoreTheme,
|
||||||
|
} from "@/interface/utils/themeStoreFlavours";
|
||||||
|
import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen";
|
||||||
|
|
||||||
/**
|
|
||||||
* Server response shape from `/api/theme-of-the-month/current`.
|
|
||||||
* Hero image is resolved client-side via the theme store API when `theme_id` is set.
|
|
||||||
*/
|
|
||||||
export interface ThemeOfTheMonthEntry {
|
export interface ThemeOfTheMonthEntry {
|
||||||
id: string;
|
id: string;
|
||||||
month: string;
|
month: string;
|
||||||
@@ -22,12 +24,6 @@ export interface ThemeOfTheMonthEntry {
|
|||||||
updated_at: number;
|
updated_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the current month's Theme of the Month entry from the API.
|
|
||||||
* Returns `null` when no entry is configured for this month, or when the
|
|
||||||
* request fails (we never want a network problem to block other startup
|
|
||||||
* popups).
|
|
||||||
*/
|
|
||||||
export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | null> {
|
export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, {
|
const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, {
|
||||||
@@ -45,7 +41,6 @@ export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | nul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when the current month's entry should appear in the startup queue. */
|
|
||||||
export function shouldShowThemeOfTheMonth(entry: ThemeOfTheMonthEntry | null): boolean {
|
export function shouldShowThemeOfTheMonth(entry: ThemeOfTheMonthEntry | null): boolean {
|
||||||
if (!entry || settingsState.themeOfTheMonthDisabled) return false;
|
if (!entry || settingsState.themeOfTheMonthDisabled) return false;
|
||||||
return settingsState.themeOfTheMonthDismissedMonth !== entry.month;
|
return settingsState.themeOfTheMonthDismissedMonth !== entry.month;
|
||||||
@@ -67,7 +62,6 @@ function formatMonthLabel(month: string): string {
|
|||||||
return date.toLocaleDateString("en-US", { year: "numeric", month: "long" });
|
return date.toLocaleDateString("en-US", { year: "numeric", month: "long" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same priority as the theme store: marquee, then cover/banner. */
|
|
||||||
function heroUrlFromStoreTheme(theme: {
|
function heroUrlFromStoreTheme(theme: {
|
||||||
marqueeImage?: string | null;
|
marqueeImage?: string | null;
|
||||||
coverImage?: string | null;
|
coverImage?: string | null;
|
||||||
@@ -76,41 +70,484 @@ function heroUrlFromStoreTheme(theme: {
|
|||||||
return url || null;
|
return url || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function fetchThemeStoreTheme(themeId: string): Promise<Theme | null> {
|
||||||
* Loads hero image for a store theme via the background script (same path as
|
|
||||||
* {@link ThemeSelector} / theme store detail fetches).
|
|
||||||
*/
|
|
||||||
export async function fetchThemeStoreHeroImage(themeId: string): Promise<string | null> {
|
|
||||||
try {
|
try {
|
||||||
const token = await cloudAuth.getStoredToken();
|
const token = await cloudAuth.getStoredToken();
|
||||||
const res = (await browser.runtime.sendMessage({
|
const res = (await browser.runtime.sendMessage({
|
||||||
type: "fetchThemeDetails",
|
type: "fetchThemeDetails",
|
||||||
themeId,
|
themeId,
|
||||||
token: token ?? undefined,
|
token: token ?? undefined,
|
||||||
})) as { success?: boolean; data?: { theme?: { marqueeImage?: string; coverImage?: string } } };
|
})) as { success?: boolean; data?: { theme?: Record<string, unknown> } };
|
||||||
|
|
||||||
if (!res?.success || !res?.data?.theme) return null;
|
if (!res?.success || !res?.data?.theme) return null;
|
||||||
return heroUrlFromStoreTheme(res.data.theme);
|
return normalizeStoreTheme(res.data.theme);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[ThemeOfTheMonth] Failed to fetch theme store image:", err);
|
console.warn("[ThemeOfTheMonth] Failed to fetch theme store details:", err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Linked theme store image, else optional admin-uploaded cover. */
|
export async function fetchThemeStoreHeroImage(themeId: string): Promise<string | null> {
|
||||||
async function resolvePopupHeroImageUrl(entry: ThemeOfTheMonthEntry): Promise<string | null> {
|
const theme = await fetchThemeStoreTheme(themeId);
|
||||||
const themeId = entry.theme_id ?? entry.theme?.id;
|
return theme ? heroUrlFromStoreTheme(theme) : null;
|
||||||
if (themeId) {
|
|
||||||
const fromStore = await fetchThemeStoreHeroImage(themeId);
|
|
||||||
if (fromStore) return fromStore;
|
|
||||||
}
|
|
||||||
const fallback = entry.cover_image?.trim();
|
|
||||||
return fallback || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void) {
|
type PopupGallerySlide = { imageUrl: string; caption: string };
|
||||||
|
|
||||||
|
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;
|
if (card.classList.contains("themeOfTheMonthCardClosing")) return;
|
||||||
|
|
||||||
card.classList.add("themeOfTheMonthCardClosing");
|
card.classList.add("themeOfTheMonthCardClosing");
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
card.remove();
|
card.remove();
|
||||||
@@ -118,38 +555,127 @@ function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void) {
|
|||||||
}, 180);
|
}, 180);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ---------------------------------------------------------------------------
|
||||||
* Renders the Theme of the Month announcement card.
|
// Gallery
|
||||||
*/
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function initThemeOfTheMonthGallery(card: HTMLElement): void {
|
||||||
|
const track = card.querySelector<HTMLElement>(".themeOfTheMonthCardGalleryTrack");
|
||||||
|
if (!track) return;
|
||||||
|
|
||||||
|
const slides = [...track.querySelectorAll<HTMLElement>(".themeOfTheMonthCardGallerySlide")];
|
||||||
|
if (slides.length <= 1) return;
|
||||||
|
|
||||||
|
const dots = [...card.querySelectorAll<HTMLButtonElement>(".themeOfTheMonthCardGalleryDot")];
|
||||||
|
let activeIndex = 0;
|
||||||
|
|
||||||
|
const scrollToIndex = (index: number) => {
|
||||||
|
const clamped = ((index % slides.length) + slides.length) % slides.length;
|
||||||
|
activeIndex = clamped;
|
||||||
|
const slide = slides[clamped];
|
||||||
|
track.scrollTo({ left: slide.offsetLeft, behavior: "smooth" });
|
||||||
|
for (const dot of dots) {
|
||||||
|
const isActive = Number(dot.dataset.slide) === clamped;
|
||||||
|
dot.classList.toggle("themeOfTheMonthCardGalleryDotActive", isActive);
|
||||||
|
dot.setAttribute("aria-selected", isActive ? "true" : "false");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
card.querySelector(".themeOfTheMonthCardGalleryPrev")?.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
scrollToIndex(activeIndex - 1);
|
||||||
|
});
|
||||||
|
card.querySelector(".themeOfTheMonthCardGalleryNext")?.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
scrollToIndex(activeIndex + 1);
|
||||||
|
});
|
||||||
|
for (const dot of dots) {
|
||||||
|
dot.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
scrollToIndex(Number((e.currentTarget as HTMLButtonElement).dataset.slide));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncDotsFromScroll = () => {
|
||||||
|
const mid = track.scrollLeft + track.clientWidth / 2;
|
||||||
|
let nearest = 0;
|
||||||
|
let nearestDist = Infinity;
|
||||||
|
slides.forEach((slide, i) => {
|
||||||
|
const center = slide.offsetLeft + slide.offsetWidth / 2;
|
||||||
|
const dist = Math.abs(center - mid);
|
||||||
|
if (dist < nearestDist) {
|
||||||
|
nearestDist = dist;
|
||||||
|
nearest = i;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (nearest === activeIndex) return;
|
||||||
|
activeIndex = nearest;
|
||||||
|
for (const dot of dots) {
|
||||||
|
const isActive = Number(dot.dataset.slide) === nearest;
|
||||||
|
dot.classList.toggle("themeOfTheMonthCardGalleryDotActive", isActive);
|
||||||
|
dot.setAttribute("aria-selected", isActive ? "true" : "false");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
track.addEventListener("scroll", syncDotsFromScroll, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachPopupImages(root: ParentNode): void {
|
||||||
|
for (const img of root.querySelectorAll<HTMLImageElement>("img")) {
|
||||||
|
attachPopupMediaFullscreen(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main entry point
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function OpenThemeOfTheMonthPopup(
|
export async function OpenThemeOfTheMonthPopup(
|
||||||
entry: ThemeOfTheMonthEntry,
|
entry: ThemeOfTheMonthEntry,
|
||||||
onDismissed?: () => void,
|
onDismissed?: () => void,
|
||||||
) {
|
): Promise<void> {
|
||||||
document.getElementById("theme-of-the-month-card")?.remove();
|
document.getElementById("theme-of-the-month-card")?.remove();
|
||||||
|
document.getElementById("theme-of-the-month-backdrop")?.remove();
|
||||||
|
|
||||||
const monthLabel = formatMonthLabel(entry.month);
|
const monthLabel = formatMonthLabel(entry.month);
|
||||||
const heroUrl = await resolvePopupHeroImageUrl(entry);
|
|
||||||
const description = escapeHTML(entry.description).replace(/\n/g, " ");
|
|
||||||
const linkedThemeId = entry.theme_id ?? entry.theme?.id;
|
const linkedThemeId = entry.theme_id ?? entry.theme?.id;
|
||||||
|
const storeTheme = linkedThemeId ? await fetchThemeStoreTheme(linkedThemeId) : null;
|
||||||
|
const heroUrl =
|
||||||
|
(storeTheme ? heroUrlFromStoreTheme(storeTheme) : null) ??
|
||||||
|
entry.cover_image?.trim() ??
|
||||||
|
null;
|
||||||
|
const gallerySlides = buildPopupGallerySlides(entry, storeTheme, heroUrl);
|
||||||
|
const hasExpandableContent = gallerySlides.length > 0 || entry.description.trim().length > 0;
|
||||||
|
|
||||||
|
const descriptionHtml = escapeHTML(entry.description).replace(/\n/g, "<br />");
|
||||||
|
const heroEmbossHtml =
|
||||||
|
heroUrl && storeTheme ? renderHeroEmbossHtml(storeTheme, entry) : "";
|
||||||
|
|
||||||
|
const backdrop = stringToHTML(/* html */ `
|
||||||
|
<div id="theme-of-the-month-backdrop" class="themeOfTheMonthBackdrop" hidden aria-hidden="true"></div>
|
||||||
|
`).firstChild as HTMLElement;
|
||||||
|
|
||||||
const card = stringToHTML(/* html */ `
|
const card = stringToHTML(/* html */ `
|
||||||
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard" role="dialog" aria-label="Theme of the Month">
|
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard${settingsState.animations ? "" : " themeOfTheMonthCardReducedMotion"}" role="dialog" aria-label="Theme of the Month">
|
||||||
${
|
<div class="themeOfTheMonthCardMedia">
|
||||||
heroUrl
|
<button type="button" class="themeOfTheMonthCardPopout" aria-label="Expand" title="Expand"${hasExpandableContent ? "" : " hidden"}>
|
||||||
? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />`
|
<span class="themeOfTheMonthCardPopoutIcon">${POPOUT_EXPAND_SVG}</span>
|
||||||
: ""
|
</button>
|
||||||
}
|
<div class="themeOfTheMonthCardCompactMedia"${heroUrl ? "" : " hidden"}>
|
||||||
|
${heroUrl ? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />` : ""}
|
||||||
|
</div>
|
||||||
|
<div class="themeOfTheMonthCardExpandedPanel" hidden>
|
||||||
|
${renderGallerySlidesHtml(gallerySlides)}
|
||||||
|
</div>
|
||||||
|
${heroEmbossHtml}
|
||||||
|
</div>
|
||||||
<div class="themeOfTheMonthCardBody">
|
<div class="themeOfTheMonthCardBody">
|
||||||
<p class="themeOfTheMonthCardEyebrow">Theme of the Month · ${escapeHTML(monthLabel)}</p>
|
<p class="themeOfTheMonthCardEyebrow">Theme of the Month · ${escapeHTML(monthLabel)}</p>
|
||||||
<h2>${escapeHTML(entry.title)}</h2>
|
<h2>${escapeHTML(entry.title)}</h2>
|
||||||
<p class="themeOfTheMonthCardDescription">${description}</p>
|
<p class="themeOfTheMonthCardDescription themeOfTheMonthCardDescriptionClipped">${descriptionHtml}</p>
|
||||||
<div class="themeOfTheMonthCardActions">
|
<div class="themeOfTheMonthCardActions">
|
||||||
<div class="themeOfTheMonthCardActionsStart">
|
<div class="themeOfTheMonthCardActionsStart">
|
||||||
${
|
${linkedThemeId ? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>` : ""}
|
||||||
linkedThemeId
|
|
||||||
? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="themeOfTheMonthCardActionsEnd">
|
<div class="themeOfTheMonthCardActionsEnd">
|
||||||
<button type="button" class="themeOfTheMonthCardSecondary">Close</button>
|
<button type="button" class="themeOfTheMonthCardSecondary">Close</button>
|
||||||
@@ -170,32 +696,97 @@ export async function OpenThemeOfTheMonthPopup(
|
|||||||
</aside>
|
</aside>
|
||||||
`).firstChild as HTMLElement;
|
`).firstChild as HTMLElement;
|
||||||
|
|
||||||
const autoCloseTimeout = window.setTimeout(() => {
|
let isExpanded = false;
|
||||||
closeThemeOfTheMonthCard(card, onDismissed);
|
let expandAnimating = false;
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
const dismiss = () => {
|
const applyExpandedState = async (expanded: boolean): Promise<void> => {
|
||||||
window.clearTimeout(autoCloseTimeout);
|
updateThemeOfTheMonthPopoutUi(card, expanded);
|
||||||
|
if (!settingsState.animations) {
|
||||||
|
setThemeOfTheMonthExpandedInstant(card, backdrop, expanded, descriptionHtml);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expandAnimating = true;
|
||||||
|
try {
|
||||||
|
if (expanded) {
|
||||||
|
await runThemeOfTheMonthExpand(card, backdrop, descriptionHtml);
|
||||||
|
} else {
|
||||||
|
await runThemeOfTheMonthCollapse(card, backdrop, descriptionHtml);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
expandAnimating = false;
|
||||||
|
updateThemeOfTheMonthPopoutUi(card, expanded);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDocKey = (ev: KeyboardEvent) => {
|
||||||
|
if (ev.key !== "Escape") return;
|
||||||
|
if (!isExpanded || expandAnimating) return;
|
||||||
|
ev.stopPropagation();
|
||||||
|
isExpanded = false;
|
||||||
|
void applyExpandedState(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let autoCloseTimeout = 0;
|
||||||
|
const pauseAutoClose = () => window.clearTimeout(autoCloseTimeout);
|
||||||
|
const onResize = () => {
|
||||||
|
if (isExpanded) applyExpandedCardShell(card);
|
||||||
|
applyThemeOfTheMonthCardPosition(
|
||||||
|
card,
|
||||||
|
isExpanded,
|
||||||
|
false,
|
||||||
|
isExpanded ? themeOfTheMonthExpandedShellHeight() : undefined,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissWithCleanup = () => {
|
||||||
|
pauseAutoClose();
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
backdrop.remove();
|
||||||
|
document.removeEventListener("keydown", onDocKey, true);
|
||||||
closeThemeOfTheMonthCard(card, onDismissed);
|
closeThemeOfTheMonthCard(card, onDismissed);
|
||||||
};
|
};
|
||||||
|
|
||||||
card.addEventListener("mouseenter", () => window.clearTimeout(autoCloseTimeout), { once: true });
|
autoCloseTimeout = window.setTimeout(dismissWithCleanup, 30_000);
|
||||||
|
card.addEventListener("mouseenter", pauseAutoClose, { once: true });
|
||||||
|
|
||||||
|
initThemeOfTheMonthGallery(card);
|
||||||
|
attachPopupImages(card);
|
||||||
|
|
||||||
const confirmEl = card.querySelector<HTMLElement>(".themeOfTheMonthCardConfirm");
|
const confirmEl = card.querySelector<HTMLElement>(".themeOfTheMonthCardConfirm");
|
||||||
|
|
||||||
|
const toggleExpanded = () => {
|
||||||
|
if (expandAnimating) return;
|
||||||
|
isExpanded = !isExpanded;
|
||||||
|
pauseAutoClose();
|
||||||
|
void applyExpandedState(isExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
card.querySelector(".themeOfTheMonthCardPopout")?.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleExpanded();
|
||||||
|
});
|
||||||
|
|
||||||
|
backdrop.addEventListener("click", () => {
|
||||||
|
if (!isExpanded || expandAnimating) return;
|
||||||
|
isExpanded = false;
|
||||||
|
void applyExpandedState(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onDocKey, true);
|
||||||
|
|
||||||
card.querySelector(".themeOfTheMonthCardSecondary")?.addEventListener("click", () => {
|
card.querySelector(".themeOfTheMonthCardSecondary")?.addEventListener("click", () => {
|
||||||
settingsState.themeOfTheMonthDismissedMonth = entry.month;
|
settingsState.themeOfTheMonthDismissedMonth = entry.month;
|
||||||
dismiss();
|
dismissWithCleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
|
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
|
||||||
settingsState.themeOfTheMonthDismissedMonth = entry.month;
|
settingsState.themeOfTheMonthDismissedMonth = entry.month;
|
||||||
dismiss();
|
dismissWithCleanup();
|
||||||
openThemeStoreWithHighlight(linkedThemeId!);
|
openThemeStoreWithHighlight(linkedThemeId!);
|
||||||
});
|
});
|
||||||
|
|
||||||
const openDontShowConfirm = () => {
|
const openDontShowConfirm = () => {
|
||||||
window.clearTimeout(autoCloseTimeout);
|
pauseAutoClose();
|
||||||
if (!confirmEl) return;
|
if (!confirmEl) return;
|
||||||
confirmEl.hidden = false;
|
confirmEl.hidden = false;
|
||||||
requestAnimationFrame(() => confirmEl.classList.add("themeOfTheMonthCardConfirmVisible"));
|
requestAnimationFrame(() => confirmEl.classList.add("themeOfTheMonthCardConfirmVisible"));
|
||||||
@@ -206,23 +797,37 @@ export async function OpenThemeOfTheMonthPopup(
|
|||||||
card.querySelector(".themeOfTheMonthCardConfirmCancel")?.addEventListener("click", () => {
|
card.querySelector(".themeOfTheMonthCardConfirmCancel")?.addEventListener("click", () => {
|
||||||
if (!confirmEl) return;
|
if (!confirmEl) return;
|
||||||
confirmEl.classList.remove("themeOfTheMonthCardConfirmVisible");
|
confirmEl.classList.remove("themeOfTheMonthCardConfirmVisible");
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => { confirmEl.hidden = true; }, 160);
|
||||||
confirmEl.hidden = true;
|
|
||||||
}, 160);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
card.querySelector(".themeOfTheMonthCardConfirmAccept")?.addEventListener("click", () => {
|
card.querySelector(".themeOfTheMonthCardConfirmAccept")?.addEventListener("click", () => {
|
||||||
settingsState.themeOfTheMonthDisabled = true;
|
settingsState.themeOfTheMonthDisabled = true;
|
||||||
dismiss();
|
dismissWithCleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.body.appendChild(card);
|
// Mount — card at top:0; left:0, all positioning via transform.
|
||||||
|
card.style.position = "fixed";
|
||||||
|
card.style.top = "0";
|
||||||
|
card.style.left = "0";
|
||||||
|
|
||||||
|
document.body.append(backdrop, card);
|
||||||
|
|
||||||
|
// Set initial collapsed position instantly (no transition).
|
||||||
|
applyThemeOfTheMonthCardPosition(card, false, false);
|
||||||
|
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
|
||||||
|
// Enable morph-ready class after two frames so the initial snap doesn't
|
||||||
|
// accidentally play a transition.
|
||||||
|
if (settingsState.animations) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
card.classList.add("themeOfTheMonthCardMorphReady");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Dev helper: fetch the current month's entry and show the popup immediately,
|
|
||||||
* even if the user dismissed it for this calendar month.
|
|
||||||
*/
|
|
||||||
export async function showThemeOfTheMonthPopupNow(): Promise<void> {
|
export async function showThemeOfTheMonthPopupNow(): Promise<void> {
|
||||||
const entry = await fetchThemeOfTheMonth();
|
const entry = await fetchThemeOfTheMonth();
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
|
|||||||
const text = stringToHTML(/* html */ `
|
const text = stringToHTML(/* html */ `
|
||||||
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
|
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
|
||||||
|
|
||||||
<h1>3.7.0 – Grade Analytics, fonts, Global Search & SEQTA Engage Improvements</h1>
|
<h1>3.7.0 – Grade Analytics, Enhanced Navigation, fonts, Global Search & SEQTA Engage Improvements</h1>
|
||||||
|
<li>Added Enhanced Navigation for courses: the navigator now auto-scrolls to the selected lesson (e.g. inside the "Go to…" popup) and prev/next arrows for jumping between lessons.</li>
|
||||||
<li>Added Grade Analytics, new sidebar page with grade trend charts synced from SEQTA.</li>
|
<li>Added Grade Analytics, new sidebar page with grade trend charts synced from SEQTA.</li>
|
||||||
<li>Added Grade distribution auto-detects your school’s letter scale from released marks for analytics page.</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>
|
<li>Added documents, notices, portals, folios, goals, and more to Global Search.</li>
|
||||||
@@ -53,6 +54,7 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
|
|||||||
<li>Added font picker in settings.</li>
|
<li>Added font picker in settings.</li>
|
||||||
<li>Added rubric copy on assessment detail pages.</li>
|
<li>Added rubric copy on assessment detail pages.</li>
|
||||||
<li>Added manual weight entry when an assessment weight is N/A.</li>
|
<li>Added manual weight entry when an assessment weight is N/A.</li>
|
||||||
|
<li>Added an automatic reindex of assessments if any of a series of tracked values change (title, release state, etc). Helps keep weightings up to date.</li>
|
||||||
<li>Fixed BetterSEQTA sidebar injection issues on some pages.</li>
|
<li>Fixed BetterSEQTA sidebar injection issues on some pages.</li>
|
||||||
<li>Tweak Theme of the Month popup making it more clear about dismissals and respecting “Don’t show again”.</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>
|
<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 browser from "webextension-polyfill";
|
||||||
import localforage from "localforage";
|
import localforage from "localforage";
|
||||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||||
|
import { clearCloudPfpCache, pfpUrlWithHash } from "@/seqta/utils/cloudPfpCache";
|
||||||
|
|
||||||
const ACCOUNTS_BASE = "https://accounts.betterseqta.org";
|
const ACCOUNTS_BASE = "https://accounts.betterseqta.org";
|
||||||
const PLUGIN_SETTINGS_KEY = "plugin.profile-picture.settings";
|
const PLUGIN_SETTINGS_KEY = "plugin.profile-picture.settings";
|
||||||
@@ -10,9 +11,32 @@ const profileStore = localforage.createInstance({
|
|||||||
storeName: "profilePicture",
|
storeName: "profilePicture",
|
||||||
});
|
});
|
||||||
|
|
||||||
function cacheBustPfpUrl(url: string): string {
|
/** Downscale before upload to reduce ingress (server still normalizes). */
|
||||||
const base = url.split("?")[0]!;
|
async function downscaleForUpload(blob: Blob, maxEdge = 512): Promise<Blob> {
|
||||||
return `${base}?v=${Date.now()}`;
|
if (!blob.type.startsWith("image/")) return blob;
|
||||||
|
|
||||||
|
const bitmap = await createImageBitmap(blob);
|
||||||
|
const maxSide = Math.max(bitmap.width, bitmap.height);
|
||||||
|
if (maxSide <= maxEdge) {
|
||||||
|
bitmap.close();
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = maxEdge / maxSide;
|
||||||
|
const w = Math.max(1, Math.round(bitmap.width * scale));
|
||||||
|
const h = Math.max(1, Math.round(bitmap.height * scale));
|
||||||
|
|
||||||
|
const canvas = new OffscreenCanvas(w, h);
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) {
|
||||||
|
bitmap.close();
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
ctx.drawImage(bitmap, 0, 0, w, h);
|
||||||
|
bitmap.close();
|
||||||
|
|
||||||
|
const out = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.85 });
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isUseCloudPfpEnabled(): Promise<boolean> {
|
export async function isUseCloudPfpEnabled(): Promise<boolean> {
|
||||||
@@ -41,6 +65,9 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
|
|||||||
const token = await cloudAuth.getStoredToken();
|
const token = await cloudAuth.getStoredToken();
|
||||||
if (!token) return { success: false, error: "Not logged in" };
|
if (!token) return { success: false, error: "Not logged in" };
|
||||||
|
|
||||||
|
const user = cloudAuth.state.user;
|
||||||
|
const userId = user?.id;
|
||||||
|
|
||||||
const blob = await profileStore.getItem<Blob>("profile-picture");
|
const blob = await profileStore.getItem<Blob>("profile-picture");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -57,10 +84,10 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return { success: false, error: (data.error as string) ?? `Clear failed (${res.status})` };
|
return { success: false, error: (data.error as string) ?? `Clear failed (${res.status})` };
|
||||||
}
|
}
|
||||||
const user = cloudAuth.state.user;
|
|
||||||
if (user) {
|
if (user) {
|
||||||
await cloudAuth.setUser({ ...user, pfpUrl: undefined });
|
await cloudAuth.setUser({ ...user, pfpUrl: undefined, pfpHash: null });
|
||||||
}
|
}
|
||||||
|
if (userId) await clearCloudPfpCache(userId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,8 +98,9 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
|
|||||||
return { success: false, error: "File too large (max 5MB)" };
|
return { success: false, error: "File too large (max 5MB)" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uploadBlob = await downscaleForUpload(blob);
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", blob, "profile-picture");
|
formData.append("file", uploadBlob, "profile-picture.jpg");
|
||||||
|
|
||||||
const res = await fetch(`${ACCOUNTS_BASE}/api/user/pfp`, {
|
const res = await fetch(`${ACCOUNTS_BASE}/api/user/pfp`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -85,10 +113,15 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pfpUrl = data.pfpUrl as string | undefined;
|
const pfpUrl = data.pfpUrl as string | undefined;
|
||||||
const user = cloudAuth.state.user;
|
const pfpHash = (data.pfpHash as string | null | undefined) ?? null;
|
||||||
if (user && pfpUrl) {
|
if (user && pfpUrl) {
|
||||||
await cloudAuth.setUser({ ...user, pfpUrl: cacheBustPfpUrl(pfpUrl) });
|
await cloudAuth.setUser({
|
||||||
|
...user,
|
||||||
|
pfpUrl: pfpUrlWithHash(pfpUrl, pfpHash),
|
||||||
|
pfpHash,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
if (userId) await clearCloudPfpCache(userId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ export interface SettingsState {
|
|||||||
animations: boolean;
|
animations: boolean;
|
||||||
defaultPage: string;
|
defaultPage: string;
|
||||||
devMode?: boolean;
|
devMode?: boolean;
|
||||||
|
/** Dev-only: pretend this is the latest GitHub release version for update badge testing. */
|
||||||
|
devGhReleaseVersionOverride?: string;
|
||||||
|
/** ISO timestamp of the last acknowledged nightly release publish time. */
|
||||||
|
lastSeenNightlyPublishedAt?: string;
|
||||||
originalDarkMode?: boolean;
|
originalDarkMode?: boolean;
|
||||||
newsSource?: string;
|
newsSource?: string;
|
||||||
mockNotices?: boolean;
|
mockNotices?: boolean;
|
||||||
@@ -71,7 +75,7 @@ export interface SettingsState {
|
|||||||
bsplus_client_id?: string;
|
bsplus_client_id?: string;
|
||||||
bsplus_token?: string;
|
bsplus_token?: string;
|
||||||
bsplus_refresh_token?: string;
|
bsplus_refresh_token?: string;
|
||||||
bsplus_user?: { id: string; email?: string; username?: string; displayName?: string; pfpUrl?: string; admin_level?: number };
|
bsplus_user?: { id: string; email?: string; username?: string; displayName?: string; pfpUrl?: string; pfpHash?: string | null; admin_level?: number };
|
||||||
/** When not `false`, automatic cloud settings sync is enabled (default-on). */
|
/** When not `false`, automatic cloud settings sync is enabled (default-on). */
|
||||||
autoCloudSettingsSync?: boolean;
|
autoCloudSettingsSync?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/**/*.js",
|
||||||
"src/**/*.svelte",
|
"src/**/*.svelte",
|
||||||
"src/interface/+layout.svelte",
|
"src/interface/+layout.svelte",
|
||||||
|
"src/env.d.ts",
|
||||||
"declarations.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";
|
const useMillion = mode.toLowerCase() !== "firefox";
|
||||||
|
|
||||||
export default defineConfig(({ command }) => ({
|
export default defineConfig(({ command }) => ({
|
||||||
|
define: {
|
||||||
|
__ENABLE_GH_RELEASE_UPDATE_CHECK__: JSON.stringify(
|
||||||
|
process.env.GH_RELEASE_UPDATE_CHECK === "true",
|
||||||
|
),
|
||||||
|
__GH_RELEASE_REPO__: JSON.stringify(
|
||||||
|
process.env.GH_RELEASE_REPO ?? "BetterSEQTA/BetterSEQTA-Plus",
|
||||||
|
),
|
||||||
|
__UPDATE_CHANNEL__: JSON.stringify(process.env.UPDATE_CHANNEL ?? "stable"),
|
||||||
|
__BUILD_LABEL__: JSON.stringify(process.env.BUILD_LABEL ?? ""),
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
base64Loader,
|
base64Loader,
|
||||||
InlineWorkerPlugin(),
|
InlineWorkerPlugin(),
|
||||||
|
|||||||
Reference in New Issue
Block a user