Compare commits

..

1 Commits

Author SHA1 Message Date
Seth Burkart fda0071251 Merge pull request #257 from BetterSEQTA/main
Merge main into global-search
2025-05-03 18:58:46 +10:00
268 changed files with 24439 additions and 53673 deletions
+112 -95
View File
@@ -2,83 +2,87 @@
module.exports = {
forbidden: [
{
name: "no-circular",
severity: "warn",
name: 'no-circular',
severity: 'warn',
comment:
"This dependency is part of a circular relationship. You might want to revise " +
"your solution (i.e. use dependency inversion, make sure the modules have a single responsibility) ",
'This dependency is part of a circular relationship. You might want to revise ' +
'your solution (i.e. use dependency inversion, make sure the modules have a single responsibility) ',
from: {},
to: {
circular: true,
},
circular: true
}
},
{
name: "no-orphans",
name: 'no-orphans',
comment:
"This is an orphan module - it's likely not used (anymore?). Either use it or " +
"remove it. If it's logical this module is an orphan (i.e. it's a config file), " +
"add an exception for it in your dependency-cruiser configuration. By default " +
"this rule does not scrutinize dot-files (e.g. .eslintrc.js), TypeScript declaration " +
"files (.d.ts), tsconfig.json and some of the babel and webpack configs.",
severity: "warn",
severity: 'warn',
from: {
orphan: true,
pathNot: [
"(^|/)[.][^/]+[.](?:js|cjs|mjs|ts|cts|mts|json)$", // dot files
"[.]d[.]ts$", // TypeScript declaration files
"(^|/)tsconfig[.]json$", // TypeScript config
"(^|/)(?:babel|webpack)[.]config[.](?:js|cjs|mjs|ts|cts|mts|json)$", // other configs
],
'(^|/)[.][^/]+[.](?:js|cjs|mjs|ts|cts|mts|json)$', // dot files
'[.]d[.]ts$', // TypeScript declaration files
'(^|/)tsconfig[.]json$', // TypeScript config
'(^|/)(?:babel|webpack)[.]config[.](?:js|cjs|mjs|ts|cts|mts|json)$' // other configs
]
},
to: {},
},
{
name: "no-deprecated-core",
name: 'no-deprecated-core',
comment:
"A module depends on a node core module that has been deprecated. Find an alternative - these are " +
'A module depends on a node core module that has been deprecated. Find an alternative - these are ' +
"bound to exist - node doesn't deprecate lightly.",
severity: "warn",
severity: 'warn',
from: {},
to: {
dependencyTypes: ["core"],
path: [
"^v8/tools/codemap$",
"^v8/tools/consarray$",
"^v8/tools/csvparser$",
"^v8/tools/logreader$",
"^v8/tools/profile_view$",
"^v8/tools/profile$",
"^v8/tools/SourceMap$",
"^v8/tools/splaytree$",
"^v8/tools/tickprocessor-driver$",
"^v8/tools/tickprocessor$",
"^node-inspect/lib/_inspect$",
"^node-inspect/lib/internal/inspect_client$",
"^node-inspect/lib/internal/inspect_repl$",
"^async_hooks$",
"^punycode$",
"^domain$",
"^constants$",
"^sys$",
"^_linklist$",
"^_stream_wrap$",
dependencyTypes: [
'core'
],
},
path: [
'^v8/tools/codemap$',
'^v8/tools/consarray$',
'^v8/tools/csvparser$',
'^v8/tools/logreader$',
'^v8/tools/profile_view$',
'^v8/tools/profile$',
'^v8/tools/SourceMap$',
'^v8/tools/splaytree$',
'^v8/tools/tickprocessor-driver$',
'^v8/tools/tickprocessor$',
'^node-inspect/lib/_inspect$',
'^node-inspect/lib/internal/inspect_client$',
'^node-inspect/lib/internal/inspect_repl$',
'^async_hooks$',
'^punycode$',
'^domain$',
'^constants$',
'^sys$',
'^_linklist$',
'^_stream_wrap$'
],
}
},
{
name: "not-to-deprecated",
name: 'not-to-deprecated',
comment:
"This module uses a (version of an) npm module that has been deprecated. Either upgrade to a later " +
"version of that module, or find an alternative. Deprecated modules are a security risk.",
severity: "warn",
'This module uses a (version of an) npm module that has been deprecated. Either upgrade to a later ' +
'version of that module, or find an alternative. Deprecated modules are a security risk.',
severity: 'warn',
from: {},
to: {
dependencyTypes: ["deprecated"],
},
dependencyTypes: [
'deprecated'
]
}
},
{
name: "no-non-package-json",
severity: "error",
name: 'no-non-package-json',
severity: 'error',
comment:
"This module depends on an npm package that isn't in the 'dependencies' section of your package.json. " +
"That's problematic as the package either (1) won't be available on live (2 - worse) will be " +
@@ -86,75 +90,84 @@ module.exports = {
"in your package.json.",
from: {},
to: {
dependencyTypes: ["npm-no-pkg", "npm-unknown"],
},
dependencyTypes: [
'npm-no-pkg',
'npm-unknown'
]
}
},
{
name: "not-to-unresolvable",
name: 'not-to-unresolvable',
comment:
"This module depends on a module that cannot be found ('resolved to disk'). If it's an npm " +
"module: add it to your package.json. In all other cases you likely already know what to do.",
severity: "error",
'module: add it to your package.json. In all other cases you likely already know what to do.',
severity: 'error',
from: {},
to: {
couldNotResolve: true,
},
couldNotResolve: true
}
},
{
name: "no-duplicate-dep-types",
name: 'no-duplicate-dep-types',
comment:
"Likely this module depends on an external ('npm') package that occurs more than once " +
"in your package.json i.e. bot as a devDependencies and in dependencies. This will cause " +
"maintenance problems later on.",
severity: "warn",
severity: 'warn',
from: {},
to: {
moreThanOneDependencyType: true,
// as it's pretty common to have a type import be a type only import
// _and_ (e.g.) a devDependency - don't consider type-only dependency
// types for this rule
dependencyTypesNot: ["type-only"],
},
dependencyTypesNot: ["type-only"]
}
},
/* rules you might want to tweak for your specific situation: */
{
name: "not-to-spec",
name: 'not-to-spec',
comment:
"This module depends on a spec (test) file. The sole responsibility of a spec file is to test code. " +
'This module depends on a spec (test) file. The sole responsibility of a spec file is to test code. ' +
"If there's something in a spec that's of use to other modules, it doesn't have that single " +
"responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.",
severity: "error",
'responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.',
severity: 'error',
from: {},
to: {
path: "[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$",
},
path: '[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$'
}
},
{
name: "not-to-dev-dep",
severity: "error",
name: 'not-to-dev-dep',
severity: 'error',
comment:
"This module depends on an npm package from the 'devDependencies' section of your " +
"package.json. It looks like something that ships to production, though. To prevent problems " +
'package.json. It looks like something that ships to production, though. To prevent problems ' +
"with npm packages that aren't there on production declare it (only!) in the 'dependencies'" +
"section of your package.json. If this module is development only - add it to the " +
"from.pathNot re of the not-to-dev-dep rule in the dependency-cruiser configuration",
'section of your package.json. If this module is development only - add it to the ' +
'from.pathNot re of the not-to-dev-dep rule in the dependency-cruiser configuration',
from: {
path: "^(src)",
pathNot: "[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$",
path: '^(src)',
pathNot: '[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$'
},
to: {
dependencyTypes: ["npm-dev"],
dependencyTypes: [
'npm-dev',
],
// type only dependencies are not a problem as they don't end up in the
// production code or are ignored by the runtime.
dependencyTypesNot: ["type-only"],
pathNot: ["node_modules/@types/"],
},
dependencyTypesNot: [
'type-only'
],
pathNot: [
'node_modules/@types/'
]
}
},
{
name: "optional-deps-used",
severity: "info",
name: 'optional-deps-used',
severity: 'info',
comment:
"This module depends on an npm package that is declared as an optional dependency " +
"in your package.json. As this makes sense in limited situations only, it's flagged here. " +
@@ -162,28 +175,33 @@ module.exports = {
"dependency-cruiser configuration.",
from: {},
to: {
dependencyTypes: ["npm-optional"],
},
dependencyTypes: [
'npm-optional'
]
}
},
{
name: "peer-deps-used",
name: 'peer-deps-used',
comment:
"This module depends on an npm package that is declared as a peer dependency " +
"in your package.json. This makes sense if your package is e.g. a plugin, but in " +
"other cases - maybe not so much. If the use of a peer dependency is intentional " +
"add an exception to your dependency-cruiser configuration.",
severity: "warn",
severity: 'warn',
from: {},
to: {
dependencyTypes: ["npm-peer"],
},
},
dependencyTypes: [
'npm-peer'
]
}
}
],
options: {
/* Which modules not to follow further when encountered */
doNotFollow: {
/* path: an array of regular expressions in strings to match against */
path: ["node_modules"],
path: ['node_modules']
},
/* Which modules to exclude */
@@ -256,7 +274,7 @@ module.exports = {
defaults to './tsconfig.json'.
*/
tsConfig: {
fileName: "tsconfig.json",
fileName: 'tsconfig.json'
},
/* Webpack configuration to use to get resolve options from.
@@ -346,8 +364,8 @@ module.exports = {
"bun:wrap",
"detect-libc",
"undici",
"ws",
],
"ws"
]
},
reporterOptions: {
@@ -357,7 +375,7 @@ module.exports = {
collapses everything in node_modules to one folder deep so you see
the external modules, but their innards.
*/
collapsePattern: "node_modules/(?:@[^/]+/[^/]+|[^/]+)",
collapsePattern: 'node_modules/(?:@[^/]+/[^/]+|[^/]+)',
/* Options to tweak the appearance of your graph.See
https://github.com/sverweij/dependency-cruiser/blob/main/doc/options-reference.md#reporteroptions
@@ -379,8 +397,7 @@ module.exports = {
dependency graph reporter (`archi`) you probably want to tweak
this collapsePattern to your situation.
*/
collapsePattern:
"^(?:packages|src|lib(s?)|app(s?)|bin|test(s?)|spec(s?))/[^/]+|node_modules/(?:@[^/]+/[^/]+|[^/]+)",
collapsePattern: '^(?:packages|src|lib(s?)|app(s?)|bin|test(s?)|spec(s?))/[^/]+|node_modules/(?:@[^/]+/[^/]+|[^/]+)',
/* Options to tweak the appearance of your graph. If you don't specify a
theme for 'archi' dependency-cruiser will use the one specified in the
@@ -388,10 +405,10 @@ module.exports = {
*/
// theme: { },
},
text: {
highlightFocused: true,
},
},
"text": {
"highlightFocused": true
},
}
}
};
// generated: dependency-cruiser@16.10.0 on 2025-02-16T22:32:01.621Z
+2 -5
View File
@@ -12,15 +12,12 @@
},
"rules": {
// allow importing ts extensions
"sort-imports": [
"error",
{
"sort-imports": ["error", {
"ignoreCase": true,
"ignoreDeclarationSort": true,
"ignoreMemberSort": false,
"memberSyntaxSortOrder": ["none", "all", "multiple", "single"]
}
],
}],
"import/extensions": [
"error",
"ignorePackages",
-1
View File
@@ -2,7 +2,6 @@ name: Bug report
description: Report an issue with the modpack in its unmodified state. For other issues, use Discord.
labels: bug
title: "[BUG]"
type: "Bug"
body:
- type: markdown
attributes:
+6 -4
View File
@@ -2,8 +2,8 @@ name: Feature request
description: Suggest a new Feature to be added or replaced in BetterSeqtaPLUS
labels: enhancement
title: "[FR]"
body:
- type: checkboxes
attributes:
label: Confirm
@@ -25,6 +25,7 @@ body:
## Feature details
Before you request a feature, [please search](https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues) if it has already been requested. (Make sure to check closed issues as well!)
- type: dropdown
attributes:
label: Feature type
@@ -36,17 +37,18 @@ body:
validations:
required: true
- type: input
attributes:
label: Feature Details
description: Please write, with as much detail as possible, what you would like to see from this feature.
placeholder: it would be cool if
description: Please write, with as much detail as possible, what you would like to see from this mod.
placeholder: I would like to see...
validations:
required: false
- type: textarea
attributes:
label: Additional details
description: Anything else that would help describe your vision (reference images, descriptions, etc)
description: Anything else you want to add?
validations:
required: false
-114
View File
@@ -1,114 +0,0 @@
name: 🙋 New Contributor - Need Help Getting Started
description: Perfect for first-time contributors who need guidance
labels: ["help wanted", "documentation"]
title: "[NEW CONTRIBUTOR] "
body:
- type: markdown
attributes:
value: |
## Hi there! 👋
Welcome to BetterSEQTA+! We're excited to have you join our community.
- type: checkboxes
attributes:
label: Tell us about yourself (check all that apply)
options:
- label: "This is my first time contributing to open source"
required: false
- label: "I'm new to browser extensions"
required: false
- label: "I'm new to TypeScript/JavaScript"
required: false
- label: "I have some coding experience but new to this project"
required: false
- type: checkboxes
attributes:
label: What would you like to work on? (check all that apply)
options:
- label: "Fix a bug 🐛"
required: false
- label: "Add a new feature ✨"
required: false
- label: "Improve documentation 📚"
required: false
- label: "Create a plugin 🧩"
required: false
- label: "Improve the UI/design 🎨"
required: false
- label: "Write tests 🧪"
required: false
- label: "Not sure - I want to help but need guidance!"
required: false
- type: checkboxes
attributes:
label: Have you read our guides?
options:
- label: "Getting Started Guide (see docs/GETTING_STARTED_CONTRIBUTING.md)"
required: true
- label: "Architecture Guide (see docs/ARCHITECTURE.md)"
required: true
- label: "Plugin Development Guide (see docs/plugins/README.md)"
required: true
- type: checkboxes
attributes:
label: Have you set up the development environment yet?
options:
- label: Yes, everything works! 🎉
required: false
- label: Partially - I can run `npm run dev` but having some issues
required: false
- label: No, I need help with setup
required: false
- label: I tried but ran into errors (please describe below)
required: false
- type: input
attributes:
label: Errors
description: "Please list any encountered errors here:"
placeholder: "I am encountering issues with..."
validations:
required: false
- type: input
attributes:
label: Questions or Issues
description: "Tell us:
1. What specifically would you like help with?
2. Are you stuck on anything?
3. Do you have any questions about the codebase?
4. Is there anything in our documentation that's unclear?"
placeholder: "I want help with..."
validations:
required: false
- type: input
attributes:
label: Ideas or Suggestions
description: "If you have any ideas for features, improvements, or just want to share your thoughts:"
placeholder: "It would be cool if I could help add..."
validations:
required: false
- type: markdown
attributes:
value: |
## What happens next?
A maintainer will respond within 24-48 hours to:
- Answer your questions
- Suggest some good issues to work on
- Help you with setup if needed
- Point you to relevant documentation
Don't worry if you're new to this - we're here to help! Every expert was once a beginner. 🚀
**Join our [Discord server](https://discord.gg/YzmbnCDkat) for real-time help and community chat!**
+6 -8
View File
@@ -4,11 +4,12 @@ package-lock.json
bun.lockb
pnpm-lock.yaml
yarn.lock
bun.lock
# PDF.js extension assets (copied by postinstall from pdfjs-dist)
src/public/resources/pdfjs/pdf.worker.min.mjs
src/public/resources/pdfjs/pdf.legacy.min.mjs
.parcel-cache
.env
.env.submit
dependency-graph.svg
# Build
extension.zip
@@ -18,8 +19,5 @@ betterseqtaplus-safari/
.million/
.vscode/
**/.DS_Store
.parcel-cache
.env
.env.submit
dependency-graph.svg
-2
View File
@@ -1,2 +0,0 @@
legacy-peer-deps=true
+10 -10
View File
@@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
+7 -25
View File
@@ -1,31 +1,13 @@
# Contributing to BetterSEQTA+
# Contributing
Hey there! 👋 Thanks for your interest in contributing to BetterSEQTA+! We're excited to have you join our community of contributors.
## 🚀 New Contributors Start Here!
**Never contributed to an open source project before?** No worries! We've made it super easy to get started:
- **📖 Read our [contributing guide](https://docs.betterseqta.org/contributing/)** - This walks you through everything step-by-step, from setting up your development environment to making your first pull request.
- **🏗️ Understand the codebase** with the [architecture guide](https://docs.betterseqta.org/architecture/)
- **🔧 Having issues?** Check the [troubleshooting guide](https://docs.betterseqta.org/troubleshooting/)
We have lots of [`good first issue`](https://github.com/BetterSEQTA/BetterSEQTA-plus/labels/good%20first%20issue) labels that are perfect for beginners!
## Discussion Before Contributing
For significant changes, please first discuss what you'd like to change via:
- Opening an issue
- Joining our Discord server
- Emailing the maintainers
This helps ensure your contribution aligns with the project's goals and saves you time!
When contributing to this repository, please first discuss the change you wish to make via issue,
email, or any other method with the owners of this repository before making a change.
## Community
Join our community channels to discuss the project, get help, and connect with other contributors:
- **Discord Server**: [Join our Discord](https://discord.gg/YzmbnCDkat)
- **Discord Server**: [Join our Discord](https://discord.gg/betterseqta)
- **GitHub Discussions**: For longer-form conversations
- **GitHub Issues**: For bug reports and feature requests
@@ -33,13 +15,13 @@ Join our community channels to discuss the project, get help, and connect with o
If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides:
- [Plugin development](https://docs.betterseqta.org/plugin-development/)
- [Plugin API](https://docs.betterseqta.org/plugin-api/)
- [Creating Your First Plugin](./docs/plugins/creating-plugins.md)
- [Plugin API Reference](./docs/advanced/plugin-api.md)
## Pull Request Process
1. It is recommended to start by opening an issue to discuss the change you wish to make. This will allow us to discuss the change and ensure it is a good fit for the project.
2. Fork the repo and create your branch from `main`.
2. Fork the repo and create your branch from `master`.
3. When writing your pull request, make sure to use the pull request template.
### Pull Request Template
+76 -32
View File
@@ -1,3 +1,6 @@
#
<a href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel">
<img src="https://socialify.git.ci/betterseqta/betterseqta-plus/image?description=1&font=Inter&forks=1&issues=1&logo=data%3Aimage%2Fsvg%2Bxml%2C%253Csvg%20height%3D%27656pt%27%20fill%3D%27white%27%20preserveAspectRatio%3D%27xMidYMid%20meet%27%20viewBox%3D%270%200%20658%20656%27%20width%3D%27658pt%27%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%253E%253Cg%20transform%3D%27matrix(.1%200%200%20-.1%200%20656)%27%253E%253Cpath%20d%3D%27m2960%206499c-918-100-1726-561-2278-1299-196-262-374-609-475-925-171-533-203-1109-91-1655%20228-1115%201030-2032%202104-2408%20356-124%20680-177%201080-176%20269%201%20403%2014%20650%2064%20790%20159%201503%20624%201980%201290%20714%20998%20799%202342%20217%203420-488%20902-1361%201515-2382%201671-113%2017-196%2022-430%2024-159%202-328-1-375-6zm566-1443c476-99%20885-385%201134-791%20190-309%20282-696%20250-1045-22-240-73-420-180-635-78-156-159-275-274-401l-77-84h445%20446v-235-236l-1162%204-1163%203-100%2023c-449%20101-812%20337-1071%20697-77%20107-193%20335-233%20459-115%20358-116%20726-1%201078%20209%20644%20766%201101%201446%201187%20128%2016%20405%204%20540-24z%27%2F%253E%253Cpath%20d%3D%27m3065%204604c-250-36-396-89-576-209-280-187-470-478-535-821-25-135-16-395%2019-525%2095-351%20331-644%20651-806%2098-49%20225-93%20331-114%2092-18%20368-18%20460%200%20481%2095%20853%20444%20982%20921%2035%20129%2044%20389%2019%20524-36%20191-121%20387-228%20531-186%20249-476%20428-783%20485-65%2012-291%2021-340%2014z%27%2F%253E%253C%2Fg%253E%253C%2Fsvg%253E&name=1&owner=1&pattern=Signal&stargazers=1&theme=Dark" />
</a>
@@ -8,7 +11,7 @@
<p align="center">
<a target="_blank" href="https://chrome.google.com/webstore/detail/betterseqta%20/afdgaoaclhkhemfkkkonemoapeinchel"><img src="https://user-images.githubusercontent.com/95666457/149519713-159d7ef7-2c21-4034-a616-f037ff46d9a4.png" alt="ChromeDownload" width="250"></a>
<a target="_blank" href="https://discord.gg/YzmbnCDkat"><img src="https://github.com/BetterSEQTA/BetterSEQTA-Plus/assets/108050083/23055730-b16e-44c0-9bef-221d8545af92" width="240" style="border-radius:10%;" /></a>
<a target="_blank" href="https://discord.gg/YzmbnCDkat"><img src="https://github.com/SethBurkart123/EvenBetterSEQTA/assets/108050083/23055730-b16e-44c0-9bef-221d8545af92" width="240" style="border-radius:10%;" /></a>
</p>
<div>
@@ -16,15 +19,17 @@
<img src="https://img.shields.io/chrome-web-store/rating/afdgaoaclhkhemfkkkonemoapeinchel" />
</div>
## 📚 Documentation
All documentation has been moved to the [official docs site](https://docs.betterseqta.org):
## Table of contents
Includes:
- Getting started
- Development setup
- Architecture
- Plugin system
- Theme creation
- [Features](#features)
- [Creating Custom Themes](#creating-custom-themes)
- [Getting Started](#getting-started)
- [Running Development](#running-development)
- [Building for production](#building-for-production)
- [Folder Structure](#folder-structure)
- [Contributors](#contributors)
- [Credits](#credits)
- [Star History](#star-history)
## Features
@@ -48,32 +53,75 @@ Includes:
## Creating Custom Themes
If you are looking to create custom themes, I would recommend you start at the official documentation [here](https://docs.betterseqta.org/theme-creation/). You can see some premade examples along with a compilation script that can be used to allow for CSS frameworks and libraries such as SCSS to be used [here](https://github.com/BetterSEQTA/BetterSEQTA-Theme-Generator).
If you are looking to create custom themes, I would recommend you start at the official documentation [here](https://betterseqta.gitbook.io/betterseqta-docs). You can see some premade examples along with a compilation script that can be used to allow for CSS frameworks and libraries such as SCSS to be used [here](https://github.com/BetterSEQTA/BetterSEQTA-Theme-Generator).
Don't worry- if you get stuck feel free to ask around in the [discord](https://discord.gg/YzmbnCDkat). We're open and happy to help out! Happy creating :)
## 🚀 Contributing
**New contributors welcome!**
- 📖 Start here: https://docs.betterseqta.org/install/
- 🧠 Architecture: https://docs.betterseqta.org/architecture/
- 🧩 Plugins: https://docs.betterseqta.org/plugins/
- 💬 Discord: https://discord.gg/YzmbnCDkat
## Getting started
1. Clone the repository
```
git clone https://github.com/BetterSEQTA/BetterSEQTA-Plus
```
## ⚡ Quick Start
```bash
git clone https://github.com/YOUR_USERNAME_FORKED_WITH/BetterSEQTA-Plus
cd BetterSEQTA-Plus
npm install --legacy-peer-deps
npm run dev
````
1. Install dependencies
Then load `dist` in `chrome://extensions` (Developer Mode → Load unpacked).
You may install the dependencies like below:
```
npm install # or your preferred package manager like pnpm or yarn
```
But it is recommended to do it like this:
```
npm install --legacy-peer-deps # Only NPM supported
```
### Running Development
2. Run the dev script (it updates as you save files)
```
npm run dev # or use your perferred package manager
```
Full setup guide:
[https://betterseqta.github.io/BetterSEQTA-Docs/install/#for-developers-development-environment](https://betterseqta.github.io/BetterSEQTA-Docs/install/#for-developers-development-environment)
### Building for production
2. Run the build script
```
npm run build # or use your perferred package manager
```
2.1. Package it up (optional)
```
npm run zip # This REQUIRES 7-Zip to be installed in order to work. You can also use your perferred package manager
```
3. Load the extension into chrome
- Go to `chrome://extensions`
- Enable developer mode
- Click `Load unpacked`
- Select the `dist` folder
Just remember, in order to update changes to the extension if you are running in developer mode, you need to click the refresh button on the extension in `chrome://extensions` whenever anything's changed.
## Folder Structure
The folder structure is as follows:
- The `src` folder contains source files that are compiled to the build directory.
-
- The `src/plugins` folder contains vital loaders required for BetterSEQTA+ functionality.
- The `src/interface` folder contains source React & Svelte files that are required for the Settings page.
- The `dist` folder is where the compiled code ends up, this is the folder what you need to load into chrome as an unpacked extension for development.
## Contributors
@@ -81,14 +129,10 @@ Full setup guide:
<img src="https://contrib.rocks/image?repo=betterseqta/betterseqta-plus" />
</a>
Want to contribute? [Click Here!](https://docs.betterseqta.org/contributing/)
Want to contribute? [Click Here!](https://github.com/BetterSEQTA/BetterSEQTA-Plus/blob/main/CONTRIBUTING.md)
## Credits
This extension was initially developed by [Nulkem](https://github.com/Nulkem/betterseqta), was ported to manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) and is currently under active development from lead developers [SethBurkart123](https://github.com/SethBurkart123) and [Crazypersonalph](https://github.com/Crazypersonalph) with help from other volunteers.
Originally developed by [Nulkem](https://github.com/Nulkem/betterseqta)
Ported to Manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68)
Maintained by [SethBurkart123](https://github.com/SethBurkart123), [Crazypersonalph](https://github.com/Crazypersonalph) & the rest of the BetterSEQTA team!
This extension was initially developed by [Nulkem](https://github.com/Nulkem/betterseqta), was ported to manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) and is currently under active development by [SethBurkart123](https://github.com/SethBurkart123) and [Crazypersonalph](https://github.com/Crazypersonalph)
## Star History
+1 -2
View File
@@ -5,12 +5,11 @@
Below here is the supported versions of BetterSEQTA+. Anything older than this is not supported and contains bugs.
| Version | Supported |
| ------- | --------- |
| ------- | ------------------ |
| 3.4.3 | ✅ |
| < 3.4.3 | :x: |
`*` May not work on other devices.
## Reporting a Vulnerability
If you find vulnerabilities, REPORT IT IMMEDIATELY. open the [advisories tab](https://github.com/BetterSEQTA/BetterSEQTA-Plus/security/advisories) on the left and click the green "report a vulnerability" button or use [this quick-link](https://github.com/BetterSEQTA/BetterSEQTA-Plus/security/advisories/new) to create a new report
-2038
View File
File diff suppressed because it is too large Load Diff
-237
View File
@@ -1,237 +0,0 @@
# BetterSEQTA+ Architecture
**Published version:** [docs.betterseqta.org/architecture/](https://docs.betterseqta.org/architecture/)
Hey there! 👋 New to the codebase and feeling a bit lost? Don't worry - this guide will help you understand how everything fits together!
## Table of Contents
- [Overview](#overview)
- [High-Level Architecture](#high-level-architecture)
- [Core Components](#core-components)
- [Plugin System](#plugin-system)
- [File Structure Explained](#file-structure-explained)
- [Data Flow](#data-flow)
- [Browser Extension Basics](#browser-extension-basics)
## Overview
BetterSEQTA+ is a browser extension that enhances SEQTA Learn by:
- Adding new features through a plugin system
- Providing customizable themes and UI improvements
- Offering better navigation and user experience
Think of it like this: **SEQTA Learn + BetterSEQTA+ = Enhanced SEQTA Experience**
## High-Level Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ BROWSER EXTENSION │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ Background │ │ Content Script │ │
│ │ Script │ │ (SEQTA.ts) │ │
│ │ │ │ │ │
│ │ - Settings │◄───┤ - Page Detection│ │
│ │ - Storage │ │ - Plugin Loading│ │
│ │ - Updates │ │ - UI Injection │ │
│ └─────────────────┘ └──────────────────┘ │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ Plugin System │ │
│ │ │ │
│ │ ┌─────────────┐ │ │
│ │ │ Built-in │ │ │
│ │ │ Plugins │ │ │
│ │ │ │ │ │
│ │ │ - Themes │ │ │
│ │ │ - Search │ │ │
│ │ │ - Timetable │ │ │
│ │ │ - etc... │ │ │
│ │ └─────────────┘ │ │
│ └───────────────────┘ │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ Settings UI │ │
│ │ (Svelte App) │ │
│ │ │ │
│ │ - Plugin Config │ │
│ │ - Theme Creator │ │
│ │ - General Settings│ │
│ └───────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────▼─────────┐
│ SEQTA Learn │
│ Website │
└───────────────────┘
```
## Core Components
### 1. Entry Point (`src/SEQTA.ts`)
This is where it all begins! When you visit a SEQTA page:
1. Detects if you're on a SEQTA Learn page
2. Injects our CSS styles
3. Changes the favicon to BetterSEQTA+ icon
4. Loads settings from storage
5. Initializes the plugin system
### 2. Plugin System (`src/plugins/`)
The heart of BetterSEQTA+! This is what makes it extensible:
- **Plugin Manager**: Registers and manages all plugins
- **Built-in Plugins**: Pre-made plugins (themes, search, etc.)
- **Plugin API**: Provides plugins with tools to interact with SEQTA
### 3. Settings UI (`src/interface/`)
A Svelte application that lets users:
- Enable/disable plugins
- Configure plugin settings
- Create custom themes
- Browse the theme store
### 4. Background Script (`src/background.ts`)
Runs in the background and handles:
- Extension-wide settings storage
- Communication between different parts
- Update notifications
## Plugin System
Our plugin system is what makes BetterSEQTA+ so powerful. Here's how it works:
### Plugin Lifecycle
```
Plugin Registration → Settings Loading → Plugin Initialization → Running → Cleanup
```
### Built-in Plugins Overview
| Plugin | What it does | Files |
|--------|-------------|-------|
| **Themes** | Custom CSS themes and backgrounds | `src/plugins/built-in/themes/` |
| **Global Search** | Search across all SEQTA content | `src/plugins/built-in/globalSearch/` |
| **Timetable** | Enhanced timetable features | `src/plugins/built-in/timetable/` |
| **Profile Picture** | Custom profile pictures | `src/plugins/built-in/profilePicture/` |
| **Animated Background** | Moving background animations | `src/plugins/built-in/animatedBackground/` |
### Creating a Plugin
Every plugin follows this structure:
```typescript
const myPlugin: Plugin = {
id: "unique-plugin-id",
name: "Human Readable Name",
description: "What does this plugin do?",
version: "1.0.0",
settings: { /* user configurable options */ },
run: async (api) => {
// Your plugin code goes here!
}
};
```
## File Structure Explained
```
src/
├── SEQTA.ts # 🚀 Main entry point - start reading here!
├── background.ts # 🔧 Background script for extension
├── manifests/ # 📦 Browser extension manifests
├── plugins/ # 🧩 Plugin system (the magic happens here!)
│ ├── core/ # 🏗️ Plugin infrastructure
│ ├── built-in/ # 🎁 Pre-made plugins
│ └── index.ts # 📋 Plugin registration
├── interface/ # 🎨 Settings UI (Svelte app)
│ ├── pages/ # 📄 Settings pages
│ ├── components/ # 🧱 Reusable UI components
│ └── main.ts # 🏠 Settings app entry point
├── seqta/ # 🔗 SEQTA-specific utilities
│ ├── main.ts # 🎯 Core SEQTA modifications
│ ├── ui/ # 🎨 UI manipulation helpers
│ └── utils/ # 🛠️ Helper functions
└── css/ # 💄 Styles and themes
```
### Where to Start Reading?
1. **New to the project?** Start with `src/SEQTA.ts`
2. **Want to understand plugins?** Look at `src/plugins/core/types.ts`
3. **Want to see a simple plugin?** Check out `src/plugins/built-in/profilePicture/`
4. **Interested in the UI?** Explore `src/interface/main.ts`
## Data Flow
Here's how data flows through the system:
```
User visits SEQTA → SEQTA.ts detects page → Loads settings from storage
Plugin Manager initializes → Each plugin gets API access → Plugins modify SEQTA
User opens settings → Svelte UI loads → Settings changed → Storage updated
Storage change detected → Plugins notified → UI updates automatically
```
## Browser Extension Basics
Never worked on a browser extension before? Here's what you need to know:
### Content Scripts vs Background Scripts
- **Content Script** (`SEQTA.ts`): Runs on SEQTA pages, can access and modify the page
- **Background Script** (`background.ts`): Runs in the background, handles storage and messaging
### Manifest Files
Each browser needs a slightly different manifest file:
- `manifests/chrome.ts` - Chrome, Edge, Brave
- `manifests/firefox.ts` - Firefox
- `manifests/safari.ts` - Safari (experimental)
### Communication
Different parts of the extension communicate using:
- `browser.runtime.sendMessage()` - Send messages
- `browser.storage` - Shared storage, but we have created a custom storage system that is easier to use:
```ts
settingsState.[the setting name] = [whatever you want to set it to]
console.log(settingsState.[the setting name])
```
- Custom events for plugin communication
## Development Tips
### Debugging
1. **Chrome DevTools**: Right-click → Inspect → Console tab
2. **Extension Console**: `chrome://extensions` → BetterSEQTA+ → "Inspect views: background page"
3. **Look for logs**: We log everything with `[BetterSEQTA+]` prefix
### Making Changes
1. Edit code → Save → Browser auto-reloads extension → Refresh SEQTA page
2. For UI changes: The dev server hot-reloads automatically
3. For plugin changes: May need to disable/enable the plugin in settings
### Common Gotchas
- Settings take a moment to load (use `api.settings.loaded` promise)
- Some SEQTA elements load dynamically (use `api.seqta.onMount()`)
- Plugin cleanup is important (always return a cleanup function)
## Next Steps
Ready to contribute? Here's what to do next:
1. **Read the code**: Start with `src/SEQTA.ts` and follow the flow
2. **Try creating a simple plugin**: Follow the [plugin documentation](https://docs.betterseqta.org/plugins/)
3. **Look at existing issues**: Check our [GitHub issues](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues) for "good first issue" labels
4. **Join our Discord**: Get help from the community!
## Questions?
Still confused about something? That's totally normal! Here are your options:
- 💬 Ask in our [Discord server](https://discord.gg/YzmbnCDkat)
- 🐛 Open an issue on GitHub
- 📧 Email us at betterseqta.plus@gmail.com
Remember: **Every expert was once a beginner!** We're here to help you learn and contribute. 🚀
-133
View File
@@ -1,133 +0,0 @@
# BetterSEQTA Cloud — settings sync (server specification)
This document describes the HTTP API the BetterSEQTA+ extension expects for **cloud backup of extension settings**. The client is implemented in the extension repo; the accounts service (`accounts.betterseqta.org`) must implement these endpoints.
## Purpose
- Store **one JSON document per authenticated BetterSEQTA Cloud user** representing a snapshot of the extensions `chrome.storage.local` data (theme, layout, plugin settings, `plugin.*` keys, etc.).
- The extension **does not upload OAuth tokens** (`bsplus_token`, `bsplus_refresh_token`, `bsplus_client_id`, `bsplus_user`). Those remain only on the client.
- **Download** replaces local storage with the stored snapshot, then the client reapplies the current devices session tokens so the user stays signed in.
## Base URL
All routes below are relative to:
`https://accounts.betterseqta.org`
## Authentication
Every request must include:
```http
Authorization: Bearer <access_token>
```
Use the same **access tokens** issued by the existing BetterSEQTA+ OAuth flows (`/api/bsplus/login`, `/api/bsplus/refresh`). Resolve the user from the token; the document is scoped to that user.
## Endpoints
### `PUT /api/bsplus/settings/sync`
Upserts the callers settings backup.
**Request body (JSON):**
```json
{
"schemaVersion": 1,
"data": {
"...": "flat key-value map mirroring extension storage (see Payload shape)"
}
}
```
- **`schemaVersion`**: integer. The extension currently sends `1`. The server may reject unknown major versions or store it for future migrations.
- **`data`**: object whose keys are storage keys (strings) and values are JSON-serializable values (same types as stored in `chrome.storage.local`).
**Success response:** HTTP `200` (or `201` if you prefer create semantics). Example:
```json
{
"updated_at": "2026-04-07T12:00:00.000Z"
}
```
`updated_at` should be an ISO 8601 timestamp of the save time. The extension displays success without requiring extra fields.
**Error responses:** Standard JSON error body if applicable, e.g. `{ "error": "message" }`, with appropriate HTTP status (`401`, `413`, `422`, etc.).
---
### `GET /api/bsplus/settings/sync`
Returns the callers latest settings backup.
**Success response:** HTTP `200` with body:
```json
{
"schemaVersion": 1,
"data": { },
"updated_at": "2026-04-07T12:00:00.000Z"
}
```
- **`data`**: required for restore; must be the same shape as accepted in `PUT` (flat map of storage keys).
- **`schemaVersion`**: optional but recommended; should match what was stored.
- **`updated_at`**: optional; included for UX if the client shows “last backup” time.
**No backup yet:** HTTP **`404`**. The extension treats this as “nothing in the cloud” and shows an error to the user.
**Error responses:** `401` if the token is invalid, etc.
---
## Suggested database shape
Example relational layout:
| Column | Type | Notes |
|---------------|-------------|--------|
| `user_id` | FK → users | Unique per backup row (one row per user). |
| `payload` | JSON / JSONB| Store `{ "schemaVersion", "data" }` or only `data` + separate `schema_version` column. |
| `updated_at` | timestamptz | Set on each successful `PUT`. |
Unique constraint on `user_id`.
## Semantics
- **Last write wins:** each `PUT` replaces the stored backup for that user.
- **Optional later:** `If-Unmodified-Since` or a `revision` field for conflict detection (not required for v1).
## Security and privacy
- **Encryption at rest** for `payload` is recommended.
- Payload may contain **school-related UI preferences** and plugin data; treat as **user data** under your privacy policy.
- **Do not require or store** refresh/access tokens in the payload; the extension already strips them on upload.
### Never included in the sync payload (`chrome.storage.local` only)
The backup is a flat JSON map of **`chrome.storage.local`** keys. It does **not** include:
- **IndexedDB** — e.g. the Global Search index (`betterseqta-index` and related DBs) lives outside extension storage and is never serialized here.
- **OAuth / session keys** — `bsplus_token`, `bsplus_refresh_token`, `bsplus_client_id`, `bsplus_user`, plus legacy `cloudAccessToken` / `cloudUsername`.
- **Assessment Averages caches** — `plugin.assessments-average.storage.assessments`, `plugin.assessments-average.storage.weightings` (school assessment data).
- **Keys under** `plugin.global-search.storage.*` — reserved so any future plugin storage cache there is not synced.
- **`bsplus_cloud_settings_known_remote_updated_at`** — client-only watermark for auto-sync (not part of the cloud backup blob).
On restore, those keys are **not** taken from the server; the device keeps its current local values.
## Related endpoint: `GET /api/user/cloud-summary`
The extension may call **`GET /api/user/cloud-summary`** (same host, `Authorization: Bearer`) for a **small** JSON summary (e.g. whether DesQTA / BetterSEQTA+ cloud settings exist and **`bsplus.updated_at`** / **`schemaVersion`**). It does **not** return the large settings `data` blob.
- **Auto-sync flow:** compare `bsplus.updated_at` to a **client-only** watermark stored in extension storage as **`bsplus_cloud_settings_known_remote_updated_at`** (never uploaded, never applied from the server payload; preserved on restore).
- If the server timestamp is newer (and `schemaVersion` is not ahead of the client), the client then calls **`GET /api/bsplus/settings/sync`** and applies the full envelope as usual.
This uses standard **WebExtension** APIs (`browser.alarms`, `runtime` messages, `storage`) and works on **Chromium and Firefox** builds (see `webextension-polyfill`).
## Client reference (extension)
- Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, and **`bsplus_cloud_settings_known_remote_updated_at`**).
- Download: `applyDownloadedEnvelope` after `GET`; local auth keys, sensitive device keys, and the client-only watermark key are merged back after `chrome.storage.local.clear()`.
- Auto sync (summary, debounced upload, alarms): `src/background/cloudSettingsAutoSync.ts`; content script triggers a poll on each verified SEQTA Learn/Engage page load (top frame) via `cloudSettingsPoll`.
-287
View File
@@ -1,287 +0,0 @@
# Getting Started as a Contributor
**Published version:** [docs.betterseqta.org/contributing/](https://docs.betterseqta.org/contributing/)
Welcome to BetterSEQTA+! 🎉 This guide will walk you through making your first contribution, even if you're completely new to the project.
## Table of Contents
- [Before You Start](#before-you-start)
- [Your First 30 Minutes](#your-first-30-minutes)
- [Making Your First Contribution](#making-your-first-contribution)
- [Types of Contributions](#types-of-contributions)
- [Finding Something to Work On](#finding-something-to-work-on)
- [Development Workflow](#development-workflow)
- [Getting Help](#getting-help)
## Before You Start
### What You'll Need
- **Node.js** (v16 or higher) - [Download here](https://nodejs.org/)
- **Git** - [Download here](https://git-scm.com/)
- **A code editor** - We recommend [VS Code](https://code.visualstudio.com/)
- **A Chromium browser** (Chrome, Edge, Brave) for testing (recommended, however you can use firefox although it requires being built every time you make a change)
### Helpful Background (but not required!)
- Basic JavaScript/TypeScript knowledge
- Some familiarity with HTML/CSS
- Understanding of browser extensions (we'll teach you!)
**Don't worry if you're missing some of these!** We're happy to help you learn. 🤗
## Your First 30 Minutes
Let's get you up and running quickly:
### 1. Get the Code (3 minutes)
```bash
# Fork the repository on GitHub first, then:
git clone https://github.com/YOUR_USERNAME/BetterSEQTA-plus.git
cd BetterSEQTA-plus
```
### 2. Install Dependencies (3 minutes)
```bash
npm install --legacy-peer-deps
```
### 3. Start Development Server (2 minutes)
```bash
npm run dev
```
### 4. Load Extension in Browser (4 minutes)
1. Open Chrome and go to `chrome://extensions`
2. Enable "Developer mode" (toggle in top right)
3. Click "Load unpacked"
4. Select the `dist` folder in your project
5. Visit a SEQTA Learn page to see BetterSEQTA+ in action!
### 5. Make a Tiny Change (5 minutes)
Let's prove everything works:
1. Open `src/SEQTA.ts`
2. Find the line that says `"[BetterSEQTA+] Successfully initialised"`
3. Change it to `"[BetterSEQTA+] Successfully initialised - Hello [YOUR_NAME]!"`
4. Save the file
5. Go to `chrome://extensions`, click the refresh icon on BetterSEQTA+
6. Refresh a SEQTA page and check the browser console (F12) - you should see your message!
### 6. Reset Your Change (3 minutes)
```bash
git checkout -- src/SEQTA.ts
```
**Congratulations! 🎉 You've successfully set up BetterSEQTA+ for development!**
## Making Your First Contribution
### Easy First Contributions
Here are some great starter contributions:
1. **Fix a typo in documentation** - Super easy and always appreciated!
2. **Improve error messages** - Make them more helpful
3. **Add comments to code** - Help other contributors understand
4. **Create a simple plugin** - Follow our plugin guide
5. **Fix a bug you found** - If you found a bug, fix it!
### Step-by-Step: Your First Pull Request
#### Step 1: Pick an Issue
- Go to our [Issues page](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues)
- Look for labels like:
- `good first issue` - Perfect for beginners
- `help wanted` - We'd love help with these
- `documentation` - Improve our docs
- `bug` - Fix something broken
#### Step 2: Claim the Issue
Comment on the issue saying "I'd like to work on this!" We'll assign it to you.
#### Step 3: Create a Branch
```bash
git checkout -b fix-issue-123 # Replace 123 with the issue number
```
#### Step 4: Make Your Changes
- Follow the patterns you see in existing code
- Test your changes thoroughly
- Keep changes focused and small
#### Step 5: Test Everything
```bash
# Test the extension still loads
npm run dev
# Test in browser
# 1. Reload extension at chrome://extensions
# 2. Visit SEQTA page
# 3. Verify everything still works
```
#### Step 6: Commit Your Changes
```bash
git add .
git commit -m "Fix issue #123: Brief description of what you fixed"
```
#### Step 7: Push and Create Pull Request
```bash
git push origin fix-issue-123
```
Then go to GitHub and create a pull request with:
- **Clear title**: "Fix issue #123: Brief description"
- **Description**: Explain what you changed and why
- **Testing**: Describe how you tested it
## Types of Contributions
### 🐛 Bug Fixes
- Fix broken features
- Improve error handling
- Resolve compatibility issues
**Example**: "The theme selector doesn't work on Firefox"
### ✨ New Features
- Add new plugins
- Enhance existing functionality
- Improve user experience
**Example**: "Add keyboard shortcuts for common actions"
### 📚 Documentation
- Fix typos and unclear explanations
- Add examples and tutorials
- Improve code comments
**Example**: "Add more examples to the plugin guide"
### 🎨 Design & UI
- Improve the settings interface
- Make things more user-friendly
- Add animations and polish
**Example**: "Make the theme creator more intuitive"
### 🔧 Technical Improvements
- Refactor code for clarity
- Add tests
- Improve performance
**Example**: "Simplify the plugin loading logic"
## Finding Something to Work On
### Browse Issues by Label
- [`good first issue`](https://github.com/BetterSEQTA/BetterSEQTA-plus/labels/good%20first%20issue) - Perfect for beginners
- [`help wanted`](https://github.com/BetterSEQTA/BetterSEQTA-plus/labels/help%20wanted) - We need help with these
- [`documentation`](https://github.com/BetterSEQTA/BetterSEQTA-plus/labels/documentation) - Improve our docs
- [`bug`](https://github.com/BetterSEQTA/BetterSEQTA-plus/labels/bug) - Fix something broken
- [`enhancement`](https://github.com/BetterSEQTA/BetterSEQTA-plus/labels/enhancement) - Add new features
### Create Your Own Issue
Found a bug or have an idea? Create an issue first to discuss it!
### Plugin Ideas
Want to create a plugin? Here are some ideas:
- **Study Timer**: Track study time across SEQTA pages
- **Grade Tracker**: Better visualization of grades over time
- **Quick Notes**: Add notes to any SEQTA page
- **Homework Reminder**: Smart notifications for upcoming due dates
- **Custom Shortcuts**: User-defined keyboard shortcuts
## Development Workflow
### Daily Development
```bash
# Start working
git pull origin main
npm run dev
# Make changes, test, commit
git add .
git commit -m "Descriptive commit message"
# Push when ready
git push origin your-branch-name
```
### Before Submitting PR
1. **Test thoroughly** - Make sure nothing breaks
2. **Check console** - No new errors
3. **Test in different browsers** - Chrome and Firefox
4. **Update documentation** - If you changed how something works
### Code Style
- Use TypeScript where possible
- Follow existing naming conventions
- Add comments for complex logic
- Keep functions small and focused
## Getting Help
### Stuck? Here's How to Get Unstuck
1. **Check the docs** - The [architecture guide](https://docs.betterseqta.org/architecture/) explains everything
2. **Search existing issues** - Someone might have had the same problem
3. **Ask in Discord** - Our community is super helpful
4. **Create an issue** - If you found a bug or need help
### Discord Community
Join our [Discord server](https://discord.gg/YzmbnCDkat) for:
- Real-time help and discussion
- Collaboration on features
- Sharing ideas and feedback
- Getting to know the community
### Code Review Process
- All contributions need code review
- We'll provide helpful feedback
- Don't worry about making mistakes - we're here to help!
- Reviews usually happen within 24-48 hours
## Common Questions
**Q: I'm new to browser extensions. Is this too advanced for me?**
A: Not at all! We have lots of beginner-friendly issues, and our plugin system makes it easy to add features without understanding all the browser extension complexities.
**Q: How long does it take to get my first PR merged?**
A: For simple fixes, usually 1-3 days. For larger features, it might take a week or two as we discuss the best approach.
**Q: I made a mistake in my PR. What do I do?**
A: No worries! Just push more commits to the same branch and they'll be added to your PR automatically.
**Q: Can I work on multiple issues at once?**
A: It's better to focus on one issue at a time, especially when starting out. This makes code review easier and reduces conflicts.
**Q: What if I start working on something and get stuck?**
A: Ask for help! Create a draft PR with what you have so far, and we'll help you figure out the next steps.
## Recognition
All contributors get:
- Recognition in our README
- Contributor badge in Discord
- Our eternal gratitude! 🙏
Significant contributors may also get:
- Special Discord roles
- Input on project direction
- Maintainer status
## Next Steps
Ready to contribute? Here's what to do:
1.**Set up your development environment** (follow the 30-minute guide above)
2. 🔍 **Find an issue to work on** (check the "good first issue" label)
3. 💬 **Join our Discord** and introduce yourself
4. 🚀 **Make your first contribution** and submit a PR
Remember: **Every expert was once a beginner!** We're excited to help you learn and grow as a contributor. Welcome to the team! 🎉
---
*Questions? Suggestions for improving this guide? Open an issue or message us on Discord!*
+18 -23
View File
@@ -1,36 +1,25 @@
# BetterSEQTA+ Documentation
**Canonical documentation lives at [docs.betterseqta.org](https://docs.betterseqta.org/).** The pages below are the same topics; use the site for search, navigation, and the latest updates.
🚧 DOCS UNDER CONSTRUCTION! 🚧
Welcome to the BetterSEQTA+ documentation! This documentation will help you understand how BetterSEQTA+ works and how to extend it with plugins and new features.
## Table of Contents
### Getting Started
- [Project Overview](./README.md) - This file
- [Installation Guide](./installation.md) - How to install and set up BetterSEQTA+
- [Contributing Guide](../CONTRIBUTING.md) - How to contribute to BetterSEQTA+
- [Documentation home](https://docs.betterseqta.org/)
- [Installation](https://docs.betterseqta.org/install/)
- [Contributing](https://docs.betterseqta.org/contributing/)
- [Architecture](https://docs.betterseqta.org/architecture/)
- [Contribution guidelines (repository)](../CONTRIBUTING.md)
- [Troubleshooting](https://docs.betterseqta.org/troubleshooting/)
### Features & customization
- [Features](https://docs.betterseqta.org/features/)
- [Themes & customization](https://docs.betterseqta.org/customization/)
- [Theme creation](https://docs.betterseqta.org/theme-creation/)
### Plugin system
- [Plugins overview](https://docs.betterseqta.org/plugins/)
- [Plugin development](https://docs.betterseqta.org/plugin-development/)
- [Plugin API](https://docs.betterseqta.org/plugin-api/)
- [Example plugin](https://docs.betterseqta.org/example-plugin/)
### Plugin System
- [Creating Your First Plugin](./plugins/README.md) - A comprehensive, beginner-friendly guide to creating plugins
- [Plugin API Reference](./plugins/api-reference.md) - Detailed technical documentation of the plugin APIs
## Core Concepts
BetterSEQTA+ is built around several core concepts:
1. **Plugin System**: BetterSEQTA+ uses a plugin system to extend SEQTA with new features. Plugins are self-contained pieces of code that can be enabled or disabled by the user. See the [plugins documentation](https://docs.betterseqta.org/plugins/).
1. **Plugin System**: BetterSEQTA+ uses a plugin system to extend SEQTA with new features. Plugins are self-contained pieces of code that can be enabled or disabled by the user. Check out our [plugin guide](./plugins/README.md) to learn how to create your own!
2. **Type-Safe Settings**: Each plugin can define settings that are type-safe and automatically rendered in the settings UI. The settings system uses TypeScript decorators to make it easy to define settings with proper typing.
@@ -42,13 +31,19 @@ BetterSEQTA+ is built around several core concepts:
If you need help with BetterSEQTA+, you can:
- [Open an Issue](https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues) - Report bugs or request features
- [Open an Issue](https://github.com/SeqtaLearning/betterseqta-plus/issues) - Report bugs or request features
- [Join the Discord](https://discord.gg/YzmbnCDkat) - Chat with the community
- [Email the Maintainers](mailto:betterseqta.plus@gmail.com) - Contact the maintainers directly
## Contributing to the Documentation
We welcome contributions to the documentation. The published site is built from the documentation repository; see [Contributing](https://docs.betterseqta.org/contributing/) for how to help.
We welcome contributions to the documentation! If you find something unclear or missing, please open an issue or submit a pull request.
To contribute to the documentation:
1. Fork the repository
2. Make your changes to the documentation files
3. Submit a pull request with a clear description of your changes
## License
-587
View File
@@ -1,587 +0,0 @@
# Theme Creation Guide
**Published version:** [docs.betterseqta.org/theme-creation/](https://docs.betterseqta.org/theme-creation/)
This guide covers everything you need to know about creating custom themes for BetterSEQTA+.
## Table of Contents
1. [Overview](#overview)
2. [Theme Structure](#theme-structure)
3. [CSS Variables](#css-variables)
4. [CSS Selectors & Classes](#css-selectors--classes)
5. [Custom Images](#custom-images)
6. [Theme Settings](#theme-settings)
7. [Best Practices](#best-practices)
8. [Examples](#examples)
## Overview
Themes in BetterSEQTA+ allow you to completely customize the appearance of SEQTA Learn. A theme consists of:
- **Custom CSS**: CSS rules that override default styles
- **Custom Images**: Images that can be referenced via CSS variables
- **Theme Metadata**: Name, description, default color, etc.
- **Theme Settings**: Options like forcing dark/light mode
Themes are applied by injecting CSS into the SEQTA page and setting CSS custom properties (variables) on the document root.
## CSS Variables
BetterSEQTA+ provides a comprehensive set of CSS variables that you can use in your themes. These variables automatically adapt to light/dark mode and user preferences.
### Core Background Variables
| Variable | Light Mode | Dark Mode | Description |
|----------|------------|-----------|-------------|
| `--background-primary` | `#ffffff` | `#232323` | Main background color |
| `--background-secondary` | `#e5e7eb` | `#1a1a1a` | Secondary background color |
| `--theme-primary` | `#ffffff` | `#232323` | Primary theme color (same as background-primary) |
| `--theme-secondary` | `#e5e7eb` | `#1a1a1a` | Secondary theme color (same as background-secondary) |
| `--text-primary` | `black` | `white` | Primary text color |
| `--text-color` | `black` | `white` | Text color (alias for text-primary) |
### BetterSEQTA+ Specific Variables
| Variable | Description | Notes |
|----------|-------------|-------|
| `--better-main` | User's selected accent color | Dynamically set based on color picker |
| `--better-sub` | Dark navy color | Always `#161616` |
| `--better-pale` | Lightened version of accent color | Only available in light mode |
| `--better-light` | Lighter version of accent color | Calculated based on brightness |
| `--better-alert-highlight` | Alert/highlight color | `#c61851` |
| `--betterseqta-logo` | Logo URL | Changes based on dark/light mode |
| `--auto-background` | Auto background color | Falls back to `--better-pale` or `--background-secondary` |
| `--navy` | Navy color | `#1a1a1a` |
| `--theme-fg-parts` | Theme foreground parts | `white` |
### Subject/Item Color Variables
| Variable | Description |
|----------|-------------|
| `--item-colour` | Subject/item color | Set dynamically per subject/item |
| `--colour` | Generic color variable | Used in various contexts |
| `--person-colour` | Person/avatar color | `var(--better-light)` for staff |
### Transparency Effects
When transparency effects are enabled, background variables become semi-transparent:
| Variable | Light Mode (Transparent) | Dark Mode (Transparent) |
|----------|--------------------------|-------------------------|
| `--background-primary` | `rgba(255, 255, 255, 0.6)` | `rgba(35, 35, 35, 0.6)` |
| `--background-secondary` | `rgba(229, 231, 235, 0.6)` | `rgba(26, 26, 26, 0.6)` |
### Using CSS Variables
You can use these variables in your custom CSS:
```css
/* Example: Style a custom element */
.my-custom-element {
background: var(--background-primary);
color: var(--text-primary);
border: 1px solid var(--better-main);
}
/* Example: Create a gradient */
.gradient-box {
background: linear-gradient(
to bottom,
var(--better-main),
var(--background-secondary)
);
}
```
## CSS Selectors & Classes
BetterSEQTA+ uses specific CSS selectors and classes that you can target in your themes. Here are the most important ones:
### Main Layout Elements
| Selector | Description |
|----------|-------------|
| `#container` | Main container element |
| `#content` | Content area |
| `#main` | Main content wrapper |
| `#title` | Top title bar |
| `#menu` | Sidebar menu |
### Dark Mode
The `dark` class is added to `html` when dark mode is active:
```css
/* Target dark mode specifically */
html.dark #main {
background: var(--background-primary);
}
/* Target light mode */
html:not(.dark) #main {
background: var(--background-primary);
}
```
### Transparency Effects
When transparency effects are enabled, the `transparencyEffects` class is added to `html`:
```css
html.transparencyEffects .notice {
backdrop-filter: blur(80px);
}
```
### Common SEQTA Classes
| Class/Selector | Description |
|----------------|-------------|
| `.notice` | Notice cards |
| `.day` | Day containers in timetable |
| `.dashboard` | Dashboard sections |
| `.dashlet` | Dashboard widgets |
| `.document` | Document elements |
| `.quickbar` | Quick action bar |
| `.calendar` | Calendar elements |
| `.message` | Message elements |
| `.thread` | Forum threads |
| `.shortcut` | Shortcut buttons |
| `.upcoming-assessment` | Upcoming assessments |
| `.entry.class` | Timetable entries |
### BetterSEQTA+ Specific Classes
| Class | Description |
|-------|-------------|
| `.addedButton` | BetterSEQTA+ added buttons |
| `.tooltip` | Tooltip elements |
| `.notice-unified-content` | Unified notice content |
| `.home-container` | Home page container |
| `.timetable-container` | Timetable container |
| `.notices-container` | Notices container |
### Attribute Selectors
SEQTA uses data attributes that you can target:
```css
/* Target specific data types */
[data-type="student"] .header {
color: var(--text-primary);
}
/* Target specific labels */
[data-label="inbox"] {
/* Styles */
}
```
### CSS Modules
SEQTA uses CSS modules with hashed class names. You can target them using attribute selectors:
```css
/* Target CSS module classes */
[class*="MessageList__MessageList___"] {
background: var(--background-primary);
}
[class*="BasicPanel__BasicPanel___"] {
border-radius: 16px;
}
```
## Custom Images
Themes can include custom images that are made available as CSS variables.
### Adding Images
1. Upload an image in the theme creator
2. Set a CSS variable name (e.g., `custom-background`)
3. The image will be available as `var(--custom-background)`
### Using Image Variables
```css
/* Use as background */
.my-element {
background-image: var(--custom-background);
background-size: cover;
background-position: center;
}
/* Use in content */
.my-icon::before {
content: '';
background-image: var(--custom-icon);
width: 24px;
height: 24px;
}
```
### Image Variable Format
Images are stored as `url()` values:
```css
/* The variable contains: url(blob:...) */
--custom-background: url(blob:chrome-extension://...);
```
## Theme Settings
### Force Dark/Light Mode
You can force a theme to always use dark or light mode:
```typescript
forceDark: true // Force dark mode
forceDark: false // Force light mode
forceDark: undefined // Use user's preference (default)
```
When `forceDark` is set, users cannot toggle dark/light mode while the theme is active.
### Default Color
Set a default accent color for your theme:
```typescript
defaultColour: "rgba(0, 123, 255, 1)" // Blue
defaultColour: "#ff6b6b" // Red (hex format)
```
### Allow Color Changes
Control whether users can change the accent color:
```typescript
CanChangeColour: true // Users can change color
CanChangeColour: false // Color is locked
```
## Best Practices
### 1. Use CSS Variables
Always use CSS variables instead of hardcoded colors:
```css
/* Good */
.my-element {
background: var(--background-primary);
color: var(--text-primary);
}
/* Bad */
.my-element {
background: #ffffff;
color: #000000;
}
```
### 2. Support Both Light and Dark Modes
Unless your theme forces a specific mode, ensure it works in both:
```css
/* Use variables that adapt automatically */
.my-element {
background: var(--background-primary);
color: var(--text-primary);
}
/* Or explicitly handle both modes */
html.dark .my-element {
background: #1a1a1a;
}
html:not(.dark) .my-element {
background: #ffffff;
}
```
### 3. Use !important Sparingly
Only use `!important` when necessary to override SEQTA's default styles:
```css
/* Good - necessary override */
#title {
background: var(--background-primary) !important;
}
/* Bad - unnecessary */
.my-element {
color: var(--text-primary) !important;
}
```
### 4. Test Responsive Design
SEQTA is responsive. Test your theme at different screen sizes:
```css
/* Example: Mobile-specific styles */
@media (max-width: 900px) {
#menu {
transform: translate(-270px);
}
}
```
### 5. Use Semantic Selectors
Prefer semantic selectors over fragile ones:
```css
/* Good - stable selector */
#main > .dashboard > section {
border-radius: 16px;
}
/* Caution - CSS module classes may change */
[class*="Dashboard__Dashboard___"] {
border-radius: 16px;
}
```
### 6. Optimize Images
Keep image file sizes reasonable:
- Use appropriate formats (PNG for transparency, JPG for photos)
- Compress images before uploading
- Consider using CSS for simple graphics instead of images
### 7. Document Your Theme
Include comments in your CSS explaining complex styles:
```css
/*
* Custom gradient background for dashboard
* Uses the user's accent color for a cohesive look
*/
#main > .dashboard {
background: linear-gradient(
135deg,
var(--better-main),
var(--background-secondary)
);
}
```
## Examples
### Example 1: Simple Color Theme
```css
/* Change accent color throughout */
:root {
--better-main: #ff6b6b;
}
/* Style the menu */
#menu {
background: var(--background-primary);
border-right: 3px solid var(--better-main);
}
/* Style buttons */
.uiButton {
background: var(--better-main);
color: var(--text-color);
border-radius: 8px;
}
```
### Example 2: Custom Background Image
```css
/* Use a custom background image */
body {
background-image: var(--custom-background);
background-size: cover;
background-attachment: fixed;
}
/* Add overlay for readability */
#main::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: -1;
}
```
### Example 3: Rounded Corners Theme
```css
/* Make everything more rounded */
#main > .dashboard > section,
.dashlet,
.notice,
.document {
border-radius: 20px !important;
}
/* Round buttons */
.uiButton {
border-radius: 25px !important;
}
```
### Example 4: Minimal Theme
```css
/* Remove shadows and borders */
#main > .dashboard > section,
.dashlet,
.notice {
box-shadow: none !important;
border: 1px solid var(--background-secondary) !important;
}
/* Simplify colors */
#menu {
background: var(--background-primary) !important;
}
/* Remove gradients */
.day {
background: var(--background-primary) !important;
}
```
### Example 5: High Contrast Theme
```css
/* Increase contrast */
:root {
--background-primary: #000000;
--background-secondary: #1a1a1a;
--text-primary: #ffffff;
}
html:not(.dark) {
--background-primary: #ffffff;
--background-secondary: #f0f0f0;
--text-primary: #000000;
}
/* Add borders for clarity */
.dashlet,
.notice,
.document {
border: 2px solid var(--better-main) !important;
}
```
## Advanced Techniques
### CSS Custom Properties Override
You can override CSS variables in your theme:
```css
/* Override a variable */
:root {
--better-main: #your-color;
}
/* Override conditionally */
html.dark {
--background-primary: #your-dark-color;
}
```
### Animations
Add smooth transitions:
```css
/* Smooth color transitions */
#menu li {
transition: background-color 0.3s ease;
}
/* Hover effects */
.dashlet:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
}
```
### Pseudo-elements
Use pseudo-elements for decorative elements:
```css
/* Add decorative border */
.notice::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: var(--better-main);
}
```
## Troubleshooting
### Theme Not Applying
1. Check browser console for CSS errors
2. Verify CSS syntax is correct
3. Ensure selectors are specific enough
4. Check if `!important` is needed
### Colors Not Changing
1. Verify you're using CSS variables
2. Check if `forceDark` is overriding your styles
3. Ensure variables are set on `:root` or `html`
### Images Not Showing
1. Verify image variable name matches CSS
2. Check image format is supported
3. Ensure image size is reasonable
4. Verify `url()` wrapper in CSS
### Dark Mode Issues
1. Test with `forceDark: true` and `forceDark: false`
2. Check if transparency effects are interfering
3. Verify `html.dark` selector is correct
## Resources
- **Theme Creator**: Access via BetterSEQTA+ settings
- **CSS Variables Reference**: See [CSS Variables](#css-variables) section above
- **SEQTA DOM Structure**: Inspect SEQTA pages in browser DevTools
- **BetterSEQTA+ Source**: Check `src/css/injected.scss` for default styles
## Contributing Themes
If you create a great theme, consider sharing it:
1. Export your theme (Share button in theme creator)
2. Submit to the BetterSEQTA+ theme store
3. Or share on GitHub/Discord
---
**Note**: This documentation is based on BetterSEQTA+ v3.4.13. Some details may change in future versions.
-350
View File
@@ -1,350 +0,0 @@
# Troubleshooting Guide
**Published version:** [docs.betterseqta.org/troubleshooting/](https://docs.betterseqta.org/troubleshooting/)
Having issues with BetterSEQTA+ development? This guide covers the most common problems and their solutions.
## Table of Contents
- [Installation Issues](#installation-issues)
- [Development Server Issues](#development-server-issues)
- [Browser Extension Issues](#browser-extension-issues)
- [Plugin Development Issues](#plugin-development-issues)
- [Build Issues](#build-issues)
- [Still Stuck?](#still-stuck)
## Installation Issues
### ❌ "npm install" fails with peer dependency errors
**Problem**: You see errors about peer dependencies or conflicting packages.
**Solution**:
```bash
rm -rf node_modules package-lock.json
npm install --legacy-peer-deps
```
### ❌ "Cannot find module" errors
**Problem**: Node.js can't find required packages.
**Solutions**:
1. **Clear and reinstall**:
```bash
rm -rf node_modules
npm install --legacy-peer-deps
```
2. **Check Node.js version**:
```bash
node --version # Should be v16 or higher
```
3. **Try with npm cache clean**:
```bash
npm cache clean --force
npm install --legacy-peer-deps
```
### ❌ Permission errors on macOS/Linux
**Problem**: "EACCES" or permission denied errors.
**Solution**:
```bash
sudo chown -R $(whoami) ~/.npm
sudo chown -R $(whoami) /usr/local/lib/node_modules
```
## Development Server Issues
### ❌ "npm run dev" fails
**Problem**: Development server won't start.
**Solutions**:
1. **Check if port is in use**:
```bash
lsof -i :5173 # Kill the process using the port
```
2. **Clear dist folder**:
```bash
rm -rf dist
npm run dev
```
3. **Check for TypeScript errors**:
```bash
npx tsc --noEmit # Check for type errors
```
### ❌ Changes not reflecting in browser
**Problem**: You make code changes but don't see them in the browser.
**Solutions**:
1. **Reload the extension**:
- Go to `chrome://extensions`
- Find BetterSEQTA+ and click the refresh icon
- Refresh your SEQTA page
2. **Check if dev server is running**:
- Look for "Build completed" in your terminal
- If not, restart `npm run dev`
3. **Hard refresh the page**:
- Press `Ctrl+Shift+R` (or `Cmd+Shift+R` on Mac)
## Browser Extension Issues
### ❌ Extension doesn't load in Chrome
**Problem**: Extension appears in `chrome://extensions` but doesn't work.
**Solutions**:
1. **Check for errors**:
- Go to `chrome://extensions`
- Click "Errors" button on BetterSEQTA+
- Fix any JavaScript errors shown
2. **Verify manifest**:
- Check if `dist/manifest.json` exists
- Ensure it has proper structure
3. **Check permissions**:
- Extension needs permission to access SEQTA pages
- Click "Details" → "Site access" → "On all sites"
### ❌ Extension doesn't appear on SEQTA pages
**Problem**: Extension loads but doesn't modify SEQTA.
**Solutions**:
1. **Check if you're on a SEQTA Learn page**:
- URL should contain "seqta" or "learn"
- Page title should include "SEQTA Learn"
2. **Check browser console**:
- Press `F12` → Console tab
- Look for "[BetterSEQTA+]" messages
- If no messages, extension isn't running
3. **Verify page detection**:
- Extension only runs on actual SEQTA Learn pages
- Test on a real SEQTA instance
### ❌ Settings page won't open
**Problem**: Clicking the extension icon doesn't open settings.
**Solutions**:
1. **Check popup errors**:
- Right-click extension icon → "Inspect popup"
- Look for JavaScript errors
2. **Clear extension storage**:
```javascript
// In browser console on any page:
chrome.storage.local.clear()
```
3. **Reload extension and try again**
## Plugin Development Issues
### ❌ My plugin doesn't appear in settings
**Problem**: Created a plugin but it's not showing up.
**Solutions**:
1. **Check plugin registration**:
- Ensure your plugin is imported in `src/plugins/index.ts`
- Verify `pluginManager.registerPlugin(yourPlugin)` is called
2. **Check plugin structure**:
```typescript
// Ensure your plugin has all required fields
const myPlugin: Plugin = {
id: "unique-id", // Must be unique
name: "Display Name",
description: "What it does",
version: "1.0.0",
run: async (api) => {
// Your code here
}
};
```
3. **Check for errors**:
- Look in browser console for plugin loading errors
### ❌ Plugin settings not working
**Problem**: Plugin settings don't save or load properly.
**Solutions**:
1. **Check settings definition**:
```typescript
import { defineSettings, booleanSetting } from "@/plugins/core/settingsHelpers";
const settings = defineSettings({
myOption: booleanSetting({
default: true,
title: "My Option",
description: "What this does"
})
});
```
2. **Wait for settings to load**:
```typescript
run: async (api) => {
await api.settings.loaded; // Wait for settings to load
console.log(api.settings.myOption); // Now you can use settings
}
```
### ❌ Plugin API functions not working
**Problem**: `api.seqta.onMount()` or other API functions don't work.
**Solutions**:
1. **Check selector specificity**:
```typescript
// Be specific with selectors
api.seqta.onMount(".home-page", (element) => {
// Your code
});
```
2. **Wait for elements**:
```typescript
// Some elements load after page navigation
api.seqta.onPageChange((page) => {
if (page === "home") {
api.seqta.onMount(".home-content", (element) => {
// Now element should exist
});
}
});
```
## Build Issues
### ❌ "npm run build" fails
**Problem**: Production build fails with errors.
**Solutions**:
1. **Check TypeScript errors**:
```bash
npx tsc --noEmit
```
2. **Clear cache and rebuild**:
```bash
rm -rf dist node_modules
npm install --legacy-peer-deps
npm run build
```
3. **Check for import errors**:
- Ensure all imports use correct paths
- Check for missing files
### ❌ Built extension doesn't work
**Problem**: `npm run build` succeeds but extension doesn't work.
**Solutions**:
1. **Test the built extension**:
- Load the `dist` folder as unpacked extension
- Check console for errors
2. **Compare with dev version**:
- If dev works but build doesn't, there might be a build configuration issue
3. **Check manifest generation**:
- Verify `dist/manifest.json` looks correct
- Compare with working version
## Common Error Messages
### "Cannot access contents of the URL"
- **Cause**: Extension permissions issue
- **Fix**: Go to `chrome://extensions` → BetterSEQTA+ → Details → Site access → "On all sites"
### "Extension context invalidated"
- **Cause**: Extension was reloaded while page was open
- **Fix**: Refresh the SEQTA page
### "Uncaught ReferenceError: browser is not defined"
- **Cause**: Missing webextension-polyfill import
- **Fix**: Add `import browser from "webextension-polyfill";` at top of file
### "Module not found: Can't resolve '@/...' "
- **Cause**: TypeScript path mapping issue
- **Fix**: Check `tsconfig.json` and `vite.config.ts` for path configuration
## Performance Issues
### Extension makes SEQTA slow
1. **Check for memory leaks**:
- Use Chrome DevTools → Performance tab
- Look for growing memory usage
2. **Optimize plugin code**:
- Remove unnecessary listeners
- Clean up intervals/timeouts
- Use efficient selectors
3. **Profile your changes**:
- Test with extension disabled vs enabled
- Identify which plugin is causing issues
## Still Stuck?
If none of these solutions work:
1. **🔍 Search existing issues**: [GitHub Issues](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues)
2. **💬 Ask on Discord**: [Join our server](https://discord.gg/YzmbnCDkat) - fastest way to get help!
3. **📝 Create a new issue**: Include:
- Your operating system
- Node.js version (`node --version`)
- Browser version
- Exact error message
- Steps to reproduce
- What you've already tried
4. **📧 Email us**: betterseqta.plus@gmail.com for urgent issues
## Getting More Debug Info
### Enable verbose logging
Add this to your plugin's `run` function:
```typescript
console.log("[DEBUG] Plugin starting:", api);
```
### Check extension background page
1. Go to `chrome://extensions`
2. Click "Details" on BetterSEQTA+
3. Click "Inspect views: background page"
4. Check console for background script errors
### Export debug info
Run this in browser console on a SEQTA page:
```javascript
console.log("Extension info:", {
version: chrome.runtime.getManifest().version,
url: window.location.href,
userAgent: navigator.userAgent,
storage: await chrome.storage.local.get()
});
```
Remember: **Don't give up!** Every developer faces these issues. The community is here to help, and solving these problems makes you a better developer. 💪
+3 -11
View File
@@ -1,7 +1,5 @@
# Contributing to BetterSEQTA+
**Published version:** [docs.betterseqta.org/contributing/](https://docs.betterseqta.org/contributing/)
Thank you for your interest in contributing to BetterSEQTA+! This document provides guidelines and instructions for contributing to the project.
## Table of Contents
@@ -24,7 +22,6 @@ Thank you for your interest in contributing to BetterSEQTA+! This document provi
BetterSEQTA+ is committed to providing a welcoming and inclusive environment for all contributors. We expect all participants to adhere to our Code of Conduct, which promotes respectful and harassment-free interaction.
Key points:
- Be respectful and inclusive
- Focus on what is best for the community
- Show empathy towards other community members
@@ -59,7 +56,7 @@ Key points:
5. **Install in Chrome/Firefox**
Follow the [installation instructions](https://docs.betterseqta.org/install/) to load the development version into your browser.
Follow the [installation instructions](./installation.md#development-installation) to load the development version into your browser.
### Project Structure
@@ -108,7 +105,6 @@ git checkout -b feature/my-new-feature
2. **Write Clear Commit Messages**
Follow the conventional commits format:
```
feat: add new feature
fix: resolve bug with timetable
@@ -122,7 +118,6 @@ git checkout -b feature/my-new-feature
4. **Run Tests**
Make sure all tests pass before submitting your PR:
```bash
npm test
```
@@ -162,7 +157,6 @@ We follow TypeScript best practices and have a consistent code style:
5. **Use Linters**
We use ESLint and Prettier. Run them before submitting your PR:
```bash
npm run lint
npm run format
@@ -179,7 +173,6 @@ If you find a bug, please report it by creating an issue on GitHub:
2. **Use the Bug Report Template**
Fill in all sections of the bug report template:
- Description
- Steps to reproduce
- Expected behavior
@@ -202,7 +195,6 @@ We welcome feature suggestions! To suggest a new feature:
2. **Use the Feature Request Template**
Fill in all sections of the feature request template:
- Description
- Use case
- Potential implementation
@@ -248,8 +240,8 @@ Join our community channels to discuss the project, get help, and connect with o
If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides:
- [Plugin development](https://docs.betterseqta.org/plugin-development/)
- [Plugin API](https://docs.betterseqta.org/plugin-api/)
- [Creating Your First Plugin](./plugins/creating-plugins.md)
- [Plugin API Reference](./advanced/plugin-api.md)
## Recognition
+2 -6
View File
@@ -1,7 +1,5 @@
# Installing BetterSEQTA+
**Published version:** [docs.betterseqta.org/install/](https://docs.betterseqta.org/install/)
This guide will walk you through the process of installing and setting up BetterSEQTA+ for development or usage.
## Prerequisites
@@ -134,7 +132,6 @@ bun install
#### Extension not appearing in SEQTA
Make sure:
- You're visiting a SEQTA Learn page
- The extension is enabled
- You've refreshed the page after installing the extension
@@ -142,7 +139,6 @@ Make sure:
#### Development build not updating
Try:
1. Stopping the development server
2. Clearing your browser cache
3. Removing the extension from your browser
@@ -180,5 +176,5 @@ bun run dev
Now that you have BetterSEQTA+ installed, you can:
- [Plugins](https://docs.betterseqta.org/plugins/)
- [Contribute to the project](https://docs.betterseqta.org/contributing/) · [Repository CONTRIBUTING.md](../CONTRIBUTING.md)
- [Getting Started with Plugins](./plugins/getting-started.md)
- [Contribute to the project](../CONTRIBUTING.md)
-337
View File
@@ -1,337 +0,0 @@
# Example Plugin Template
**Published version:** [docs.betterseqta.org/example-plugin/](https://docs.betterseqta.org/example-plugin/)
This is a complete, working example of a simple BetterSEQTA+ plugin. You can copy this code and modify it to create your own plugin!
## What This Example Does
This plugin adds a friendly welcome message to the SEQTA homepage and lets users customize the message through settings.
## Complete Plugin Code
Create a new file in `src/plugins/built-in/my-first-plugin/index.ts`:
```typescript
import type { Plugin } from "@/plugins/core/types";
import { BasePlugin } from "@/plugins/core/settings";
import {
defineSettings,
booleanSetting,
stringSetting
} from "@/plugins/core/settingsHelpers";
import { Setting } from "@/plugins/core/settingsHelpers";
// Define the plugin settings
const settings = defineSettings({
enabled: booleanSetting({
default: true,
title: "Show Welcome Message",
description: "Display a welcome message on the SEQTA homepage"
}),
customMessage: stringSetting({
default: "Welcome to SEQTA! 🎉",
title: "Custom Message",
description: "The message to display on the homepage",
maxLength: 100
}),
showEmoji: booleanSetting({
default: true,
title: "Show Emoji",
description: "Include emojis in the welcome message"
})
});
// Create settings class
class MyFirstPluginSettings extends BasePlugin<typeof settings> {
@Setting(settings.enabled)
enabled!: boolean;
@Setting(settings.customMessage)
customMessage!: string;
@Setting(settings.showEmoji)
showEmoji!: boolean;
}
// Create settings instance
const settingsInstance = new MyFirstPluginSettings();
// Define the plugin
const myFirstPlugin: Plugin<typeof settings> = {
id: "my-first-plugin",
name: "My First Plugin",
description: "Adds a customizable welcome message to the SEQTA homepage",
version: "1.0.0",
// Link our settings
settings: settingsInstance.settings,
// Mark as beta (optional)
beta: true,
// Add some CSS styles (optional)
styles: `
.my-plugin-welcome {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 12px;
margin: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
text-align: center;
font-size: 18px;
animation: slideIn 0.5s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.my-plugin-welcome .close-btn {
float: right;
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
padding: 5px 10px;
border-radius: 20px;
cursor: pointer;
font-size: 12px;
}
.my-plugin-welcome .close-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
`,
// Main plugin function
run: async (api) => {
console.log("[My First Plugin] Starting up! 🚀");
// Wait for settings to load
await api.settings.loaded;
let welcomeElement: HTMLElement | null = null;
// Function to create the welcome message
const createWelcomeMessage = () => {
// Only show if enabled in settings
if (!api.settings.enabled) {
return;
}
// Remove existing message if it exists
if (welcomeElement) {
welcomeElement.remove();
}
// Create the message element
welcomeElement = document.createElement("div");
welcomeElement.className = "my-plugin-welcome";
// Build the message content
let message = api.settings.customMessage;
if (!api.settings.showEmoji) {
// Remove emojis if disabled
message = message.replace(/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, '');
}
welcomeElement.innerHTML = `
<button class="close-btn" onclick="this.parentElement.remove()">×</button>
<div>${message}</div>
<small style="opacity: 0.8; margin-top: 10px; display: block;">
Powered by My First Plugin
</small>
`;
return welcomeElement;
};
// Function to add message to homepage
const addToHomepage = () => {
api.seqta.onMount(".home-page, .dashboard, [class*='home']", (homePage) => {
console.log("[My First Plugin] Found homepage, adding welcome message");
const message = createWelcomeMessage();
if (message) {
// Add to the top of the homepage
homePage.insertBefore(message, homePage.firstChild);
}
});
};
// Add message when plugin starts
addToHomepage();
// Re-add message when user navigates to homepage
api.seqta.onPageChange((page) => {
console.log("[My First Plugin] Page changed to:", page);
if (page.includes("home") || page.includes("dashboard")) {
// Small delay to let the page load
setTimeout(addToHomepage, 500);
}
});
// Listen for settings changes and update the message
api.settings.onChange("enabled", (enabled) => {
console.log("[My First Plugin] Enabled setting changed:", enabled);
if (enabled) {
addToHomepage();
} else if (welcomeElement) {
welcomeElement.remove();
welcomeElement = null;
}
});
api.settings.onChange("customMessage", (newMessage) => {
console.log("[My First Plugin] Message changed:", newMessage);
if (welcomeElement && api.settings.enabled) {
// Update existing message
addToHomepage();
}
});
api.settings.onChange("showEmoji", (showEmoji) => {
console.log("[My First Plugin] Show emoji changed:", showEmoji);
if (welcomeElement && api.settings.enabled) {
// Update existing message
addToHomepage();
}
});
// Return cleanup function (called when plugin is disabled)
return () => {
console.log("[My First Plugin] Cleaning up...");
if (welcomeElement) {
welcomeElement.remove();
welcomeElement = null;
}
};
}
};
export default myFirstPlugin;
```
## How to Use This Example
### Step 1: Create the Plugin File
1. Create a new folder: `src/plugins/built-in/my-first-plugin/`
2. Create `index.ts` in that folder
3. Copy the code above into `index.ts`
### Step 2: Register the Plugin
Add this to `src/plugins/index.ts`:
```typescript
// Add this import at the top
import myFirstPlugin from "./built-in/my-first-plugin";
// Add this line where other plugins are registered
pluginManager.registerPlugin(myFirstPlugin);
```
### Step 3: Test It
1. Run `npm run dev`
2. Reload your extension in Chrome
3. Visit a SEQTA page
4. You should see your welcome message!
5. Open BetterSEQTA+ settings to customize it
## Key Concepts Explained
### 1. Plugin Structure
```typescript
const myPlugin: Plugin = {
id: "unique-id", // Must be unique across all plugins
name: "Display Name", // Shown in settings
description: "What it does", // Shown in settings
version: "1.0.0", // Plugin version
settings: settingsObject, // User-configurable options
styles: "/* CSS here */", // Optional CSS styles
run: async (api) => { // Main plugin code
// Your code here
}
};
```
### 2. Settings System
```typescript
// Define what settings your plugin has
const settings = defineSettings({
myOption: booleanSetting({
default: true,
title: "My Option",
description: "What this option does"
})
});
// Use in your plugin
if (api.settings.myOption) {
// Do something
}
```
### 3. SEQTA Integration
```typescript
// Wait for elements to appear
api.seqta.onMount(".some-selector", (element) => {
// Modify the element
});
// Detect page changes
api.seqta.onPageChange((page) => {
if (page === "home") {
// User navigated to homepage
}
});
```
### 4. Cleanup
Always return a cleanup function to remove your changes when the plugin is disabled:
```typescript
run: async (api) => {
// Add your features
return () => {
// Remove your features
};
}
```
## Customization Ideas
Want to modify this example? Here are some ideas:
1. **Change the styling**: Modify the CSS to use different colors, animations, or layouts
2. **Add more settings**: Number settings, select dropdowns, hotkeys
3. **Different trigger**: Show on different pages, or based on time of day
4. **Add interactions**: Buttons that do things when clicked
5. **Store data**: Use `api.storage` to remember user preferences
6. **Communicate with other plugins**: Use `api.events` to send/receive events
## Next Steps
Once you've got this working:
1. **Experiment**: Try changing things and see what happens
2. **Read other plugins**: Look at the built-in plugins for inspiration
3. **Check the API docs**: Learn about all available API functions
4. **Share your creation**: Show it off in Discord!
## Need Help?
- 💬 Ask in our [Discord server](https://discord.gg/YzmbnCDkat)
- 📚 Read the [plugin documentation](https://docs.betterseqta.org/plugins/)
- 🐛 Check the [troubleshooting guide](https://docs.betterseqta.org/troubleshooting/)
- 📝 Open an issue on GitHub
Happy coding! 🎉
+36 -78
View File
@@ -1,13 +1,10 @@
# Creating Plugins for BetterSEQTA+
**Published version:** [docs.betterseqta.org/plugins/](https://docs.betterseqta.org/plugins/) · [Plugin development](https://docs.betterseqta.org/plugin-development/) · [Plugin API](https://docs.betterseqta.org/plugin-api/)
Hey there! 👋 So you want to create a plugin for BetterSEQTA+? That's awesome! This guide will walk you through everything you need to know, from the very basics to more advanced features. Don't worry if you're new to this - we'll explain everything step by step.
## What is a Plugin?
In BetterSEQTA+, a plugin is like a mini-app that adds new features to SEQTA. Think of it as a piece of LEGO that you can snap onto SEQTA to make it do new things. For example, you could create a plugin that:
- Changes how SEQTA looks
- Adds new buttons or features
- Shows extra information on your timetable
@@ -19,32 +16,29 @@ In BetterSEQTA+, a plugin is like a mini-app that adds new features to SEQTA. Th
Let's create a super simple plugin together. We'll make one that adds a friendly message to the SEQTA homepage. Here's what we'll need:
```typescript
import type { Plugin } from "@/plugins/core/types";
import type { Plugin } from '@/plugins/core/types';
const myFirstPlugin: Plugin = {
// Every plugin needs these basic details
id: "my-first-plugin",
name: "My First Plugin",
description: "Adds a friendly message to SEQTA",
version: "1.0.0",
id: 'my-first-plugin',
name: 'My First Plugin',
description: 'Adds a friendly message to SEQTA',
version: '1.0.0',
// This tells BetterSEQTA+ that users can turn our plugin on/off
disableToggle: true,
// Optional: Mark your plugin as beta to show a "Beta" tag in settings
beta: true,
// This is where the magic happens!
run: async (api) => {
// Wait for the homepage to load
api.seqta.onMount(".home-page", (homePage) => {
api.seqta.onMount('.home-page', (homePage) => {
// Create our message
const message = document.createElement("div");
message.textContent = "Hello from my first plugin! 🎉";
message.style.padding = "20px";
message.style.backgroundColor = "#e9f5ff";
message.style.borderRadius = "8px";
message.style.margin = "20px";
const message = document.createElement('div');
message.textContent = 'Hello from my first plugin! 🎉';
message.style.padding = '20px';
message.style.backgroundColor = '#e9f5ff';
message.style.borderRadius = '8px';
message.style.margin = '20px';
// Add it to the page
homePage.prepend(message);
@@ -52,10 +46,10 @@ const myFirstPlugin: Plugin = {
// Return a cleanup function that removes our message when the plugin is disabled
return () => {
const message = document.querySelector(".home-page > div");
const message = document.querySelector('.home-page > div');
message?.remove();
};
},
}
};
export default myFirstPlugin;
@@ -70,11 +64,10 @@ Let's break down what's happening here:
- `description`: Explain what your plugin does
- `version`: Your plugin's version number
3. We set `disableToggle: true` so users can turn our plugin on/off in settings
4. We set `beta: true` to mark the plugin as beta
5. The `run` function is where we put our plugin's code
6. We use `api.seqta.onMount` to wait for the homepage to load
7. We create and style a message element
8. We return a cleanup function that removes our changes when the plugin is disabled
4. The `run` function is where we put our plugin's code
5. We use `api.seqta.onMount` to wait for the homepage to load
6. We create and style a message element
7. We return a cleanup function that removes our changes when the plugin is disabled
## The Plugin API
@@ -86,13 +79,13 @@ This helps you interact with SEQTA's pages:
```typescript
// Wait for an element to appear on the page
api.seqta.onMount(".some-class", (element) => {
api.seqta.onMount('.some-class', (element) => {
// Do something with the element
});
// Know when the user changes pages
api.seqta.onPageChange((page) => {
console.log("User went to:", page);
console.log('User went to:', page);
});
// Get the current page
@@ -104,12 +97,8 @@ const currentPage = api.seqta.getCurrentPage();
Want to let users customize your plugin? Use settings!
```typescript
import { BasePlugin } from "@/plugins/core/settings";
import {
booleanSetting,
defineSettings,
Setting,
} from "@/plugins/core/settingsHelpers";
import { BasePlugin } from '@/plugins/core/settings';
import { booleanSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers';
// Define your settings
const settings = defineSettings({
@@ -117,7 +106,7 @@ const settings = defineSettings({
default: true,
title: "Show Welcome Message",
description: "Show a friendly message on the homepage",
}),
})
});
// Create a class for your plugin
@@ -140,14 +129,14 @@ const myPlugin: Plugin<typeof settings> = {
}
// Listen for setting changes
api.settings.onChange("showMessage", (newValue) => {
api.settings.onChange('showMessage', (newValue) => {
if (newValue) {
// Show the message
} else {
// Hide the message
}
});
},
}
};
```
@@ -157,14 +146,14 @@ Need to save some data? The storage API has got you covered:
```typescript
// Save some data
await api.storage.set("lastVisit", new Date().toISOString());
await api.storage.set('lastVisit', new Date().toISOString());
// Get it back later
const lastVisit = await api.storage.get("lastVisit");
const lastVisit = await api.storage.get('lastVisit');
// Listen for changes
api.storage.onChange("lastVisit", (newValue) => {
console.log("Last visit updated:", newValue);
api.storage.onChange('lastVisit', (newValue) => {
console.log('Last visit updated:', newValue);
});
```
@@ -174,12 +163,12 @@ Want your plugin to be able to interface with other plugins? Then use events!
```typescript
// Listen for an event
api.events.on("myCustomEvent", (data) => {
console.log("Got event:", data);
api.events.on('myCustomEvent', (data) => {
console.log('Got event:', data);
});
// Send an event
api.events.emit("myCustomEvent", { some: "data" });
api.events.emit('myCustomEvent', { some: 'data' });
```
## Adding Styles
@@ -210,7 +199,7 @@ const myPlugin: Plugin = {
run: async (api) => {
// Your plugin code here
},
}
};
```
@@ -219,31 +208,28 @@ const myPlugin: Plugin = {
Here are some tips to make your plugin awesome:
1. **Always Clean Up**: When your plugin is disabled, clean up any changes you made:
```typescript
run: async (api) => {
// Add stuff to the page
const element = document.createElement("div");
const element = document.createElement('div');
document.body.appendChild(element);
// Return a cleanup function
return () => {
element.remove();
};
};
}
```
2. **Use TypeScript**: It helps catch errors before they happen and makes your code easier to understand.
3. **Test Your Plugin**: Make sure it works in different situations:
- When SEQTA is loading
- When the user switches pages
- When the plugin is enabled/disabled
- When settings are changed
4. **Keep It Fast**: Don't slow down SEQTA:
- Use `onMount` instead of intervals or timeouts
- Clean up event listeners when they're not needed
- Don't do heavy calculations on the main thread
@@ -252,37 +238,10 @@ Here are some tips to make your plugin awesome:
- Add clear settings with good descriptions
- Use `disableToggle: true` so users can turn it off if needed
- Add helpful error messages if something goes wrong
- Use `beta: true` for experimental features to let users know they're trying something new
## Plugin Metadata Options
Your plugin object supports several optional flags to customize how it appears and behaves:
```typescript
const myPlugin: Plugin = {
id: "my-plugin",
name: "My Plugin",
description: "What my plugin does",
version: "1.0.0",
// Optional flags:
disableToggle: true, // Show enable/disable toggle in settings
defaultEnabled: false, // Start disabled by default (requires disableToggle: true)
beta: true, // Show "Beta" tag in settings UI
// Your plugin code...
run: async (api) => { /* ... */ },
};
```
- **`disableToggle`**: When `true`, users can enable/disable your plugin in settings
- **`defaultEnabled`**: When `false`, your plugin starts disabled (only works with `disableToggle: true`)
- **`beta`**: When `true`, shows an orange "Beta" tag next to your plugin name in settings
## Examples
Want to see more examples? Check out our built-in plugins:
- [themes](../../src/plugins/built-in/themes/index.ts): Shows how to change SEQTA's appearance
- [notificationCollector](../../src/plugins/built-in/notificationCollector/index.ts): Shows how to work with SEQTA's notifications
- [timetable](../../src/plugins/built-in/timetable/index.ts): Shows how to modify SEQTA's timetable view
@@ -291,9 +250,8 @@ Want to see more examples? Check out our built-in plugins:
## Need Help?
Got stuck? No worries! Here's where you can get help:
- Join our [Discord server](https://discord.gg/YzmbnCDkat)
- Check out the built-in plugins in the `src/plugins/built-in` folder
- Open an issue on our [GitHub page](https://github.com/betterseqta/betterseqta-plus/issues)
Happy coding and feel free to check out the [plugin API](https://docs.betterseqta.org/plugin-api/) on the documentation site.
Happy coding and feel free to checkout the api reference [here](./api-reference.md)
+75 -129
View File
@@ -1,21 +1,15 @@
# Plugin API Reference
**Published version:** [docs.betterseqta.org/plugin-api/](https://docs.betterseqta.org/plugin-api/)
This document provides detailed technical information about BetterSEQTA+'s plugin APIs. For a beginner-friendly introduction, see the [plugins section](https://docs.betterseqta.org/plugins/) at [docs.betterseqta.org](https://docs.betterseqta.org/).
This document provides detailed technical information about BetterSEQTA+'s plugin APIs. For a beginner-friendly introduction, see [Creating Your First Plugin](./README.md).
## Plugin Structure
Here's how a plugin is structured:
```typescript
import type { Plugin } from "@/plugins/core/types";
import { BasePlugin } from "@/plugins/core/settings";
import {
booleanSetting,
defineSettings,
Setting,
} from "@/plugins/core/settingsHelpers";
import type { Plugin } from '@/plugins/core/types';
import { BasePlugin } from '@/plugins/core/settings';
import { booleanSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers';
// First, define your settings
const settings = defineSettings({
@@ -23,7 +17,7 @@ const settings = defineSettings({
default: true,
title: "Enable Feature",
description: "Turn this feature on or off",
}),
})
});
// Create a class to handle your settings
@@ -37,92 +31,59 @@ const settingsInstance = new MyPluginClass();
// Create your plugin
const myPlugin: Plugin<typeof settings> = {
id: "my-plugin",
name: "My Plugin",
description: "A cool plugin that does things",
version: "1.0.0",
id: 'my-plugin',
name: 'My Plugin',
description: 'A cool plugin that does things',
version: '1.0.0',
settings: settingsInstance.settings,
disableToggle: true,
beta: true,
run: async (api) => {
console.log("Plugin is running!");
console.log('Plugin is running!');
// Do stuff when settings change
api.settings.onChange("enabled", (enabled) => {
api.settings.onChange('enabled', (enabled) => {
if (enabled) {
console.log("Feature enabled!");
console.log('Feature enabled!');
}
});
// Return a cleanup function
return () => {
console.log("Plugin cleanup");
console.log('Plugin cleanup');
};
},
}
};
export default myPlugin;
```
## Plugin Metadata
The plugin object supports several metadata fields and options:
```typescript
interface Plugin {
// Required fields
id: string; // Unique identifier (lowercase, dashes)
name: string; // Display name shown to users
description: string; // Brief description of what the plugin does
version: string; // Semantic version (e.g., "1.0.0")
settings: PluginSettings; // Plugin settings object
run: (api: PluginAPI) => void; // Main plugin function
// Optional fields
styles?: string; // CSS styles to inject
disableToggle?: boolean; // Show enable/disable toggle in settings
defaultEnabled?: boolean; // Start enabled/disabled (requires disableToggle)
beta?: boolean; // Show "Beta" tag in settings UI
}
```
### Metadata Options
- **`disableToggle`**: When `true`, users can enable/disable your plugin in the settings page
- **`defaultEnabled`**: When `false`, your plugin starts disabled by default (only works with `disableToggle: true`)
- **`beta`**: When `true`, displays an orange "Beta" tag next to your plugin name in the settings UI
- **`styles`**: CSS string that gets injected into the page when your plugin runs
## SEQTA API
The SEQTA API helps you interact with SEQTA's pages:
```typescript
import type { Plugin } from "@/plugins/core/types";
import type { Plugin } from '@/plugins/core/types';
const seqtaPlugin: Plugin<typeof settings> = {
id: "seqta-example",
name: "SEQTA Example",
description: "Shows how to use the SEQTA API",
version: "1.0.0",
id: 'seqta-example',
name: 'SEQTA Example',
description: 'Shows how to use the SEQTA API',
version: '1.0.0',
settings: {},
disableToggle: true,
run: async (api) => {
// Wait for elements to appear
const { unregister: timetableUnregister } = api.seqta.onMount(
".timetable",
(timetable) => {
const button = document.createElement("button");
button.textContent = "Export";
const { unregister: timetableUnregister } = api.seqta.onMount('.timetable', (timetable) => {
const button = document.createElement('button');
button.textContent = 'Export';
timetable.appendChild(button);
},
);
});
// Track page changes
const { unregister: pageUnregister } = api.seqta.onPageChange((page) => {
console.log("User went to:", page);
console.log('User went to:', page);
});
// Clean up when disabled
@@ -130,7 +91,7 @@ const seqtaPlugin: Plugin<typeof settings> = {
timetableUnregister();
pageUnregister();
};
},
}
};
export default seqtaPlugin;
@@ -141,29 +102,22 @@ export default seqtaPlugin;
Here's how to add settings to your plugin:
```typescript
import type { Plugin } from "@/plugins/core/types";
import { BasePlugin } from "@/plugins/core/settings";
import {
booleanSetting,
stringSetting,
numberSetting,
selectSetting,
defineSettings,
Setting,
} from "@/plugins/core/settingsHelpers";
import type { Plugin } from '@/plugins/core/types';
import { BasePlugin } from '@/plugins/core/settings';
import { booleanSetting, stringSetting, numberSetting, selectSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers';
// Define your settings
const settings = defineSettings({
darkMode: booleanSetting({
default: false,
title: "Dark Mode",
description: "Enable dark mode",
description: "Enable dark mode"
}),
userName: stringSetting({
default: "",
title: "User Name",
description: "Your display name",
placeholder: "Enter your name...",
placeholder: "Enter your name..."
}),
theme: selectSetting({
default: "light",
@@ -171,9 +125,9 @@ const settings = defineSettings({
description: "Choose your theme",
options: [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
],
}),
{ value: "dark", label: "Dark" }
]
})
});
// Create your settings class
@@ -190,29 +144,29 @@ class ThemePluginClass extends BasePlugin<typeof settings> {
// Create the plugin
const themePlugin: Plugin<typeof settings> = {
id: "theme-example",
name: "Theme Example",
description: "Shows how to use settings",
version: "1.0.0",
id: 'theme-example',
name: 'Theme Example',
description: 'Shows how to use settings',
version: '1.0.0',
settings: new ThemePluginClass().settings,
disableToggle: true,
run: async (api) => {
// Apply initial settings
if (api.settings.darkMode) {
document.body.classList.add("dark");
document.body.classList.add('dark');
}
// Listen for changes
const { unregister } = api.settings.onChange("darkMode", (enabled) => {
document.body.classList.toggle("dark", enabled);
const { unregister } = api.settings.onChange('darkMode', (enabled) => {
document.body.classList.toggle('dark', enabled);
});
return () => {
unregister();
document.body.classList.remove("dark");
document.body.classList.remove('dark');
};
},
}
};
export default themePlugin;
@@ -223,13 +177,13 @@ export default themePlugin;
Here's how to use storage in your plugin:
```typescript
import type { Plugin } from "@/plugins/core/types";
import type { Plugin } from '@/plugins/core/types';
const storagePlugin: Plugin<typeof settings> = {
id: "storage-example",
name: "Storage Example",
description: "Shows how to use storage",
version: "1.0.0",
id: 'storage-example',
name: 'Storage Example',
description: 'Shows how to use storage',
version: '1.0.0',
settings: {},
disableToggle: true,
@@ -238,21 +192,21 @@ const storagePlugin: Plugin<typeof settings> = {
await api.storage.loaded;
// Save some data
await api.storage.set("lastVisit", new Date().toISOString());
await api.storage.set('lastVisit', new Date().toISOString());
// Get saved data
const lastVisit = await api.storage.get("lastVisit");
console.log("Last visit:", lastVisit);
const lastVisit = await api.storage.get('lastVisit');
console.log('Last visit:', lastVisit);
// Listen for changes
const { unregister } = api.storage.onChange("lastVisit", (newValue) => {
console.log("Last visit updated:", newValue);
const { unregister } = api.storage.onChange('lastVisit', (newValue) => {
console.log('Last visit updated:', newValue);
});
return () => {
unregister();
};
},
}
};
export default storagePlugin;
@@ -263,39 +217,33 @@ export default storagePlugin;
Here's how to use events in your plugin:
```typescript
import type { Plugin } from "@/plugins/core/types";
import type { Plugin } from '@/plugins/core/types';
const eventsPlugin: Plugin<typeof settings> = {
id: "events-example",
name: "Events Example",
description: "Shows how to use events",
version: "1.0.0",
id: 'events-example',
name: 'Events Example',
description: 'Shows how to use events',
version: '1.0.0',
settings: {},
disableToggle: true,
run: async (api) => {
// Listen for theme changes
const { unregister: themeListener } = api.events.on(
"theme.changed",
(theme) => {
console.log("Theme changed to:", theme);
},
);
const { unregister: themeListener } = api.events.on('theme.changed', (theme) => {
console.log('Theme changed to:', theme);
});
// Listen for notifications
const { unregister: notifyListener } = api.events.on(
"notification.new",
(notification) => {
console.log("New notification:", notification);
},
);
const { unregister: notifyListener } = api.events.on('notification.new', (notification) => {
console.log('New notification:', notification);
});
// Clean up listeners
return () => {
themeListener();
notifyListener();
};
},
}
};
export default eventsPlugin;
@@ -306,20 +254,20 @@ export default eventsPlugin;
Here's how to write efficient plugins:
```typescript
import type { Plugin } from "@/plugins/core/types";
import type { Plugin } from '@/plugins/core/types';
const efficientPlugin: Plugin<typeof settings> = {
id: "efficient-example",
name: "Efficient Example",
description: "Shows performance best practices",
version: "1.0.0",
id: 'efficient-example',
name: 'Efficient Example',
description: 'Shows performance best practices',
version: '1.0.0',
settings: {},
disableToggle: true,
run: async (api) => {
// ✅ Good: Use onMount
const { unregister } = api.seqta.onMount(".timetable", (el) => {
el.classList.add("enhanced");
const { unregister } = api.seqta.onMount('.timetable', (el) => {
el.classList.add('enhanced');
});
// ❌ Bad: Don't use intervals
@@ -329,7 +277,7 @@ const efficientPlugin: Plugin<typeof settings> = {
// }, 100);
// ✅ Good: Cache DOM elements
const header = document.querySelector(".header");
const header = document.querySelector('.header');
if (header) {
// Reuse header instead of querying again
}
@@ -337,7 +285,7 @@ const efficientPlugin: Plugin<typeof settings> = {
// ✅ Good: Batch DOM updates
const fragment = document.createDocumentFragment();
for (let i = 0; i < 10; i++) {
const div = document.createElement("div");
const div = document.createElement('div');
fragment.appendChild(div);
}
document.body.appendChild(fragment);
@@ -346,14 +294,13 @@ const efficientPlugin: Plugin<typeof settings> = {
unregister();
// clearInterval(interval); // If you used the bad approach
};
},
}
};
export default efficientPlugin;
```
Each plugin should be in its own file and exported as the default export. The plugin should:
1. Import necessary types and helpers
2. Define settings if needed
3. Create a settings class if using settings
@@ -361,7 +308,6 @@ Each plugin should be in its own file and exported as the default export. The pl
5. Export the plugin as default
Remember to always:
- Use proper TypeScript types
- Clean up when your plugin is disabled
- Handle errors gracefully
-17
View File
@@ -1,17 +0,0 @@
export default {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: [
'**/__tests__/**/*.ts',
'**/?(*.)+(spec|test).ts'
],
transform: {
'^.+\\.ts$': 'ts-jest',
},
moduleFileExtensions: ['ts', 'js', 'json'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
],
};
+1 -34
View File
@@ -1,46 +1,13 @@
import fs from "fs";
import mime from "mime-types";
/**
* A Vite plugin designed to load files as base64 encoded data URLs.
* This plugin intercepts module imports that have a `?base64` query parameter
* appended to the file path. It then reads the targeted file, converts its content
* to a base64 string, and constructs a data URL which is then exported as the
* default export of a new JavaScript module.
*
* @example
* // To use this loader, import a file with ?base64 query:
* // import myImageBase64 from './path/to/myimage.png?base64';
* // myImageBase64 will then be a string like "data:image/png;base64,..."
*/
export const base64Loader = {
/**
* The name of the Vite plugin.
* @type {string}
*/
name: "base64-loader",
/**
* The core transformation function of the Vite plugin.
* It is called by Vite for modules that might need transformation. This function
* checks if the module ID includes the `?base64` query. If so, it reads the
* specified file, converts it to a base64 data URL, and returns a new
* JavaScript module that default exports this data URL.
*
* @param {any} _ The original code of the file. This parameter is unused by this loader.
* @param {string} id The ID of the module being transformed. This string typically
* contains the absolute file path and any query parameters
* (e.g., "/path/to/file.png?base64").
* @returns {string | null} If the module ID does not contain `?base64` query,
* it returns `null` to indicate no transformation.
* Otherwise, it returns a string of JavaScript code
* that default exports the base64 data URL of the file.
* For example: `export default 'data:image/png;base64,xxxx';`
*/
transform(_: any, id: string) {
const [filePath, query] = id.split("?");
if (query !== "base64") return null;
const data = fs.readFileSync(filePath, { encoding: "base64" });
const data = fs.readFileSync(filePath, { encoding: 'base64' });
const mimeType = mime.lookup(filePath);
const dataURL = `data:${mimeType};base64,${data}`;
+11 -44
View File
@@ -1,58 +1,25 @@
// ref: https://stackoverflow.com/a/76920975
import type { Plugin } from "vite";
import type { Plugin } from 'vite';
/**
* Creates a Vite plugin designed to gracefully handle the conclusion of the build process.
* This plugin utilizes the `buildEnd` and `closeBundle` hooks provided by Vite.
* It checks for errors at the end of the build:
* - If an error occurred during the build (`buildEnd` hook receives an error), it logs the error
* and explicitly exits the Node.js process with a status code of 1 (indicating failure).
* - If the build completes without errors and the bundle is successfully generated
* (`closeBundle` hook is called), it logs a success message and exits the process
* with a status code of 0 (indicating success).
* This explicit process exiting can be useful in CI/CD environments or scripts that
* rely on the process status code to determine the build outcome.
* The core logic for using these hooks to exit the process is inspired by
* a solution found on StackOverflow (https://stackoverflow.com/a/76920975).
*
* @returns {Plugin} A Vite plugin object configured with `name`, `buildEnd`, and `closeBundle` hooks.
*/
export default function ClosePlugin(): Plugin {
return {
/**
* The unique name of this Vite plugin. This name is used by Vite for identification
* purposes and will appear in warnings, errors, and logs related to this plugin.
* @type {string}
*/
name: "ClosePlugin", // required, will show up in warnings and errors
name: 'ClosePlugin', // required, will show up in warnings and errors
/**
* A Vite hook that is called when the build process has finished, regardless of
* whether it was successful or encountered an error.
*
* @param {Error} [error] An optional error object. If the build failed, this parameter
* will contain the error that occurred. If the build was successful,
* this parameter will be undefined or null.
*/
// use this to catch errors when building
buildEnd(error) {
if(error) {
console.error("Error bundling");
console.error(error);
process.exit(1); // Exit with status 1 indicating an error
console.error('Error bundling')
console.error(error)
process.exit(1)
} else {
console.log("Build ended"); // Log successful completion of the build phase
console.log('Build ended')
}
},
/**
* A Vite hook that is called after the `buildEnd` hook, but only if the build
* was successful (i.e., no errors were passed to `buildEnd`) and all output
* files have been generated and written to disk. This signifies the successful
* completion of the entire bundling process.
*/
// use this to catch the end of a build without errors
closeBundle() {
console.log("Bundle closed"); // Log successful closure of the bundle
process.exit(0); // Exit with status 0 indicating a successful build
console.log('Bundle closed')
process.exit(0)
},
};
}
}
+14 -27
View File
@@ -1,22 +1,12 @@
import type { Browser, BuildTarget, Manifest } from "./types";
import type { AnyCase } from "./utils";
import type { Browser, BuildTarget, Manifest } from './types'
import type { AnyCase } from './utils'
/**
* Packages a given manifest object with a specific target browser identifier.
* This function is typically used in multi-browser extension build processes
* to create a configuration object that pairs the manifest data with the browser
* it's intended for. The `AnyCase<Browser>` type for the browser parameter
* implies that browser names like 'chrome', 'firefox', etc., can be provided
* in various casings.
*
*
* @export
* @param {Manifest} manifest The core manifest data for the extension,
* compatible with `chrome.runtime.ManifestV3` as defined by the {@link Manifest} type.
* @param {AnyCase<Browser>} browser The target browser identifier (e.g., 'chrome', 'firefox', 'CHROME').
* Refers to the {@link Browser} type, allowing for flexible casing.
* @returns {BuildTarget} An object that pairs the `manifest` with its target `browser`.
* The structure is `{ manifest: Manifest; browser: AnyCase<Browser>; }`
* as defined by the {@link BuildTarget} type.
* @param {Manifest} manifest
* @param {AnyCase<Browser>} browser
* @return {*} {@link BuildTarget}
*/
export function createManifest(
manifest: Manifest,
@@ -25,22 +15,19 @@ export function createManifest(
return {
manifest,
browser,
};
}
}
/**
* Defines a base manifest object.
* This function is typically used to establish a common, shared foundation for an extension's manifest
* (compatible with `chrome.runtime.ManifestV3` as per the {@link Manifest} type).
* This base can then be extended or modified for different browsers or specific build configurations.
* For example, you might define core permissions and properties here, and then add
* browser-specific keys in subsequent steps.
* create a base Manifest to inherit from
* type Manifest = chrome.runtime.ManifestV3
*
* use as shared base to extend inBrowser manifests
*
* @export
* @param {Manifest} manifest The core manifest data to be used as a base.
* This should conform to the {@link Manifest} type structure.
* @returns {Manifest} The provided manifest object, intended to serve as a reusable base.
* @param {Manifest} manifest
* @return {*} {@link Manifest}
*/
export function createManifestBase(manifest: Manifest): Manifest {
return manifest;
return manifest
}
-43
View File
@@ -1,43 +0,0 @@
import type { Plugin } from "vite";
/**
* Firefox extension pages forbid eval / `Function` constructor. Some deps still emit:
* - `Function(\`return this\`)()` (lodash-style global)
* - `try { return Function(\`\`) / new Function("") … }` (feature probes, e.g. PDF.js / ORT)
*/
export function firefoxStripFunctionProbe(): Plugin {
return {
name: "firefox-strip-function-probe",
apply: "build",
enforce: "post",
generateBundle(_options, bundle) {
if ((process.env.MODE || "chrome").toLowerCase() !== "firefox") return;
const literalReplacements: [string, string][] = [
['try{return new Function(""),!0}catch{return!1}', "return!1"],
["try{return new Function(''),!0}catch{return!1}", "return!1"],
['try{return new Function(""),true}catch{return false}', "return false"],
["try{return new Function(''),true}catch{return false}", "return false"],
// Empty template literal probe (minifier output)
["try{return Function(``),!0}catch{return!1}", "return!1"],
];
for (const chunk of Object.values(bundle)) {
if (chunk.type !== "chunk" || typeof chunk.code !== "string") continue;
let { code } = chunk;
code = code.replace(/Function\(`return this`\)\(\)/g, "(globalThis)");
code = code.replace(/Function\("return this"\)\(\)/g, "(globalThis)");
code = code.replace(/Function\('return this'\)\(\)/g, "(globalThis)");
for (const [from, to] of literalReplacements) {
if (code.includes(from)) {
code = code.split(from).join(to);
}
}
chunk.code = code;
}
},
};
}
+20 -53
View File
@@ -1,70 +1,37 @@
// vite-plugin-inline-worker-dev.ts
// vite-plugin-inline-worker-dev.ts
import { Plugin } from "vite";
import fs from "fs/promises";
import { build } from "esbuild";
import { Plugin } from 'vite'
import fs from 'fs/promises'
import { build, transform } from 'esbuild'
/**
* Creates a Vite plugin designed for bundling and inlining web worker scripts during development.
* This plugin specifically targets module imports that include a `?inlineWorker` query parameter.
* When such an import is encountered, the plugin bundles the worker script using `esbuild`
* and then generates JavaScript code that inlines this bundled worker as a Blob,
* creating the worker instance via `URL.createObjectURL()`.
* The name "vite:inline-worker-dev" suggests it's primarily intended for development builds.
*
* @returns {Plugin} A Vite plugin object with `name` and `load` properties.
*/
export default function InlineWorkerDevPlugin(): Plugin {
return {
/**
* The unique name of this Vite plugin.
* @type {string}
*/
name: "vite:inline-worker-dev",
/**
* The Vite hook responsible for loading and transforming modules.
* This function intercepts modules imported with `?inlineWorker`.
* For such modules, it bundles the worker script and returns JavaScript code
* that, when executed, will create an instance of this worker from an inlined Blob.
*
* @async
* @param {string} id The path or ID of the module Vite is attempting to load,
* potentially including query parameters (e.g., "/path/to/worker.ts?inlineWorker").
* @returns {Promise<string | null>} A promise that resolves to:
* - `null` if the module ID does not include `?inlineWorker`.
* - A string of JavaScript code if the module is an inline worker.
* This code will define a default export function (e.g., `InlineWorker`)
* that, when called, creates and returns a new `Worker` instance
* from the bundled and inlined worker script.
*/
name: 'vite:inline-worker-dev',
async load(id) {
if (id.includes("?inlineWorker")) {
const [cleanPath] = id.split("?");
// Note: Original code had `await fs.readFile(cleanPath, "utf-8");` but `code` wasn't used.
// `esbuild` directly takes `cleanPath` as an entry point.
if (id.includes('?inlineWorker')) {
const [cleanPath] = id.split('?')
console.log('cleanPath', cleanPath)
const code = await fs.readFile(cleanPath, 'utf-8')
const result = await build({
entryPoints: [cleanPath], // esbuild uses the file path directly
entryPoints: [cleanPath],
bundle: true,
write: false, // We want the output in memory, not written to disk
platform: "browser", // Target environment for the worker code
format: "iife", // Immediately Invoked Function Expression, suitable for workers
target: "esnext", // Transpile to modern JavaScript
});
write: false,
platform: 'browser',
format: 'iife',
target: 'esnext',
})
const workerCode = result.outputFiles[0].text;
const workerCode = result.outputFiles[0].text
// Construct JavaScript code that will create the worker from a Blob.
// This code is what gets returned to Vite and replaces the original import.
const workerBlobCode = `
const code = ${JSON.stringify(workerCode)};
export default function InlineWorker() {
const blob = new Blob([code], { type: 'application/javascript' });
return new Worker(URL.createObjectURL(blob), { type: 'module' });
}
`;
return workerBlobCode;
`
return workerBlobCode
}
return null
}
}
return null; // Let Vite handle other modules normally
},
};
}
+79
View File
@@ -0,0 +1,79 @@
/*
TEMPORARY FIX FOR CHROME 130+ builds
*/
import path from 'node:path';
import fs from 'fs';
import { PluginOption } from 'vite';
import { ManifestV3Export } from '@crxjs/vite-plugin';
const manifestPath = path.resolve('dist/chrome/manifest.json');
export function updateManifestPlugin(): PluginOption {
return {
name: 'update-manifest-plugin',
enforce: 'post',
closeBundle() {
forceDisableUseDynamicUrl();
},
configureServer(server) {
server.httpServer?.once('listening', () => {
const updated = forceDisableUseDynamicUrl();
if (updated) {
server.ws.send({ type: 'full-reload' });
console.log('** updated **');
}
// Implement retry mechanism for file watching
const watchWithRetry = () => {
if (!fs.existsSync(manifestPath)) {
console.log('Manifest not found, retrying in 1 second...');
setTimeout(watchWithRetry, 1000);
return;
}
fs.watchFile(manifestPath, () => {
try {
const manifestContents = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
if (manifestContents.web_accessible_resources?.some((resource: any) => resource.use_dynamic_url)) {
const updated = forceDisableUseDynamicUrl();
if (updated) {
server.ws.send({ type: 'full-reload' });
console.log('** updated **');
}
}
} catch (error) {
console.log('Error reading manifest, will retry on next change:', error.message);
}
});
};
watchWithRetry();
});
},
writeBundle() {
console.log('### writeBundle ##');
forceDisableUseDynamicUrl();
},
};
}
function forceDisableUseDynamicUrl() {
if (!fs.existsSync(manifestPath)) {
return false;
}
const manifestContents = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Awaited<ManifestV3Export>;
if (typeof manifestContents === 'function' || !manifestContents.web_accessible_resources) return false;
if (manifestContents.web_accessible_resources.every((resource) => !resource.use_dynamic_url)) return false;
manifestContents.web_accessible_resources.forEach((resource) => {
if (resource.use_dynamic_url) resource.use_dynamic_url = false;
});
fs.writeFileSync(manifestPath, JSON.stringify(manifestContents, null, 2));
return true;
}
+49 -155
View File
@@ -1,199 +1,94 @@
/**
* @fileoverview
* This script is a command-line utility for publishing the BetterSEQTA+ extension.
* It automates the process of finding the latest built extension ZIP files for specified
* browsers, zipping the project source code (for Firefox), and then invoking the
* `publish-extension` tool with the appropriate arguments.
*
* To use this script, invoke it with Node.js followed by browser arguments:
* e.g., `node lib/publish.js --b chrome firefox`
* or `node lib/publish.js --b chrome`
* or `node lib/publish.js --b firefox`
*/
const glob = require('glob');
const semver = require('semver');
const { execSync } = require('child_process');
const path = require('path');
const glob = require("glob");
const semver = require("semver");
const { execSync } = require("child_process");
const path = require("path");
/**
* Determines the latest version string from a list of filenames that include version numbers.
* Filenames are expected to follow a pattern like `betterseqtaplus@3.4.5.1-chrome.zip`.
* This function handles potential 4-part versions (e.g., `3.4.5.1`) by trimming them
* to 3 parts (e.g., `3.4.5`) for comparison using the `semver` library. After identifying
* the latest semver-compatible version, it returns the original full version string
* (e.g., "3.4.5.1") that corresponds to this latest version.
*
* @param {string[]} files An array of filenames.
* @returns {string | null} The latest version string (e.g., "3.4.5.1") found among the files,
* or `null` if no valid version numbers are found or no files are provided.
*/
function getLatestVersion(files) {
console.log("Files passed to getLatestVersion:", files);
console.log('Files passed to getLatestVersion:', files);
const versions = files
.map((file) => {
const versions = files.map(file => {
const match = file.match(/@([\d\.]+)-/);
console.log(
"Matching file:",
file,
"Version found:",
match ? match[1] : "None",
);
console.log('Matching file:', file, 'Version found:', match ? match[1] : 'None');
if (!match) return null;
const fullVersion = match[1]; // Original version (e.g., 3.4.5.1)
// Trim to 3 parts for semver comparison, as semver typically handles X.Y.Z
const semverVersion = fullVersion.split(".").slice(0, 3).join(".");
const semverVersion = fullVersion.split('.').slice(0, 3).join('.'); // Trim to 3.4.5
return { fullVersion, semverVersion };
})
.filter(Boolean); // Remove null entries if any file didn't match
}).filter(Boolean);
console.log(
"Extracted versions:",
versions.map((v) => v.semverVersion),
);
if (versions.length === 0) {
console.log("No versions extracted.");
return null;
}
console.log('Extracted versions:', versions.map(v => v.semverVersion));
// Find latest version using the trimmed semver format
const latestSemver = semver.maxSatisfying(
versions.map((v) => v.semverVersion),
"*", // Satisfy any version, effectively finding the max
);
console.log("Latest SemVer-compatible version:", latestSemver);
const latestSemver = semver.maxSatisfying(versions.map(v => v.semverVersion), '*');
console.log('Latest SemVer-compatible version:', latestSemver);
if (!latestSemver) {
console.log("Could not determine latest semver version.");
return null;
// Get the full version that matches the latest SemVer version
const latestVersion = versions.find(v => v.semverVersion === latestSemver)?.fullVersion || null;
console.log('Final selected latest version:', latestVersion);
return latestVersion;
}
// Get the original full version string that matches the identified latest SemVer version
const latestVersionData = versions.find(
(v) => v.semverVersion === latestSemver,
);
const latestFullVersion = latestVersionData ? latestVersionData.fullVersion : null;
console.log("Final selected latest version:", latestFullVersion);
return latestFullVersion;
}
/**
* Finds the path to the latest built ZIP file for a specific browser.
* It constructs a glob pattern based on the browser name (e.g., `dist/betterseqtaplus@*-*chrome.zip`),
* finds all matching files, and then uses `getLatestVersion` to identify the version string
* of the most recent file. Finally, it returns the full path to that specific file.
*
* @param {string} browser A string indicating the target browser (e.g., "chrome", "firefox").
* @returns {string | undefined} The filepath string to the latest ZIP file for the specified browser,
* or `undefined` if no matching file is found or if the latest version
* cannot be determined.
*/
function getLatestFiles(browser) {
const pattern = `dist/betterseqtaplus@*-*${browser}.zip`;
console.log("Glob pattern:", pattern);
console.log('Glob pattern:', pattern);
const files = glob.sync(pattern);
console.log("Files found for browser", browser, ":", files);
if (files.length === 0) {
console.log("No files found for browser", browser);
return undefined;
}
console.log('Files found for browser', browser, ':', files);
const latestVersion = getLatestVersion(files);
if (!latestVersion) {
console.log("Could not determine latest version for browser", browser);
return undefined;
}
// Find the exact file by matching the original full version string
const latestFile = files.find((file) => file.includes(`@${latestVersion}-`));
// Find the exact file by matching the original full version
const latestFile = files.find(file => file.includes(`@${latestVersion}-`));
console.log("Latest file for browser", browser, ":", latestFile);
console.log('Latest file for browser', browser, ':', latestFile);
return latestFile;
}
/**
* Creates a ZIP file of the project's source code, excluding specified development-related
* files and directories such as `node_modules`, `dist`, `.git`, etc.
* It uses the `7z` command-line tool to perform the archiving.
* The output filename is fixed as `dist/betterseqtaplus@latest-sources.zip`.
*
* @returns {string} The filename of the created ZIP file (e.g., `dist/betterseqtaplus@latest-sources.zip`).
*/
function zipSources() {
const zipFileName = `dist/betterseqtaplus@latest-sources.zip`;
const excludePatterns = [
"node_modules",
"dist",
".env*",
".git",
".github",
".vscode",
"LICENSE",
"package.json",
]
.map((pattern) => `-x!${pattern}`) // Format for 7z exclude syntax
.join(" ");
'node_modules',
'dist',
'.env*',
'.git',
'.github',
'.vscode',
'LICENSE',
'package.json'
].map(pattern => `-x!${pattern}`).join(' ');
// Command to zip the current directory's contents into zipFileName, applying exclude patterns
const zipCommand = `7z a ${zipFileName} . ${excludePatterns}`;
console.log("Zipping project sources with command:", zipCommand);
execSync(zipCommand, { stdio: "inherit" }); // Execute synchronously and show output
console.log('Zipping project sources with command:', zipCommand);
execSync(zipCommand, { stdio: 'inherit' });
return zipFileName;
}
/**
* Orchestrates the extension publishing process for the specified browsers.
* This function performs the following steps:
* 1. Calls `getLatestFiles` to find the latest built ZIP for Chrome if "chrome" is in `browsers`.
* 2. Calls `getLatestFiles` to find the latest built ZIP for Firefox if "firefox" is in `browsers`.
* 3. Calls `zipSources` to create a source code ZIP if "firefox" is in `browsers` (required for Mozilla Add-ons).
* 4. Validates that all required files were found and that at least one browser was specified. Exits if not.
* 5. Constructs the `publish-extension` command-line string with the appropriate arguments
* based on the found ZIP files for the specified browsers.
* 6. Executes the constructed `publish-extension` command.
*
* @param {string[]} browsers An array of browser strings (e.g., ["chrome", "firefox"]) for which to publish the extension.
*/
function runPublishCommand(browsers) {
const chromeZip = browsers.includes("chrome")
? getLatestFiles("chrome")
: null;
const firefoxZip = browsers.includes("firefox")
? getLatestFiles("firefox")
: null;
// Sources are typically only needed for Firefox submissions
const firefoxSourcesZip = browsers.includes("firefox") ? zipSources() : null;
const chromeZip = browsers.includes('chrome') ? getLatestFiles('chrome') : null;
const firefoxZip = browsers.includes('firefox') ? getLatestFiles('firefox') : null;
const firefoxSourcesZip = browsers.includes('firefox') ? zipSources() : null;
console.log("Chrome zip:", chromeZip);
console.log("Firefox zip:", firefoxZip);
console.log("Firefox sources zip:", firefoxSourcesZip);
console.log('Chrome zip:', chromeZip);
console.log('Firefox zip:', firefoxZip);
console.log('Firefox sources zip:', firefoxSourcesZip);
if (browsers.length === 0) {
console.log("No browsers specified. Exiting.");
process.exit(0); // Exit gracefully if no action is needed
console.log('No browsers specified. Exiting.');
process.exit(0);
}
// Check if required files are missing for the specified browsers
if (
(browsers.includes("chrome") && !chromeZip) ||
(browsers.includes("firefox") && (!firefoxZip || !firefoxSourcesZip))
) {
console.error("Could not find required zip files for specified browsers.");
process.exit(1); // Exit with error status
if ((browsers.includes('chrome') && !chromeZip) || (browsers.includes('firefox') && (!firefoxZip || !firefoxSourcesZip))) {
console.error('Could not find required zip files for specified browsers.');
process.exit(1);
}
let command = "publish-extension";
let command = 'publish-extension';
if (chromeZip) {
command += ` --chrome-zip ${chromeZip}`;
}
@@ -201,14 +96,13 @@ function runPublishCommand(browsers) {
command += ` --firefox-zip ${firefoxZip} --firefox-sources-zip ${firefoxSourcesZip}`;
}
console.log("Running command:", command);
execSync(command, { stdio: "inherit" }); // Execute and show output
console.log('Running command:', command);
execSync(command, { stdio: 'inherit' });
}
// Parse command-line arguments to determine which browsers to publish for
// Parse command-line arguments
const args = process.argv.slice(2);
const browserIndex = args.indexOf("--b"); // Find the --b flag
// If --b is found, take all subsequent arguments as browser names
const browserIndex = args.indexOf('--b');
const browsers = browserIndex !== -1 ? args.slice(browserIndex + 1) : [];
runPublishCommand(browsers);
+8 -46
View File
@@ -1,55 +1,17 @@
import fs from "fs";
import fs from 'fs';
/**
* Creates a Vite plugin designed to improve the reliability of Hot Module Replacement (HMR)
* for global CSS files.
*
* When a JavaScript/TypeScript module that imports a CSS file is updated, Vite's HMR
* might not always reliably update the styles injected by that global CSS. This plugin
* attempts to mitigate this by listening for hot updates. If an updated module
* has direct importers that are CSS files (e.g., a JS file imports a global CSS file),
* this plugin will "touch" those CSS files by updating their access and modification
* timestamps using `fs.utimesSync`. This action can help signal to Vite or the browser
* that the CSS file has changed, potentially triggering a more reliable style reload.
*
* @returns {import('vite').Plugin} A Vite plugin object configured with `name` and `handleHotUpdate` hooks.
*/
export default function touchGlobalCSSPlugin() {
return {
/**
* The unique name of this Vite plugin.
* This name is used by Vite for identification purposes and will appear in logs.
* @type {string}
*/
name: "touch-global-css",
/**
* A Vite hook that is called when a module is hot-updated.
* This function inspects the importers of the updated module. If any of these
* importers are CSS files, their filesystem timestamps are updated ("touched").
*
* @param {object} context The context object provided by Vite's `handleHotUpdate` hook.
* @param {Array<import('vite').ModuleNode>} context.modules An array of `ModuleNode` instances that have been updated.
* This plugin specifically accesses `modules[0]._clientModule.importers`
* to find CSS files that import the updated module.
*/
name: 'touch-global-css',
handleHotUpdate({ modules }) {
// It's assumed `modules[0]` is the primary updated module of interest.
// `_clientModule` and `importers` might be internal or less stable Vite APIs.
const importers = modules[0]?._clientModule?.importers;
if (importers) {
// log all of the staticImportedUrls
const importers = modules[0]._clientModule.importers
importers.forEach((importer) => {
// Check if the importer is a CSS file
if (importer.file && importer.file.includes(".css")) {
console.log("[touch-global-css] touching", importer.file);
try {
// Update the access and modification times of the CSS file to the current time
fs.utimesSync(importer.file, new Date(), new Date());
} catch (err) {
console.error(`[touch-global-css] Error touching file ${importer.file}:`, err);
if (importer.file.includes('.css')) {
console.log("touching", importer.file)
fs.utimesSync(importer.file, new Date(), new Date())
}
})
}
});
}
},
};
}
+69 -205
View File
@@ -1,240 +1,104 @@
import type { ManifestV3Export } from "@crxjs/vite-plugin";
import { type AnyCase, createEnum, ObjectValues } from "./utils";
import type { ManifestV3Export } from '@crxjs/vite-plugin'
import { type AnyCase, createEnum } from './utils'
/**
* Enumerates supported JavaScript frameworks for project generation or configuration.
*/
export const FrameworkEnum = {
React: "React",
Vanilla: "Vanilla",
Preact: "Preact",
Lit: "Lit",
Svelte: "Svelte",
Vue: "Vue",
} as const;
React: 'React',
Vanilla: 'Vanilla',
Preact: 'Preact',
Lit: 'Lit',
Svelte: 'Svelte',
Vue: 'Vue',
} as const
/**
* Enumerates supported web browsers, typically for targeting builds or configurations.
*/
export const BrowserEnum = {
Chrome: "Chrome",
Brave: "Brave",
Opera: "Opera",
Edge: "Edge",
Firefox: "Firefox",
Safari: "Safari",
} as const;
Chrome: 'Chrome',
Brave: 'Brave',
Opera: 'Opera',
Edge: 'Edge',
Firefox: 'Firefox',
Safari: 'Safari',
} as const
/**
* @private
* Enumerates supported programming languages for project setup.
* This enum is not exported, suggesting it's for internal use within this module or related modules.
*/
const LanguageEnum = {
TypeScript: "TypeScript",
JavaScript: "JavaScript",
} as const;
TypeScript: 'TypeScript',
JavaScript: 'JavaScript',
} as const
/**
* Enumerates supported styling options or libraries.
*/
export const StyleEnum = {
Tailwind: "Tailwind",
} as const;
Tailwind: 'Tailwind',
} as const
/**
* Enumerates supported package managers.
*/
export const PackageManagerEnum = {
Bun: "Bun",
PnPm: "PnPm",
Npm: "Npm",
Yarn: "Yarn",
} as const;
Bun: 'Bun',
PnPm: 'PnPm',
Npm: 'Npm',
Yarn: 'Yarn',
} as const
/**
* Defines the structure for browser-specific settings within a web extension manifest.
* This is particularly used for Firefox (gecko) extensions to specify properties like
* an extension ID, and minimum/maximum supported browser versions.
* The structure is based on common manifest extensions for Firefox.
* See: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_specific_settings
* The link in the original code (// see: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/firefox-webext-browser/index.d.ts)
* also points to type definitions that include this structure.
*
* @property {object} [browser_specific_settings] - Container for browser-specific settings.
* @property {object} [browser_specific_settings.gecko] - Settings specific to Gecko-based browsers (e.g., Firefox).
* @property {string} [browser_specific_settings.gecko.id] - The unique identifier for the extension in Firefox.
* @property {string} [browser_specific_settings.gecko.strict_min_version] - The minimum version of Firefox the extension is compatible with.
* @property {string} [browser_specific_settings.gecko.strict_max_version] - The maximum version of Firefox the extension is compatible with.
*/
// see: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/firefox-webext-browser/index.d.ts
export type BrowserSpecificSettings = {
browser_specific_settings?: {
gecko?: {
id: string;
strict_min_version?: string;
strict_max_version?: string;
};
};
};
id: string
strict_min_version?: string
strict_max_version?: string
}
}
}
/**
* Represents the structure of a Chrome Manifest V3 file.
* This type is an alias for `ManifestV3Export` from the `@crxjs/vite-plugin`,
* which provides a comprehensive definition for Chrome extension manifests.
*/
export type Manifest = ManifestV3Export;
/** Alias for the `icons` property within a Chrome Manifest V3. */
export type ManifestIcons = chrome.runtime.ManifestIcons;
/** Alias for the `background` property within a Chrome Manifest V3. */
export type ManifestBackground = chrome.runtime.ManifestV3["background"];
/** Alias for the `content_scripts` property within a Chrome Manifest V3. */
export type Manifest = ManifestV3Export
export type ManifestIcons = chrome.runtime.ManifestIcons
export type ManifestBackground = chrome.runtime.ManifestV3['background']
export type ManifestContentScripts =
chrome.runtime.ManifestV3["content_scripts"];
/** Alias for the `web_accessible_resources` property within a Chrome Manifest V3. */
chrome.runtime.ManifestV3['content_scripts']
export type ManifestWebAccessibleResources =
chrome.runtime.ManifestV3["web_accessible_resources"];
/** Alias for the `commands` property within a Chrome Manifest V3. */
export type ManifestCommands = chrome.runtime.ManifestV3["commands"];
/** Alias for the `action` property (or `browser_action`/`page_action`) within a Chrome Manifest V3. */
export type ManifestAction = chrome.runtime.ManifestV3["action"];
/** Alias for the `permissions` property within a Chrome Manifest V3. */
export type ManifestPermissions = chrome.runtime.ManifestV3["permissions"];
/** Alias for the `options_ui` property within a Chrome Manifest V3. */
export type ManifestOptionsUI = chrome.runtime.ManifestV3["options_ui"];
/** Alias for the `chrome_url_overrides` property within a Chrome Manifest V3. */
chrome.runtime.ManifestV3['web_accessible_resources']
export type ManifestCommands = chrome.runtime.ManifestV3['commands']
export type ManifestAction = chrome.runtime.ManifestV3['action']
export type ManifestPermissions = chrome.runtime.ManifestV3['permissions']
export type ManifestOptionsUI = chrome.runtime.ManifestV3['options_ui']
export type ManifestURLOverrides =
chrome.runtime.ManifestV3["chrome_url_overrides"];
chrome.runtime.ManifestV3['chrome_url_overrides']
/**
* Creates a type that accepts a string literal `T` in either its capitalized or lowercase form.
* Useful for defining types that should be case-insensitive for specific known strings.
* @template T - A string literal type.
*/
export type BrowserName<T extends string> = Capitalize<T> | Lowercase<T>;
/**
* Creates a record type where both keys and values are derived from a string literal `T`,
* specifically using `BrowserName<T>` which allows for capitalized or lowercase forms.
* This could be used to define an object where, for example, keys are 'Chrome' or 'chrome'
* and values are also 'Chrome' or 'chrome'.
* @template T - A string literal type, typically representing a browser name.
*/
export type BrowserName<T extends string> = Capitalize<T> | Lowercase<T>
export type BrowserEnumType<T extends string> = {
[browser in BrowserName<T>]: BrowserName<T>;
};
[browser in BrowserName<T>]: BrowserName<T>
}
/**
* Represents the target browser for a build, allowing for various casings of browser names
* (e.g., "chrome", "Chrome", "CHROME") through the `AnyCase<Browser>` utility type.
* `Browser` itself is a union of specific browser name strings (e.g., "Chrome" | "Firefox").
*/
export type BuildMode = AnyCase<Browser>;
/**
* Defines an object structure that pairs a web extension `Manifest`
* with its target `browser` (represented as `AnyCase<Browser>`).
* This is commonly used in build processes to manage configurations for different browsers.
*/
export type BuildMode = AnyCase<Browser>
export type BuildTarget = {
manifest: Manifest;
browser: AnyCase<Browser>;
};
/**
* Defines the configuration options for a build process.
* @property {"build" | "serve"} [command] - The type of build command (e.g., 'build' for production, 'serve' for development).
* @property {AnyCase<Browser> | string | undefined} [mode] - The target build mode, typically a browser name (allowing various casings)
* or potentially other custom mode strings.
*/
manifest: Manifest
browser: AnyCase<Browser>
}
export type BuildConfig = {
command?: "build" | "serve";
mode?: AnyCase<Browser> | string | undefined;
};
command?: 'build' | 'serve'
mode?: AnyCase<Browser> | string | undefined
}
/**
* Defines the structure for repository information, commonly found in `package.json`.
* @property {string} type - The type of the repository (e.g., "git").
* @property {string} [url] - The URL of the repository.
* @property {Bugs} [bugs] - An object containing information about where to report bugs.
*/
export interface Repository {
type: string;
url?: string;
bugs?: Bugs;
type: string
url?: string
bugs?: Bugs
}
/**
* Defines the structure for bug reporting information, often part of the `Repository` interface.
* @property {string} [url] - The URL of the issue tracker.
* @property {string} [email] - The email address for reporting bugs.
*/
export interface Bugs {
url?: string;
email?: string;
url?: string
email?: string
}
/**
* A string literal union type representing supported browser names, derived from the values of `BrowserEnum`.
* e.g., "Chrome" | "Firefox" | ...
*/
export type Browser = ObjectValues<typeof BrowserEnum>;
export type Browser = (typeof BrowserEnum)[keyof typeof BrowserEnum]
export const Browser: AnyCase<Browser> = createEnum(BrowserEnum)
/**
* A constant intended to provide access to browser names, potentially in various casings.
* Its type `AnyCase<Browser>` suggests it can be used where case-insensitivity for browser names is needed.
* The `createEnum(BrowserEnum)` call aims to produce a representation of browser names from `BrowserEnum`.
* Note: `createEnum` from `lib/utils.ts` has a declared return type of `ObjectValues<T>` (a union of values),
* while its implementation uses `Object.values()` which returns an array. This constant will hold the
* runtime array value, but its JSDoc type refers to the more restrictive `AnyCase<Browser>` union type.
*/
export const Browser: AnyCase<Browser> = createEnum(BrowserEnum);
/**
* A string literal union type representing supported package managers, derived from the values of `PackageManagerEnum`.
* e.g., "Bun" | "PnPm" | "Npm" | "Yarn"
*/
export type PackageManager = ObjectValues<typeof PackageManagerEnum>;
/**
* A constant intended to provide access to package manager names, potentially in various casings.
* Its type `AnyCase<PackageManager>` suggests it can be used where case-insensitivity for package manager names is needed.
* Utilizes `createEnum(PackageManagerEnum)`. Refer to notes on `Browser` constant regarding `createEnum` behavior.
*/
export type PackageManager =
(typeof PackageManagerEnum)[keyof typeof PackageManagerEnum]
export const PackageManager: AnyCase<PackageManager> =
createEnum(PackageManagerEnum);
createEnum(PackageManagerEnum)
/**
* A string literal union type representing supported JavaScript frameworks, derived from the values of `FrameworkEnum`.
* e.g., "React" | "Vanilla" | ...
*/
export type Framework = ObjectValues<typeof FrameworkEnum>;
/**
* A constant intended to provide access to framework names, potentially in various casings.
* Its type `AnyCase<Framework>` suggests it can be used where case-insensitivity for framework names is needed.
* Utilizes `createEnum(FrameworkEnum)`. Refer to notes on `Browser` constant regarding `createEnum` behavior.
*/
export const Framework: AnyCase<Framework> = createEnum(FrameworkEnum);
export type Framework = (typeof FrameworkEnum)[keyof typeof FrameworkEnum]
export const Framework: AnyCase<Framework> = createEnum(FrameworkEnum)
/**
* A string literal union type representing supported styling options, derived from the values of `StyleEnum`.
* e.g., "Tailwind"
*/
export type Style = ObjectValues<typeof StyleEnum>;
/**
* A constant intended to provide access to style option names, potentially in various casings.
* Its type `AnyCase<Style>` suggests it can be used where case-insensitivity for style names is needed.
* Utilizes `createEnum(StyleEnum)`. Refer to notes on `Browser` constant regarding `createEnum` behavior.
*/
export const Style: AnyCase<Style> = createEnum(StyleEnum);
export type Style = (typeof StyleEnum)[keyof typeof StyleEnum]
export const Style: AnyCase<Style> = createEnum(StyleEnum)
/**
* A string literal union type representing supported programming languages, derived from the values of `LanguageEnum`.
* e.g., "TypeScript" | "JavaScript"
*/
export type Language = ObjectValues<typeof LanguageEnum>;
/**
* A constant intended to provide access to programming language names, potentially in various casings.
* Its type `AnyCase<Language>` suggests it can be used where case-insensitivity for language names is needed.
* Utilizes `createEnum(LanguageEnum)`. Refer to notes on `Browser` constant regarding `createEnum` behavior.
*/
export const Language: AnyCase<Language> = createEnum(LanguageEnum);
export type Language = (typeof LanguageEnum)[keyof typeof LanguageEnum]
export const Language: AnyCase<Language> = createEnum(LanguageEnum)
+6 -69
View File
@@ -1,84 +1,21 @@
/**
* Extracts a union type of all values from the properties of an object type `T`.
*
* @template T - An object type (typically a Record or an enum-like object).
* @example
* type MyObject = { a: "foo", b: "bar", c: 123 };
* type MyObjectValues = ObjectValues<MyObject>; // "foo" | "bar" | 123
*/
export type ObjectValues<T> = T[keyof T];
export type ObjectValues<T> = T[keyof T]
/**
* Creates a union of an object's string values, often used to represent the set of possible values for an enum-like object.
* Note: The implementation `Object.values(enumObj) as unknown as ObjectValues<T>` returns an array at runtime,
* but the declared return type `ObjectValues<T>` is a union of the object's property values.
* This type signature suggests it's intended to represent the set of possible string values from `enumObj`.
*
* @template T - An object type where keys are strings and values are strings (e.g., `const MyEnum = { VAL_A: "A", VAL_B: "B" }`).
* @param {T} enumObj - The object from which to extract values.
* @returns {ObjectValues<T>} A union type representing all possible string values of the `enumObj`.
* For example, if `enumObj` is `{ A: "valA", B: "valB" }`, the return type is `"valA" | "valB"`.
* (Runtime behavior of `Object.values()` is to return an array like `["valA", "valB"]`).
*/
export function createEnum<T extends Record<string, string>>(enumObj: T) {
return Object.values(enumObj) as unknown as ObjectValues<T>;
return Object.values(enumObj) as unknown as ObjectValues<T>
}
/**
* Creates a union type that includes various case formats (uppercase, lowercase, capitalized, uncapitalized)
* of a given string literal type `T`.
*
* @template T - A string literal type.
* @example
* type MyString = "example";
* type MyStringAnyCase = AnyCase<MyString>; // "EXAMPLE" | "example" | "Example" | "example" (Uncapitalize<"Example"> is "example")
*/
export type AnyCase<T extends string> =
| Uppercase<T>
| Lowercase<T>
| Capitalize<T>
| Uncapitalize<T>;
| Uncapitalize<T>
/**
* Creates a union type that includes various case formats (uppercase, lowercase, capitalized, uncapitalized)
* of the union of two given string literal types `T` and `K`.
* This is useful for representing a combined set of related string constants where case variations are permitted for each.
*
* @template T - A string literal type.
* @template K - Another string literal type.
* @example
* type Lang1 = "english";
* type Lang2 = "french";
* type CombinedLangsAnyCase = AnyCaseLanguage<Lang1, Lang2>;
* // Result includes: "ENGLISH" | "english" | "English" | "FRENCH" | "french" | "French" etc.
* // for all case variations of "english" and "french".
*/
export type AnyCaseLanguage<T extends string, K extends string> =
| Uppercase<T | K>
| Lowercase<T | K>
| Capitalize<T | K>
| Uncapitalize<T | K>;
| Uncapitalize<T | K>
/**
* Extracts a new object type containing only the keys of `T` whose properties are optional
* (i.e., their type includes `undefined`). The values associated with these keys retain their original types.
*
* @template T - An object type.
* @example
* type MyObject = {
* requiredProp: string;
* optionalProp?: number;
* anotherOptional?: boolean | undefined;
* nullProp: string | null;
* };
* type MyOptionalProps = OptionalKeys<MyObject>;
* // MyOptionalProps would be conceptually equivalent to:
* // {
* // optionalProp?: number;
* // anotherOptional?: boolean | undefined;
* // }
* // The actual resulting type is an object type with only these optional keys.
*/
export type OptionalKeys<T> = {
[K in keyof T as undefined extends T[K] ? K : never]: T[K];
};
[K in keyof T as undefined extends T[K] ? K : never]: T[K]
}
+18 -27
View File
@@ -1,15 +1,12 @@
{
"name": "betterseqtaplus",
"version": "3.6.3",
"version": "3.4.6.1",
"type": "module",
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!",
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development add add heaps more features!",
"browserslist": "> 0.5%, last 2 versions, not dead",
"scripts": {
"postinstall": "node scripts/copy-pdfjs-assets.mjs",
"autoaudit": "npm audit && npm audit fix && npm run build",
"dev": "cross-env MODE=chrome vite dev",
"dev:firefox": "cross-env MODE=firefox vite build --watch",
"compile": "npm i && npm run build",
"build": "cross-env MODE=chrome vite build && cross-env MODE=firefox vite build",
"build:chrome": "cross-env MODE=chrome vite build",
"build:firefox": "cross-env MODE=firefox vite build",
@@ -31,26 +28,26 @@
"keywords": [],
"author": {
"name": "SethBurkart123",
"email": "betterseqta.plus@gmail.com",
"url": "https://github.com/BetterSEQTA/BetterSEQTA-Plus"
"email": "betterseqta@betterseqta.com",
"url": "https://github.com/BetterSEQTA/BetterSEQTA-plus"
},
"license": "MIT",
"devDependencies": {
"@babel/plugin-transform-runtime": "^7.26.9",
"@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.1.2",
"@crxjs/vite-plugin": "^2.4.0",
"@types/mime-types": "^3.0.1",
"@bedframe/cli": "^0.0.91",
"@crxjs/vite-plugin": "2.0.0-beta.25",
"@types/mime-types": "^2.1.4",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"cross-env": "^10.0.0",
"dependency-cruiser": "^17.0.1",
"eslint": "^9.33.0",
"cross-env": "^7.0.3",
"dependency-cruiser": "^16.10.0",
"eslint": "9.22.0",
"glob": "^11.0.1",
"mime-types": "^3.0.1",
"mime-types": "^2.1.35",
"prettier": "^3.5.3",
"process": "^0.11.10",
"publish-browser-extension": "^4.0.4",
"publish-browser-extension": "^3.0.0",
"sass": "^1.85.1",
"sass-loader": "^16.0.5",
"semver": "^7.7.1",
@@ -58,7 +55,6 @@
"url": "^0.11.4"
},
"dependencies": {
"@bedframe/core": "^0.1.0",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.0",
"@codemirror/lang-css": "^6.3.1",
@@ -66,28 +62,25 @@
"@codemirror/search": "^6.5.10",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.4",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/forms": "^0.5.10",
"@tsconfig/svelte": "^5.0.4",
"@types/chrome": "^0.1.4",
"@types/chrome": "^0.0.308",
"@types/color": "^4.2.0",
"@types/lodash": "^4.17.16",
"@types/node": "^24.3.0",
"@types/qrcode": "^1.5.6",
"@types/node": "^22.13.10",
"@types/sortablejs": "^1.15.8",
"@types/uuid": "^10.0.0",
"@types/webextension-polyfill": "^0.12.3",
"@uiw/codemirror-extensions-color": "^4.23.10",
"@uiw/codemirror-theme-github": "^4.23.10",
"autoprefixer": "^10.4.21",
"canvas-confetti": "^1.9.3",
"client-vector-search": "../client-vector-search",
"codemirror": "^6.0.1",
"color": "^5.0.0",
"dompurify": "^3.2.4",
"embeddia": "^1.2.1",
"embla-carousel-autoplay": "^8.5.2",
"embla-carousel-svelte": "^8.5.2",
"esbuild": "^0.25.3",
"events": "^3.3.0",
"flexsearch": "^0.8.147",
"fuse.js": "^7.1.0",
@@ -97,18 +90,16 @@
"mathjs": "^14.4.0",
"million": "^3.1.11",
"motion": "^12.4.12",
"pdfjs-dist": "^5.4.530",
"postcss": "^8.5.3",
"qrcode": "^1.5.4",
"react": "17",
"react-best-gradient-color-picker": "3.0.11",
"react-dom": "17",
"rss-parser": "^3.13.0",
"sortablejs": "^1.15.6",
"svelte": "^5.46.4",
"svelte": "^5.22.6",
"typescript": "^5.8.2",
"uuid": "^11.1.0",
"vite": "^8.0.5",
"vite": "^6.2.1",
"webextension-polyfill": "^0.12.0"
}
}
-17
View File
@@ -1,17 +0,0 @@
import { copyFileSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const pdfjsRoot = join(root, "node_modules", "pdfjs-dist");
const outDir = join(root, "src", "public", "resources", "pdfjs");
mkdirSync(outDir, { recursive: true });
copyFileSync(
join(pdfjsRoot, "build", "pdf.worker.min.mjs"),
join(outDir, "pdf.worker.min.mjs"),
);
copyFileSync(
join(pdfjsRoot, "legacy", "build", "pdf.min.mjs"),
join(outDir, "pdf.legacy.min.mjs"),
);
+126
View File
@@ -0,0 +1,126 @@
--- a/Users/sethburkart/Documents/Coding/betterseqta-plus/src/plugins/core/settings.ts
+++ b/Users/sethburkart/Documents/Coding/betterseqta-plus/src/plugins/core/settings.ts
@@ -2,7 +2,7 @@
// Base interfaces for our settings
interface BaseSettingOptions {
- title: string;
+ readonly title: string; // Mark as readonly where appropriate
description?: string;
}
@@ -11,21 +11,21 @@
}
interface StringSettingOptions extends BaseSettingOptions {
- default: string;
+ readonly default: string;
maxLength?: number;
pattern?: string;
}
interface NumberSettingOptions extends BaseSettingOptions {
- default: number;
+ readonly default: number;
min?: number;
max?: number;
step?: number;
}
interface SelectSettingOptions<T extends string> extends BaseSettingOptions {
- default: T;
- options: readonly T[];
+ readonly default: T;
+ readonly options: readonly T[];
}
// The actual decorators
@@ -34,14 +34,16 @@
// Ensure the settings property exists on the constructor's prototype
const proto = target.constructor.prototype;
if (!proto.hasOwnProperty('settings')) {
- proto.settings = {};
+ // Initialize with a base type that can be extended
+ Object.defineProperty(proto, 'settings', {
+ value: {},
+ writable: true, // Allows adding properties
+ configurable: true,
+ enumerable: true
+ });
}
-
+
// Add the setting to the prototype's settings object with const assertion
proto.settings[propertyKey] = {
type: 'boolean' as const,
...options
};
- };
-}
-
-export function StringSetting(options: StringSettingOptions): PropertyDecorator {
- return (target: Object, propertyKey: string | symbol) => {
- // Ensure the settings property exists on the constructor's prototype
- const proto = target.constructor.prototype;
- if (!proto.hasOwnProperty('settings')) {
- proto.settings = {};
- }
-
- // Add the setting to the prototype's settings object with const assertion
- proto.settings[propertyKey] = {
- type: 'string' as const,
- ...options
- };
};
}
@@ -50,14 +52,16 @@
// Ensure the settings property exists on the constructor's prototype
const proto = target.constructor.prototype;
if (!proto.hasOwnProperty('settings')) {
- proto.settings = {};
+ Object.defineProperty(proto, 'settings', {
+ value: {},
+ writable: true,
+ configurable: true,
+ enumerable: true
+ });
}
-
+
// Add the setting to the prototype's settings object with const assertion
proto.settings[propertyKey] = {
type: 'number' as const,
...options
};
- };
-}
-
-export function SelectSetting<T extends string>(options: SelectSettingOptions<T>): PropertyDecorator {
- return (target: Object, propertyKey: string | symbol) => {
- // Ensure the settings property exists on the constructor's prototype
- const proto = target.constructor.prototype;
- if (!proto.hasOwnProperty('settings')) {
- proto.settings = {};
- }
-
- // Add the setting to the prototype's settings object with const assertion
- proto.settings[propertyKey] = {
- type: 'select' as const,
- ...options
- };
};
}
// Base plugin class that handles settings
export abstract class BasePlugin<T extends PluginSettings = PluginSettings> {
// The settings property will be populated by decorators
- settings!: T;
-
+ // Keep the instance property and constructor logic as is,
+ // as changing it would require changing animated-background/index.ts
+ settings!: T; // Use definite assignment assertion
+
constructor() {
// Copy settings from the prototype to the instance
// This ensures that each instance has its own settings object
+30 -99
View File
@@ -1,134 +1,65 @@
import {
initializeSettingsState,
settingsState,
} from "@/seqta/utils/listeners/SettingsState";
import documentLoadCSS from "@/css/documentload.scss?inline";
import icon48 from "@/resources/icons/icon-48.png?base64";
import browser from "webextension-polyfill";
} from "@/seqta/utils/listeners/SettingsState"
import documentLoadCSS from "@/css/documentload.scss?inline"
import icon48 from "@/resources/icons/icon-48.png?base64"
import browser from "webextension-polyfill"
import * as plugins from "@/plugins";
import { main } from "@/seqta/main";
import { delay } from "./seqta/utils/delay";
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
import * as plugins from "@/plugins"
import { main } from "@/seqta/main"
function registerFetchSeqtaAppLinkListener() {
browser.runtime.onMessage.addListener((request, _sender, sendResponse) => {
if (request?.type !== "fetchSeqtaAppLink") return false;
void (async () => {
try {
const res = await fetch(`${location.origin}/seqta/student/load/profile`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({}),
});
const data = await res.json();
const statusOk = data?.status === "200" || data?.status === 200;
const raw = data?.payload?.app_link;
const appLink = typeof raw === "string" && raw.length > 0 ? raw : null;
sendResponse({ appLink: statusOk ? appLink : null });
} catch {
sendResponse({ appLink: null });
}
})();
return true;
});
}
export let MenuOptionsOpen = false;
export let MenuOptionsOpen = false
var IsSEQTAPage = false;
let hasSEQTAText = false;
var IsSEQTAPage = false
let hasSEQTAText = false
// This check is placed outside of the document load event due to issues with EP (https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues/84)
if (document.childNodes[1]) {
hasSEQTAText =
document.childNodes[1].textContent?.includes(
"Copyright (c) SEQTA Software",
) ?? false;
init();
) ?? false
init()
}
async function init() {
if (
hasSEQTAText &&
(document.title.includes("SEQTA Learn") ||
document.title.includes("SEQTA Engage")) &&
!IsSEQTAPage
) {
IsSEQTAPage = true;
console.info("[BetterSEQTA+] Verified SEQTA Page");
const hasSEQTATitle = document.title.includes("SEQTA Learn")
if (typeof window !== "undefined" && window === window.top) {
void browser.runtime.sendMessage({ type: "cloudSettingsPoll" }).catch(() => {});
}
registerFetchSeqtaAppLinkListener();
const documentLoadStyle = document.createElement("style");
documentLoadStyle.textContent = documentLoadCSS;
document.head.appendChild(documentLoadStyle);
replaceIcons();
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (
mutation.type === "attributes" &&
mutation.target instanceof HTMLLinkElement &&
mutation.target.rel.includes("icon") &&
mutation.attributeName === "href"
) {
replaceIcons();
return;
}
}
});
observer.observe(document.head, {
subtree: true,
attributes: true,
attributeFilter: ["href"],
});
if (hasSEQTAText && hasSEQTATitle && !IsSEQTAPage) { // Verify we are on a SEQTA page
IsSEQTAPage = true
console.info("[BetterSEQTA+] Verified SEQTA Page")
const documentLoadStyle = document.createElement("style")
documentLoadStyle.textContent = documentLoadCSS
document.head.appendChild(documentLoadStyle)
const icon = document.querySelector('link[rel*="icon"]')! as HTMLLinkElement
icon.href = icon48 // Change the icon
try {
await initializeSettingsState();
await initializeSettingsState()
if (typeof settingsState.onoff === "undefined") {
await browser.runtime.sendMessage({ type: "setDefaultStorage" });
await delay(5);
browser.runtime.sendMessage({ type: "setDefaultStorage" })
}
await main();
plugins.Monofile();
await main()
if (settingsState.onoff) {
await plugins.initializePlugins();
}
// Initialize legacy plugins
plugins.Monofile()
if (settingsState.devMode) {
initializeHideSensitiveToggle();
// Initialize new plugin system
await plugins.initializePlugins();
}
console.info(
"[BetterSEQTA+] Successfully initialised BetterSEQTA+, starting to load assets.",
);
} catch (error) {
console.error(error);
)
} catch (error: any) {
console.error(error)
}
}
}
function replaceIcons() {
document
.querySelectorAll<HTMLLinkElement>('link[rel*="icon"]')
.forEach((link) => {
if (link.href !== icon48) {
link.href = icon48;
}
});
}
+144 -414
View File
@@ -1,414 +1,65 @@
import browser from "webextension-polyfill";
import browser from 'webextension-polyfill'
import type { SettingsState } from "@/types/storage";
import { fetchNews } from "./background/news";
import {
initCloudSettingsAutoSync,
performCloudSettingsDownloadWithRetry,
performCloudSettingsUploadWithRetry,
runCloudSettingsPoll,
} from "./background/cloudSettingsAutoSync";
import { fetchNews } from './background/news';
function reloadSeqtaPages() {
const result = browser.tabs.query({});
const result = browser.tabs.query({})
function open (tabs: any) {
for (let tab of tabs) {
if (
tab.title?.includes("SEQTA Learn") ||
tab.title?.includes("SEQTA Engage")
) {
if (tab.title.includes('SEQTA Learn')) {
browser.tabs.reload(tab.id);
}
}
}
result.then(open, console.error);
result.then(open, console.error)
}
/** Callback for sending a response back to the message sender */
type MessageSender = { (response?: unknown): void };
// @ts-ignore
browser.runtime.onMessage.addListener((request: any, _: any, sendResponse: (response?: any) => void) => {
function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
const { token } = request;
const apiUrl = `https://betterseqta.org/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`;
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(apiUrl, { cache: "no-store", headers })
.then((r) => r.json())
.then(sendResponse)
.catch((err) => {
console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message);
fetch(githubUrl, { cache: "no-store" })
.then((r) => r.json())
.then((data) => sendResponse({ success: true, data: { themes: data.themes ?? [] } }))
.catch((fallbackErr) => {
console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr);
sendResponse({ success: false, error: fallbackErr?.message });
switch (request.type) {
case 'reloadTabs':
reloadSeqtaPages();
break;
case 'extensionPages':
browser.tabs.query({}).then(function (tabs) {
for (let tab of tabs) {
if (tab.url?.includes('chrome-extension://')) {
browser.tabs.sendMessage(tab.id!, request);
}
}
});
break;
case 'currentTab':
browser.tabs.query({ active: true, currentWindow: true }).then(function (tabs) {
browser.tabs.sendMessage(tabs[0].id!, request).then(function (response) {
sendResponse(response);
});
});
return true;
case 'githubTab':
browser.tabs.create({ url: 'github.com/BetterSEQTA/BetterSEQTA-Plus' });
break;
case 'setDefaultStorage':
SetStorageValue(DefaultValues);
break;
case 'sendNews':
fetchNews(request.source ?? 'australia', sendResponse);
return true;
default:
console.log('Unknown request type');
}
function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boolean {
const { themeId, token } = request;
if (!themeId || typeof themeId !== "string") {
sendResponse({ success: false, error: "Missing themeId" });
return false;
}
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(`https://betterseqta.org/api/themes/${themeId}`, { cache: "no-store", headers })
.then((r) => r.json())
.then(sendResponse)
.catch((err) => {
console.error("[Background] fetchThemeDetails error:", err);
sendResponse({ success: false, error: err?.message });
});
return true;
}
function handleFetchFromUrl(request: any, sendResponse: MessageSender): boolean {
const { url } = request;
if (!url || typeof url !== "string") {
sendResponse({ error: "Missing url" });
return false;
}
fetch(url, { cache: "no-store" })
.then((r) => r.json())
.then((data) => sendResponse({ data }))
.catch((err) => {
console.error("[Background] fetchFromUrl error:", err);
sendResponse({ error: err?.message });
});
return true;
}
async function parseJsonResponse(r: Response): Promise<any> {
const text = await r.text();
try {
return text ? JSON.parse(text) : {};
} catch {
return {};
}
}
function handleCloudReserveClient(request: any, sendResponse: MessageSender): boolean {
const redirect_uri = request.redirect_uri ?? "https://accounts.betterseqta.org/auth/bsplus/callback";
fetch("https://accounts.betterseqta.org/api/bsplus/client/reserve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ redirect_uri }),
})
.then(async (r) => {
const data = await parseJsonResponse(r);
if (!r.ok) sendResponse({ error: data?.error ?? `Reserve failed (${r.status})` });
else sendResponse(data);
})
.catch((err) => {
console.error("[Background] cloudReserveClient error:", err);
sendResponse({ error: err?.message ?? "Network error" });
});
return true;
}
function handleCloudLogin(request: any, sendResponse: MessageSender): boolean {
const { client_id, redirect_uri, login, password } = request;
if (!client_id || !redirect_uri || !login || !password) {
sendResponse({ error: "Missing client_id, redirect_uri, login, or password" });
return false;
}
fetch("https://accounts.betterseqta.org/api/bsplus/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client_id, redirect_uri, login, password }),
})
.then(async (r) => {
const data = await parseJsonResponse(r);
if (!r.ok) sendResponse({ error: data?.error ?? "Login failed" });
else sendResponse(data);
})
.catch((err) => {
console.error("[Background] cloudLogin error:", err);
sendResponse({ error: err?.message ?? "Network error" });
});
return true;
}
function handleCloudStartLogin(request: any, sendResponse: MessageSender): boolean {
const { client_id, redirect_uri } = request;
if (!client_id || !redirect_uri) {
sendResponse({ error: "Missing client_id or redirect_uri" });
return true;
}
const authorizeUrl = `https://accounts.betterseqta.org/login?redirect=${encodeURIComponent(`/oauth/authorize?client_id=${client_id}&redirect_uri=${encodeURIComponent(redirect_uri)}`)}`;
browser.tabs.create({ url: authorizeUrl }).then(() => {
sendResponse({ success: true });
}).catch((err) => {
console.error("[Background] cloudStartLogin error:", err);
sendResponse({ error: err?.message ?? "Failed to open login page" });
});
return true;
}
const CALLBACK_URL_PREFIX = "https://accounts.betterseqta.org/auth/bsplus/callback";
function initCloudLoginCallbackListener() {
browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.url && changeInfo.url.startsWith(CALLBACK_URL_PREFIX)) {
try {
const url = new URL(changeInfo.url);
const token = url.searchParams.get("token");
const refreshToken = url.searchParams.get("refresh_token");
const userId = url.searchParams.get("user_id");
if (token && refreshToken) {
// Store tokens
void (async () => {
try {
await browser.storage.local.set({
bsplus_token: token,
bsplus_refresh_token: refreshToken,
});
// Fetch full user info
const userRes = await fetch("https://accounts.betterseqta.org/api/auth/me", {
headers: { Authorization: `Bearer ${token}` },
});
if (userRes.ok) {
const user = await userRes.json();
await browser.storage.local.set({ bsplus_user: user });
} else if (userId) {
await browser.storage.local.set({ bsplus_user: { id: userId } });
}
// Trigger cloud settings download
void performCloudSettingsDownloadWithRetry(token).catch((err) => {
console.warn("[Background] Cloud settings download after login:", err);
});
} catch (err) {
console.error("[Background] Failed to process login callback:", err);
}
})();
// Close the callback tab
void browser.tabs.remove(tabId);
}
} catch (err) {
console.error("[Background] Error parsing callback URL:", err);
}
}
});
}
initCloudLoginCallbackListener();
function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean {
const { refresh_token, client_id } = request;
if (!refresh_token || !client_id) {
sendResponse({ error: "Missing refresh_token or client_id" });
return false;
}
fetch("https://accounts.betterseqta.org/api/bsplus/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token, client_id }),
})
.then(async (r) => {
const data = await parseJsonResponse(r);
if (!r.ok) sendResponse({ error: data?.error ?? "Refresh failed" });
else sendResponse(data);
})
.catch((err) => {
console.error("[Background] cloudRefresh error:", err);
sendResponse({ error: err?.message ?? "Network error" });
});
return true;
}
function handleCloudSettingsUpload(request: any, sendResponse: MessageSender): boolean {
void (async () => {
try {
const token = request.token as string | undefined;
if (!token) {
sendResponse({ success: false, error: "Not authenticated" });
return;
}
const res = await performCloudSettingsUploadWithRetry(token);
sendResponse({
success: res.success,
error: res.error,
updated_at: res.updated_at,
});
} catch (err) {
console.error("[Background] cloudSettingsUpload error:", err);
sendResponse({
success: false,
error: err instanceof Error ? err.message : "Upload failed",
});
}
})();
return true;
}
function handleCloudSettingsDownload(request: any, sendResponse: MessageSender): boolean {
void (async () => {
try {
const token = request.token as string | undefined;
if (!token) {
sendResponse({ success: false, error: "Not authenticated" });
return;
}
const res = await performCloudSettingsDownloadWithRetry(token);
sendResponse({
success: res.success,
notFound: res.notFound,
error: res.error,
updated_at: res.updated_at,
});
} catch (err) {
console.error("[Background] cloudSettingsDownload error:", err);
sendResponse({
success: false,
error: err instanceof Error ? err.message : "Download failed",
});
}
})();
return true;
}
function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean {
const { themeId, token, action } = request;
if (!themeId || !token) {
sendResponse({ success: false, error: "Theme ID and token required" });
return false;
}
const isFavorite = action === "favorite";
fetch(`https://betterseqta.org/api/themes/${themeId}/favorite`, {
method: isFavorite ? "POST" : "DELETE",
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then(sendResponse)
.catch((err) => {
console.error("[Background] cloudFavorite error:", err);
sendResponse({ success: false, error: err?.message });
});
return true;
}
/** Handler for a message type; receives request, sendResponse, and optional sender (for tab routing) */
type MessageHandler = {
(request: any, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender): boolean | void;
};
function isSeqtaOrigin(origin: string): boolean {
try {
const u = new URL(origin);
return u.hostname.includes("seqta") || u.hostname.endsWith(".edu.au");
} catch {
return false;
}
}
const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
reloadTabs: () => reloadSeqtaPages(),
extensionPages: (req) => {
browser.tabs.query({}).then((tabs) => {
for (const tab of tabs) {
if (tab.url?.includes("chrome-extension://")) browser.tabs.sendMessage(tab.id!, req);
}
});
},
currentTab: (req, sendResponse) => {
browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => {
browser.tabs.sendMessage(tabs[0].id!, req).then(sendResponse);
});
return true;
},
githubTab: () => {
void browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" });
},
setDefaultStorage: () => SetStorageValue(getDefaultValues()),
sendNews: (req, sendResponse) => {
fetchNews(req.source ?? "australia", sendResponse);
return true;
},
fetchThemes: handleFetchThemes,
fetchThemeDetails: handleFetchThemeDetails,
fetchFromUrl: handleFetchFromUrl,
cloudReserveClient: handleCloudReserveClient,
cloudLogin: handleCloudLogin,
cloudStartLogin: handleCloudStartLogin,
cloudRefresh: handleCloudRefresh,
cloudFavorite: handleCloudFavorite,
cloudSettingsUpload: handleCloudSettingsUpload,
cloudSettingsDownload: handleCloudSettingsDownload,
cloudSettingsPoll: () => {
void runCloudSettingsPoll();
return false;
},
getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => {
(async () => {
try {
let tabId = sender?.tab?.id;
let originForCheck: string | undefined = req.baseUrl;
if (tabId == null) {
const tabs = await browser.tabs.query({ active: true, lastFocusedWindow: true });
const tab = tabs[0];
if (!tab?.id || !tab.url) {
sendResponse({ appLink: null });
return;
}
tabId = tab.id;
if (!originForCheck) originForCheck = new URL(tab.url).origin;
} else if (!originForCheck && sender?.tab?.url) {
originForCheck = new URL(sender.tab.url).origin;
}
if (!originForCheck || !isSeqtaOrigin(originForCheck)) {
sendResponse({ appLink: null });
return;
}
const reply = (await browser.tabs.sendMessage(tabId, { type: "fetchSeqtaAppLink" })) as
| { appLink?: string | null }
| undefined;
const appLink = typeof reply?.appLink === "string" && reply.appLink.length > 0 ? reply.appLink : null;
sendResponse({ appLink });
} catch (err) {
console.error("[Background] getSeqtaSession error:", err);
sendResponse({ appLink: null });
}
})();
return true;
},
};
browser.runtime.onMessage.addListener(
// @ts-ignore - OnMessageListener expects literal true for async, we return boolean
(request: any, sender: browser.Runtime.MessageSender, sendResponse: MessageSender) => {
const handler = MESSAGE_HANDLERS[request.type];
if (handler) {
const result = handler(request, sendResponse, sender);
return result === true;
}
console.log("Unknown request type");
return false;
},
);
function detectLowEndDevice(): boolean {
// Check for low-end hardware indicators
const lowCoreCount = navigator.hardwareConcurrency && navigator.hardwareConcurrency < 4;
const lowMemory = (navigator as any).deviceMemory && (navigator as any).deviceMemory <= 2;
return lowCoreCount || lowMemory;
}
function getDefaultValues(): SettingsState {
const isLowEndDevice = detectLowEndDevice();
return {
const DefaultValues: SettingsState = {
onoff: true,
animatedbk: true,
bksliderinput: "50",
@@ -435,38 +86,67 @@ function getDefaultValues(): SettingsState {
},
menuorder: [],
subjectfilters: {},
selectedTheme: "",
selectedColor:
"linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)",
originalSelectedColor: "",
selectedTheme: '',
selectedColor: 'linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)',
originalSelectedColor: '',
DarkMode: true,
animations: !isLowEndDevice,
assessmentsAverage: false,
defaultPage: "home",
animations: true,
assessmentsAverage: true,
defaultPage: 'home',
shortcuts: [
{
name: "Outlook",
name: 'YouTube',
enabled: false,
},
{
name: 'Outlook',
enabled: true,
},
{
name: "Office",
name: 'Office',
enabled: true,
},
{
name: "Google",
name: 'Spotify',
enabled: false,
},
{
name: 'Google',
enabled: true,
},
{
name: 'DuckDuckGo',
enabled: false,
},
{
name: 'Cool Math Games',
enabled: false,
},
{
name: 'SACE',
enabled: false,
},
{
name: 'Google Scholar',
enabled: false,
},
{
name: 'Gmail',
enabled: false,
},
{
name: 'Netflix',
enabled: false,
},
{
name: 'Education Perfect',
enabled: false,
},
],
customshortcuts: [],
lettergrade: false,
newsSource: "australia",
iconOnlySidebar: false,
adaptiveThemeColour: false,
adaptiveThemeGradient: false,
adaptiveThemeColourTransition: true,
autoCloudSettingsSync: true,
newsSource: 'australia',
};
}
function SetStorageValue(object: any) {
for (var i in object) {
@@ -474,13 +154,63 @@ function SetStorageValue(object: any) {
}
}
browser.runtime.onInstalled.addListener(function (event) {
browser.storage.local.remove(["justupdated"]);
browser.storage.local.remove(["data"]);
function convertBksliderToSpeed(bksliderinput: number): number {
const minBase = 50;
const maxBase = 150;
if (event.reason == "install" || event.reason == "update") {
const scaledValue = 2 + ((maxBase - bksliderinput) / (maxBase - minBase)) ** 4;
const baseSpeed = 3;
const speed = baseSpeed / scaledValue;
return speed;
}
async function migrateLegacySettings() {
const storage = await browser.storage.local.get(null) as unknown as SettingsState;
// Animated Background Migration
if ('animatedbk' in storage || 'bksliderinput' in storage) {
const animatedSettings = {
enabled: storage.animatedbk ?? true,
speed: storage.bksliderinput ? convertBksliderToSpeed(parseFloat(storage.bksliderinput)) : 1
};
await browser.storage.local.set({ 'plugin.animated-background.settings': animatedSettings });
}
// Assessments Average Migration
if ('assessmentsAverage' in storage || 'lettergrade' in storage) {
const assessmentsSettings = {
enabled: storage.assessmentsAverage ?? true,
lettergrade: storage.lettergrade ?? false
};
await browser.storage.local.set({ 'plugin.assessments-average.settings': assessmentsSettings });
}
if ('selectedTheme' in storage) {
const themesSettings = { enabled: true };
await browser.storage.local.set({ 'plugin.themes.settings': themesSettings });
}
if (storage.notificationCollector !== false) {
await browser.storage.local.set({ 'plugin.notificationCollector.settings': { enabled: true } });
} else {
await browser.storage.local.set({ 'plugin.notificationCollector.settings': { enabled: false } });
}
const keysToRemove = [
'animatedbk',
'bksliderinput',
'assessmentsAverage',
'lettergrade'
];
await browser.storage.local.remove(keysToRemove);
}
browser.runtime.onInstalled.addListener(function (event) {
browser.storage.local.remove(['justupdated']);
browser.storage.local.remove(['data']);
if ( event.reason == 'install' || event.reason == 'update' ) {
browser.storage.local.set({ justupdated: true });
migrateLegacySettings();
}
});
initCloudSettingsAutoSync({ reloadSeqtaPages });
-399
View File
@@ -1,399 +0,0 @@
import browser from "webextension-polyfill";
import {
applyDownloadedEnvelope,
buildUploadPayload,
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
isKeyIncludedInCloudUploadPayload,
setKnownRemoteUpdatedAt,
} from "@/seqta/utils/cloudSettingsSync";
const ACCOUNTS_BASE = "https://accounts.betterseqta.org";
export const CLOUD_SUMMARY_URL = `${ACCOUNTS_BASE}/api/user/cloud-summary`;
const CLOUD_SETTINGS_SYNC_URL = `${ACCOUNTS_BASE}/api/bsplus/settings/sync`;
const REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/refresh`;
const UPLOAD_DEBOUNCE_MS = 2000;
const POLL_THROTTLE_MS = 24 * 60 * 60 * 1000;
const POLL_THROTTLE_KEY = "bsplus_lastCloudPoll";
type CloudSummaryResponse = {
desqta?: unknown;
bsplus?: { updated_at: string; schemaVersion: number } | null;
};
let reloadSeqtaPagesFn: (() => void) | null = null;
let suppressAutoUploadDuringRestore = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let pollInFlight: Promise<void> | null = null;
function isAutoCloudSyncEnabled(all: Record<string, unknown>): boolean {
return all.autoCloudSettingsSync !== false;
}
async function parseJsonResponse(r: Response): Promise<any> {
const text = await r.text();
try {
return text ? JSON.parse(text) : {};
} catch {
return {};
}
}
async function getAccessToken(): Promise<string | null> {
const { bsplus_token } = await browser.storage.local.get("bsplus_token");
return typeof bsplus_token === "string" && bsplus_token.length > 0 ? bsplus_token : null;
}
async function tryRefreshTokens(): Promise<boolean> {
const result = await browser.storage.local.get([
"bsplus_refresh_token",
"bsplus_client_id",
"bsplus_user",
]);
const refresh_token = result.bsplus_refresh_token as string | undefined;
const client_id = result.bsplus_client_id as string | undefined;
if (!refresh_token || !client_id) return false;
try {
const r = await fetch(REFRESH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token, client_id }),
});
const data = await parseJsonResponse(r);
if (!r.ok || !data.access_token || !data.refresh_token) return false;
await browser.storage.local.set({
bsplus_token: data.access_token,
bsplus_refresh_token: data.refresh_token,
bsplus_user: data.user ?? result.bsplus_user,
});
return true;
} catch {
return false;
}
}
function isServerTimestampNewer(serverIso: string, localIso: string | undefined): boolean {
const a = Date.parse(serverIso);
if (Number.isNaN(a)) return false;
if (localIso === undefined || localIso === "") return true;
const b = Date.parse(localIso);
if (Number.isNaN(b)) return true;
return a > b;
}
async function fetchCloudSummaryOnce(
token: string,
): Promise<
| { ok: true; data: CloudSummaryResponse }
| { ok: false; unauthorized: boolean; error?: string }
> {
try {
const r = await fetch(CLOUD_SUMMARY_URL, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
const data = (await parseJsonResponse(r)) as CloudSummaryResponse;
if (r.status === 401) return { ok: false, unauthorized: true };
if (!r.ok) {
return {
ok: false,
unauthorized: false,
error: (data as { error?: string })?.error ?? `Summary failed (${r.status})`,
};
}
return { ok: true, data };
} catch (e) {
return {
ok: false,
unauthorized: false,
error: e instanceof Error ? e.message : "Network error",
};
}
}
async function fetchCloudSummaryWithAuthRetry(
token: string,
): Promise<CloudSummaryResponse | null> {
let t = token;
for (let attempt = 0; attempt < 2; attempt++) {
const res = await fetchCloudSummaryOnce(t);
if (res.ok) return res.data;
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) break;
const next = await getAccessToken();
if (!next) break;
t = next;
continue;
}
if (res.error) console.warn("[BS+ cloud sync] cloud-summary:", res.error);
break;
}
return null;
}
type PutResult =
| { ok: true; updated_at?: string }
| { ok: false; unauthorized: boolean; error?: string };
async function putSettingsOnce(token: string): Promise<PutResult> {
try {
const all = await browser.storage.local.get();
const payload = buildUploadPayload(all as Record<string, unknown>);
const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const data = await parseJsonResponse(r);
if (r.status === 401) return { ok: false, unauthorized: true };
if (!r.ok) {
return {
ok: false,
unauthorized: false,
error: data?.error ?? `Upload failed (${r.status})`,
};
}
const updated_at = data?.updated_at as string | undefined;
await setKnownRemoteUpdatedAt(updated_at);
return { ok: true, updated_at };
} catch (e) {
return {
ok: false,
unauthorized: false,
error: e instanceof Error ? e.message : "Upload failed",
};
}
}
export async function performCloudSettingsUploadWithRetry(
token: string,
): Promise<{ success: boolean; error?: string; updated_at?: string }> {
let t = token;
for (let attempt = 0; attempt < 2; attempt++) {
const res = await putSettingsOnce(t);
if (res.ok) return { success: true, updated_at: res.updated_at };
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) return { success: false, error: "Not authenticated" };
const next = await getAccessToken();
if (!next) return { success: false, error: "Not authenticated" };
t = next;
continue;
}
return { success: false, error: res.error ?? "Upload failed" };
}
return { success: false, error: "Upload failed" };
}
type GetResult =
| { ok: true; updated_at?: string }
| { ok: false; notFound?: boolean; unauthorized: boolean; error?: string };
async function getSettingsAndApplyOnce(token: string): Promise<GetResult> {
try {
const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
const data = await parseJsonResponse(r);
if (r.status === 401) return { ok: false, unauthorized: true };
if (r.status === 404) {
return {
ok: false,
notFound: true,
unauthorized: false,
error: "No settings backup found in the cloud",
};
}
if (!r.ok) {
return {
ok: false,
unauthorized: false,
error: data?.error ?? `Download failed (${r.status})`,
};
}
await applyDownloadedEnvelope(data);
reloadSeqtaPagesFn?.();
const updated_at = data?.updated_at as string | undefined;
await setKnownRemoteUpdatedAt(updated_at);
return { ok: true, updated_at };
} catch (e) {
return {
ok: false,
unauthorized: false,
error: e instanceof Error ? e.message : "Download failed",
};
}
}
export async function performCloudSettingsDownloadWithRetry(
token: string,
): Promise<{ success: boolean; notFound?: boolean; error?: string; updated_at?: string }> {
suppressAutoUploadDuringRestore = true;
try {
let t = token;
for (let attempt = 0; attempt < 2; attempt++) {
const res = await getSettingsAndApplyOnce(t);
if (res.ok) return { success: true, updated_at: res.updated_at };
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) return { success: false, error: "Not authenticated" };
const next = await getAccessToken();
if (!next) return { success: false, error: "Not authenticated" };
t = next;
continue;
}
return {
success: false,
notFound: res.notFound,
error: res.error ?? "Download failed",
};
}
return { success: false, error: "Download failed" };
} finally {
suppressAutoUploadDuringRestore = false;
}
}
async function maybeUploadBaseline(token: string): Promise<void> {
const res = await performCloudSettingsUploadWithRetry(token);
if (!res.success) {
console.warn("[BS+ cloud sync] Baseline upload failed:", res.error);
}
}
async function downloadIfNeeded(token: string): Promise<void> {
const res = await performCloudSettingsDownloadWithRetry(token);
if (!res.success && !res.notFound) {
console.warn("[BS+ cloud sync] Auto-download failed:", res.error);
}
}
async function runCloudSettingsPollInner(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return;
let token = await getAccessToken();
if (!token) return;
const summary = await fetchCloudSummaryWithAuthRetry(token);
if (!summary) return;
const bsplus = summary.bsplus;
const watermark = all[BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY] as string | undefined;
if (
bsplus &&
typeof bsplus.schemaVersion === "number" &&
bsplus.schemaVersion > CLOUD_SETTINGS_SYNC_SCHEMA_VERSION
) {
console.warn(
"[BS+ cloud sync] Server schemaVersion newer than client; skip auto-download",
);
return;
}
token = (await getAccessToken()) ?? token;
if (!watermark) {
if (!bsplus?.updated_at) {
await maybeUploadBaseline(token);
return;
}
await downloadIfNeeded(token);
return;
}
if (!bsplus?.updated_at) return;
if (isServerTimestampNewer(bsplus.updated_at, watermark)) {
await downloadIfNeeded(token);
}
}
export function runCloudSettingsPoll(): Promise<void> {
if (pollInFlight) return pollInFlight;
pollInFlight = (async () => {
try {
const { [POLL_THROTTLE_KEY]: last } = await browser.storage.local.get(POLL_THROTTLE_KEY);
if (Date.now() - (Number(last) || 0) < POLL_THROTTLE_MS) return;
await browser.storage.local.set({ [POLL_THROTTLE_KEY]: Date.now() });
await runCloudSettingsPollInner();
} catch (e) {
console.error("[BS+ cloud sync] Poll error:", e);
} finally {
pollInFlight = null;
}
})();
return pollInFlight;
}
function clearUploadDebounce(): void {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
}
function scheduleDebouncedUpload(): void {
if (suppressAutoUploadDuringRestore) return;
clearUploadDebounce();
debounceTimer = setTimeout(() => {
debounceTimer = null;
void runDebouncedUploadJob();
}, UPLOAD_DEBOUNCE_MS);
}
async function runDebouncedUploadJob(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return;
const token = await getAccessToken();
if (!token) return;
const res = await performCloudSettingsUploadWithRetry(token);
if (!res.success) {
console.warn("[BS+ cloud sync] Auto-upload failed:", res.error);
}
}
async function syncAutoUploadWithStorage(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) {
clearUploadDebounce();
}
}
function onStorageChanged(
changes: Record<string, browser.storage.StorageChange>,
area: string,
): void {
if (area !== "local") return;
if (Object.prototype.hasOwnProperty.call(changes, "autoCloudSettingsSync")) {
void syncAutoUploadWithStorage();
}
const keys = Object.keys(changes);
if (!keys.some((k) => isKeyIncludedInCloudUploadPayload(k))) return;
void (async () => {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return;
if (suppressAutoUploadDuringRestore) return;
if (!(await getAccessToken())) return;
scheduleDebouncedUpload();
})();
}
export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void {
reloadSeqtaPagesFn = deps.reloadSeqtaPages;
browser.storage.onChanged.addListener(onStorageChanged);
}
+22 -60
View File
@@ -1,36 +1,17 @@
import Parser from "rss-parser";
import Parser from 'rss-parser';
/**
* Fetches news articles specifically for Australia from the NewsAPI.
*
* This function handles a specific case for fetching Australian news. It includes a
* mechanism to retry the fetch operation by appending "%00" to the URL if a
* rate limit error (`response.code == "rateLimited"`) is encountered. This is
* likely a workaround for cache-busting or bypassing certain rate-limiting measures.
*
* @param {string} url The NewsAPI URL to fetch Australian news from.
* @param {any} sendResponse A callback function (likely from a browser extension message listener)
* to send the fetched news data back to the caller.
* It's called with an object like `{ news: responseData }`.
*/
const fetchAustraliaNews = async (url: string, sendResponse: any) => {
fetch(url)
.then((result) => result.json())
.then((response) => {
if (response.code == "rateLimited") {
fetchAustraliaNews((url += "%00"), sendResponse);
if (response.code == 'rateLimited') {
fetchAustraliaNews(url += '%00', sendResponse);
} else {
sendResponse({ news: response });
}
});
};
/**
* A record mapping lowercase country codes (e.g., "usa", "canada") to an array
* of RSS feed URLs for news sources in that country.
*
* @type {Record<string, string[]>}
*/
const rssFeedsByCountry: Record<string, string[]> = {
usa: [
"https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml",
@@ -50,13 +31,13 @@ const rssFeedsByCountry: Record<string, string[]> = {
"https://critica.com.pa/rss.xml",
"https://www.panamaamerica.com.pa/rss.xml",
"https://noticiassin.com/feed/",
"https://elcapitalfinanciero.com/feed/",
"https://elcapitalfinanciero.com/feed/"
],
canada: [
"https://www.cbc.ca/cmlink/rss-topstories",
"https://calgaryherald.com/feed",
"https://ottawacitizen.com/feed",
"https://www.montrealgazette.com/feed",
"https://www.montrealgazette.com/feed"
],
singapore: [
"https://www.straitstimes.com/news/singapore/rss.xml",
@@ -68,43 +49,23 @@ const rssFeedsByCountry: Record<string, string[]> = {
],
japan: [
"https://www3.nhk.or.jp/nhkworld/en/news/feeds/",
"https://news.livedoor.com/topics/rss/int.xml",
"https://news.livedoor.com/topics/rss/int.xml"
],
netherlands: [
"https://www.dutchnews.nl/feed/",
"https://www.nrc.nl/rss/"
],
netherlands: ["https://www.dutchnews.nl/feed/", "https://www.nrc.nl/rss/"],
};
/**
* Fetches news articles based on a specified source.
*
* The source can be:
* 1. The string "australia": Fetches news from Australian sources via NewsAPI,
* handled by the `fetchAustraliaNews` function.
* 2. A lowercase country code (e.g., "usa", "canada"): Fetches news from a predefined
* list of RSS feeds for that country, as specified in `rssFeedsByCountry`.
* 3. A direct RSS feed URL (starting with "http"): Fetches news directly from this URL.
*
* The fetched articles are then sent back to the caller using the `sendResponse` callback.
*
* @param {string} source The news source identifier. This can be "australia", a
* lowercase country code, or a direct RSS feed URL.
* @param {any} sendResponse A callback function (typically from a browser extension
* message listener, like `chrome.runtime.onMessage`)
* used to send the fetched news data back to the caller.
* It's called with an object like `{ news: { articles: [...] } }`.
*/
export async function fetchNews(source: string | undefined, sendResponse: any) {
const normalizedSource = typeof source === "string" && source.trim()
? source.trim()
: "australia";
if (normalizedSource === "australia") {
export async function fetchNews(source: string, sendResponse: any) {
if (source === "australia") {
const date = new Date();
const from =
date.getFullYear() +
"-" +
'-' +
(date.getMonth() + 1) +
"-" +
'-' +
(date.getDate() - 5);
const url = `https://newsapi.org/v2/everything?domains=abc.net.au&from=${from}&apiKey=17c0da766ba347c89d094449504e3080`;
@@ -115,15 +76,16 @@ export async function fetchNews(source: string | undefined, sendResponse: any) {
const parser = new Parser();
let feeds: string[];
console.log("fetchNews", normalizedSource);
console.log('fetchNews', source)
if (rssFeedsByCountry[normalizedSource.toLowerCase()]) {
feeds = rssFeedsByCountry[normalizedSource.toLowerCase()];
} else if (normalizedSource.startsWith("http")) {
feeds = [normalizedSource];
if (rssFeedsByCountry[source.toLowerCase()]) {
// If the source is a country, fetch from predefined feeds
feeds = rssFeedsByCountry[source.toLowerCase()];
} else if (source.startsWith("http")) {
// If the source is a URL, use it directly
feeds = [source];
} else {
console.warn("[BetterSEQTA+] Invalid news source, falling back to Australia", normalizedSource);
return fetchNews("australia", sendResponse);
throw new Error("Invalid source. Provide a country code or a valid RSS feed URL.");
}
const articlesPromises = feeds.map(async (feedUrl) => {
+3 -91
View File
@@ -15,44 +15,12 @@
* along with EvenBetterSEQTA. If not, see <https://www.gnu.org/licenses/>.
*/
@use "injected/popup.scss";
@font-face {
font-family: "Roboto";
src: url("https://fonts.gstatic.com/s/roboto/v50/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2")
format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IconFamily";
src: url("@/resources/fonts/IconFamily.woff") format("woff");
font-weight: normal;
font-style: normal;
font-display: block;
}
@layer base, override;
@layer override {
* {
font-family: Rubik, sans-serif !important;
}
.iconFamily,
.iconFamily *,
[class~="iconFamily"],
[class~="iconFamily"] * {
font-family: "IconFamily" !important;
}
}
@use 'injected/popup.scss';
html {
background: #161616 !important;
background-color: #161616;
font-family: Roboto, system-ui, -apple-system, sans-serif !important;
font-family: Rubik, Roboto !important;
}
.tooltip svg {
@@ -109,9 +77,7 @@ html {
transform-origin: top;
transition: transform 0.2s;
}
body:has(.outside-container:not(.hide))
#AddedSettings.tooltip:hover
> .tooltiptext {
body:has(.outside-container:not(.hide)) #AddedSettings.tooltip:hover > .tooltiptext {
transform: scale(0);
}
.assessmenttooltip svg {
@@ -126,57 +92,3 @@ body:has(.outside-container:not(.hide))
background: var(--text-primary) !important;
color: var(--theme-primary) !important;
}
.fixed-tooltip {
display: inline-block;
z-index: 5 !important;
width: 28px;
background: none;
box-shadow: none;
padding: 2px;
position: absolute;
}
.fixed-tooltip svg {
fill: var(--theme-primary);
}
.tooltiptext-fixed {
width: 120px;
transform: scale(0);
transition: transform 0.2s;
transform-origin: top;
background: var(--background-primary);
color: var(--text-primary);
text-align: center;
border-radius: 6px;
padding: 2px;
position: fixed;
z-index: 1000;
top: 0;
left: 0;
margin-left: -62px;
}
.tooltiptext-fixed::after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: transparent transparent var(--text-primary) transparent;
}
.tooltiptext-fixed.show {
transform: scale(1);
transform-origin: top;
transition: transform 0.2s;
}
.tooltiptext-fixed p:hover {
cursor: pointer;
background: rgba(0, 0, 0, 0.3) !important;
transition: 200ms;
}
.tooltiptext-fixed p {
border-radius: 8px !important;
padding-top: 2px;
padding-bottom: 2px;
margin: 2px;
}
+1 -1
View File
@@ -1 +1 @@
import "./documentload.scss";
import './documentload.scss';
+1 -9
View File
@@ -25,9 +25,7 @@ body {
span,
body {
color: white !important;
text-shadow:
1px 1px 2px #161616,
0 0 1em #161616;
text-shadow: 1px 1px 2px #161616, 0 0 1em #161616;
}
body {
@@ -114,9 +112,3 @@ body {
transition: text-shadow 0.5s;
}
}
.cke_panel_listItem > a {
&:hover {
background: #3d3d3e !important;
}
}
+317 -1371
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -36,7 +36,5 @@
transform-origin: 70% 0;
will-change: opacity, transform;
transform: translateZ(0); // promotes GPU rendering
transition:
opacity 0.05s,
transform 0.05s;
transition: opacity 0.05s, transform 0.05s;
}
+1 -11
View File
@@ -35,19 +35,9 @@
}
#menu .sub {
transition: transform 0.3s ease, left 0.4s cubic-bezier(0.4, 0, 0.2, 1);
transition: transform 0.3s ease;
}
#menu > ul:has(li.hasChildren.active) > li.active {
background: transparent !important;
}
/* Icon-only collapsed: submenu slides over narrow icons */
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul:has(li.hasChildren.active) > li::before,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li::before,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul:has(li.hasChildren.active) > li > label,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul:has(li.hasChildren.active) > li > svg,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li > label,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li > svg {
transform: translateX(-70px);
}
+12 -9
View File
@@ -8,6 +8,7 @@ html.transparencyEffects:not(.dark) {
--background-secondary: rgba(229, 231, 235, 0.6);
}
html.transparencyEffects {
/* Background Fixes */
[class*="notifications__item___"],
@@ -21,9 +22,6 @@ html.transparencyEffects {
}
/* Blurs */
.search,
.document,
.border,
.draggable,
.notice,
[class*="BasicPanel__BasicPanel___"],
@@ -39,29 +37,34 @@ html.transparencyEffects {
[class*="LabelList__selected___"],
.buttonChecklist,
.pane,
.legacy-root button,
.legacy-root a,
.legacy-root button, .legacy-root a,
[class*="MessageList__MessageList___"] {
backdrop-filter: blur(80px);
}
.filter-select,
.uiShortText.search,
.report {
backdrop-filter: blur(10px) !important;
}
#menu,
.kanban-column,
.whatsnewContainer,
[class*="Message__Message___"] {
backdrop-filter: blur(50px);
}
#menu {
backdrop-filter: blur(20px);
}
.title > a {
backdrop-filter: blur(0px) !important;
}
.search,
.document,
.border {
backdrop-filter: blur(80px);
}
#main > .dashboard {
section,
.dashlet {
+7 -7
View File
@@ -1,11 +1,11 @@
declare module "*.mp4";
declare module "*.woff";
declare module "*.scss";
declare module "*.png";
declare module "*.html";
declare module "*.svelte";
declare module '*.mp4';
declare module '*.woff';
declare module '*.scss';
declare module '*.png';
declare module '*.html';
declare module '*.svelte';
declare module "*?inlineWorker" {
declare module '*?inlineWorker' {
const value: () => Worker;
export default value;
}
-4
View File
@@ -1,4 +0,0 @@
{
"last_updated": "2024-06-15T12:00:00Z",
"whatsnew_html": "<div class=\"whatsnewTextContainer\" style=\"overflow-y: auto; font-size: 1.3rem; line-height: 1.6;\"><p>It has come to our attention that several schools have expressed concerns about BetterSEQTA+. This is very disheartening, so we have decided to release a statement on the situation.</p><p>To view our privacy policy, please click the <strong>shield icon</strong> in the settings&nbsp;menu, or <a href=\"https://betterseqta.org/privacy\" target=\"_blank\" rel=\"noopener noreferrer\" id=\"privacy-link\" style=\"color: inherit; text-decoration: underline; cursor: pointer; white-space: nowrap;\">click here</a>.</p><p style=\"font-weight: bold; margin-top: 15px;\">We never collect any information from you, and aim to provide the best features possible.</p></div>"
}
+2 -15
View File
@@ -1,20 +1,7 @@
<script lang="ts">
let {
onClick,
text,
disabled = false,
} = $props<{
onClick: () => void;
text: string;
disabled?: boolean;
}>();
let { onClick, text } = $props<{ onClick: () => void, text: string, [key: string]: any }>();
</script>
<button
type="button"
onclick={onClick}
disabled={disabled}
class="px-5 py-1.5 text-[0.75rem] shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg disabled:cursor-not-allowed disabled:opacity-50"
>
<button onclick={onClick} class='px-4 py-1 text-[0.75rem] dark:bg-[#38373D] bg-[#DDDDDD] dark:text-white rounded-md'>
{text}
</button>
-157
View File
@@ -1,157 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { animate } from "motion";
import { delay } from "@/seqta/utils/delay.ts";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
const { hidePanel } = $props<{
hidePanel: () => void;
}>();
let cloudState = $state(cloudAuth.state);
let background = $state<HTMLDivElement | null>(null);
let content = $state<HTMLDivElement | null>(null);
let loginError = $state<string | null>(null);
onMount(() => {
const unsub = cloudAuth.subscribe((s) => {
cloudState = s;
});
if (background && content) {
animate(
background,
{ opacity: [0, 1] },
{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }
);
animate(
content,
{ scale: [0.4, 1], opacity: [0, 1] },
{ type: "spring", stiffness: 400, damping: 30 }
);
}
const handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closePanel();
};
document.addEventListener("keydown", handleEscapeKey);
return () => {
unsub();
document.removeEventListener("keydown", handleEscapeKey);
};
});
async function closePanel() {
if (!background || !content) return;
animate(
content,
{ scale: [1, 0.4], opacity: [1, 0] },
{ type: "spring", stiffness: 400, damping: 30 }
);
animate(
background,
{ opacity: [1, 0] },
{ ease: [0.4, 0, 0.2, 1] }
);
await delay(400);
hidePanel();
}
function handleBackgroundClick(event: MouseEvent) {
if (event.target === background) closePanel();
}
async function handleSignIn() {
loginError = null;
const result = await cloudAuth.startLogin();
if (result.success) {
closePanel();
} else {
loginError = result.error ?? "Failed to open login page";
}
}
async function handleLogout() {
await cloudAuth.logout();
}
function getInitials(): string {
const u = cloudState.user;
if (!u) return "?";
if (u.displayName) return u.displayName.slice(0, 2).toUpperCase();
if (u.username) return u.username.slice(0, 2).toUpperCase();
if (u.email) return u.email.slice(0, 2).toUpperCase();
return "?";
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={background}
class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full cursor-pointer bg-black/50"
onclick={handleBackgroundClick}
onkeydown={(e) => { if (e.key === "Enter") handleBackgroundClick; }}
>
<div
bind:this={content}
class="p-5 w-[320px] bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
>
<h3 class="text-lg font-bold text-zinc-900 dark:text-white">BetterSEQTA Cloud</h3>
<p class="mt-0.5 text-sm text-zinc-500 dark:text-zinc-400">Account & sync</p>
<div class="mt-4">
{#if cloudState.isLoggedIn}
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
{#if cloudState.user?.pfpUrl}
<img
src={cloudState.user.pfpUrl}
alt=""
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
/>
{:else}
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
{getInitials()}
</div>
{/if}
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-zinc-900 dark:text-white truncate">
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
</p>
{#if cloudState.user?.email && cloudState.user?.email !== (cloudState.user?.displayName || cloudState.user?.username)}
<p class="text-xs text-zinc-500 dark:text-zinc-400 truncate">{cloudState.user.email}</p>
{/if}
</div>
</div>
<button
type="button"
onclick={handleLogout}
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-900 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
>
Sign out
</button>
</div>
{:else}
<div class="flex flex-col gap-3">
<p class="text-sm text-zinc-600 dark:text-zinc-400">
Sign in to sync settings across devices, use your cloud profile picture, and more.
</p>
<button
type="button"
onclick={handleSignIn}
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
Sign in with BetterSEQTA Cloud
</button>
{#if loginError}
<p class="text-xs text-red-600 dark:text-red-400">{loginError}</p>
{/if}
<p class="text-xs text-center text-zinc-400 dark:text-zinc-500">
Opens accounts.betterseqta.org in a new tab
</p>
</div>
{/if}
</div>
</div>
</div>
@@ -1,120 +0,0 @@
<script lang="ts">
import browser from "webextension-polyfill";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import Button from "./Button.svelte";
import Switch from "./Switch.svelte";
let { showDisclaimer } = $props<{
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
}>();
let cloudState = $state(cloudAuth.state);
let busy = $state(false);
let statusMessage = $state<string | null>(null);
let statusError = $state<string | null>(null);
$effect(() => {
const unsub = cloudAuth.subscribe((s) => {
cloudState = s;
});
return unsub;
});
async function upload() {
const token = await cloudAuth.getStoredToken();
if (!token) return;
busy = true;
statusError = null;
statusMessage = null;
try {
const res = (await browser.runtime.sendMessage({
type: "cloudSettingsUpload",
token,
})) as { success?: boolean; error?: string };
if (res?.success) {
statusMessage = "Settings uploaded.";
} else {
statusError = res?.error ?? "Upload failed";
}
} catch (e) {
statusError = e instanceof Error ? e.message : "Upload failed";
} finally {
busy = false;
}
}
function promptDownload() {
showDisclaimer(confirmDownload, () => {});
}
async function confirmDownload() {
const token = await cloudAuth.getStoredToken();
if (!token) return;
busy = true;
statusError = null;
statusMessage = null;
try {
const res = (await browser.runtime.sendMessage({
type: "cloudSettingsDownload",
token,
})) as { success?: boolean; error?: string; notFound?: boolean };
if (res?.success) {
statusMessage = "Settings restored.";
} else {
statusError = res?.error ?? "Download failed";
}
} catch (e) {
statusError = e instanceof Error ? e.message : "Download failed";
} finally {
busy = false;
}
}
</script>
{#if cloudState.isLoggedIn}
<div class="flex flex-col gap-2.5">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-[11px] font-semibold text-zinc-800 dark:text-zinc-100">Automatic sync</p>
<p class="text-[10px] text-zinc-500 dark:text-zinc-400">Syncs settings when SEQTA loads and when you make changes</p>
</div>
<div class="shrink-0">
<Switch
state={$settingsState.autoCloudSettingsSync !== false}
onChange={(isOn: boolean) => (settingsState.autoCloudSettingsSync = isOn)}
/>
</div>
</div>
<div class="flex flex-wrap gap-2">
<Button
text={busy ? "Please wait\u2026" : "Upload"}
onClick={upload}
disabled={busy}
/>
<Button
text={busy ? "Please wait\u2026" : "Download"}
onClick={promptDownload}
disabled={busy}
/>
</div>
{#if statusMessage}
<p class="text-[11px] text-emerald-600 dark:text-emerald-400">{statusMessage}</p>
{/if}
{#if statusError}
<p class="text-[11px] text-red-600 dark:text-red-400">{statusError}</p>
{/if}
<p class="text-[10px] text-zinc-400 dark:text-zinc-500">
Passwords and tokens are never synced.
<a
href="https://betterseqta.org/privacy"
target="_blank"
rel="noopener noreferrer"
class="font-medium text-emerald-600 underline decoration-emerald-600/50 underline-offset-2 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300 rounded-sm"
>Privacy policy</a>
</p>
</div>
{/if}
-10
View File
@@ -2,16 +2,6 @@ div:has(> #rbgcp-wrapper) {
background: transparent !important;
}
#rbgcp-inputs-wrap {
padding-top: 4px !important;
margin-bottom: -8px;
#rbgcp-hex-input,
#rbgcp-input {
height: 28px !important;
}
}
.dark {
#rbgcp-wrapper {
div[style="padding-top: 11px; position: relative;"] div {
+3 -3
View File
@@ -81,20 +81,20 @@
</script>
{#if standalone}
<div class="h-auto overflow-clip rounded-xl">
<div class="h-auto rounded-xl overflow-clip">
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} />
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={background}
class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full shadow-2xl cursor-pointer bg-black/20 border border-[#DDDDDD]/30 dark:border-[#38373D]/30"
class="absolute top-0 left-0 z-50 flex items-center justify-center w-full h-full cursor-pointer bg-black/20"
onclick={handleBackgroundClick}
onkeydown={(e) => { e.key === 'Enter' && handleBackgroundClick }}
>
<div
bind:this={content}
class="p-4 h-auto bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
class="h-auto p-4 bg-white border shadow-lg cursor-auto rounded-xl dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
>
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} />
</div>
+28 -42
View File
@@ -1,6 +1,6 @@
import ColorPicker from "react-best-gradient-color-picker";
import { useEffect, useRef, useState } from "react";
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts";
import ColorPicker from "react-best-gradient-color-picker"
import { useEffect, useRef, useState } from "react"
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
const defaultPresets = [
"linear-gradient(30deg, rgba(229,209,218,1) 0%, RGBA(235,169,202,1) 46%, rgba(214,155,162,1) 100%)",
@@ -22,12 +22,12 @@ const defaultPresets = [
"rgba(30, 64, 175, 0.89)",
"rgba(134, 25, 143, 1)",
"rgba(14, 165, 233, 0.9)",
];
]
interface PickerProps {
customOnChange?: (color: string) => void;
customState?: string;
savePresets?: boolean;
customOnChange?: (color: string) => void
customState?: string
savePresets?: boolean
}
export default function Picker({
@@ -35,44 +35,32 @@ export default function Picker({
customState,
savePresets = true,
}: PickerProps) {
const [customThemeColor, setCustomThemeColor] = useState<string | null>();
const [presets, setPresets] = useState<string[]>();
const [customThemeColor, setCustomThemeColor] = useState<string | null>()
const [presets, setPresets] = useState<string[]>()
const latestValuesRef = useRef({
customThemeColor,
customOnChange,
savePresets,
presets,
});
const latestValuesRef = useRef({ customThemeColor, customOnChange, savePresets, presets });
useEffect(() => {
if (customState !== undefined && customState !== null) {
setCustomThemeColor(customState);
setCustomThemeColor(customState)
} else {
setCustomThemeColor(settingsState.selectedColor ?? null);
setCustomThemeColor(settingsState.selectedColor ?? null)
}
if (presets === undefined) {
const savedPresets = localStorage.getItem("colorPickerPresets");
setPresets(savedPresets ? JSON.parse(savedPresets) : defaultPresets);
const savedPresets = localStorage.getItem("colorPickerPresets")
setPresets(savedPresets ? JSON.parse(savedPresets) : defaultPresets)
}
}, []);
}, [])
useEffect(() => {
latestValuesRef.current = {
customThemeColor,
customOnChange,
savePresets,
presets,
};
latestValuesRef.current = { customThemeColor, customOnChange, savePresets, presets };
}, [customThemeColor, customOnChange, savePresets, presets]);
useEffect(() => {
return () => {
const { customThemeColor, customOnChange, savePresets, presets } =
latestValuesRef.current;
if (!(customThemeColor && !customOnChange && savePresets && presets))
return;
const { customThemeColor, customOnChange, savePresets, presets } = latestValuesRef.current;
if (!(customThemeColor && !customOnChange && savePresets && presets)) return;
// Only proceed if presets are different (avoid unnecessary updates)
const existingIndex = presets.indexOf(customThemeColor);
@@ -91,32 +79,30 @@ export default function Picker({
updatedPresets = [customThemeColor, ...presets].slice(0, 18);
}
localStorage.setItem(
"colorPickerPresets",
JSON.stringify(updatedPresets),
);
};
}, []);
localStorage.setItem("colorPickerPresets", JSON.stringify(updatedPresets));
}
}, [])
useEffect(() => {
if (customThemeColor && !customOnChange) {
settingsState.selectedColor = customThemeColor;
settingsState.selectedColor = customThemeColor
}
}, [customThemeColor, customOnChange]);
}, [customThemeColor, customOnChange])
return (
<ColorPicker
disableDarkMode={true}
presets={presets}
hideInputs={customOnChange ? false : true}
value={customThemeColor ?? ""}
onChange={(color: string) => {
if (customOnChange) {
customOnChange(color);
setCustomThemeColor(color);
customOnChange(color)
setCustomThemeColor(color)
} else {
setCustomThemeColor(color);
setCustomThemeColor(color)
}
}}
/>
);
)
}
@@ -1,172 +0,0 @@
<script lang="ts">
import { fade } from "svelte/transition";
import browser from "webextension-polyfill";
import QRCode from "qrcode";
import { portal } from "../utils/portal";
let showQrModal = $state(false);
let qrDataUrl = $state<string | null>(null);
let appLink = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
let isLoading = $state(false);
let isStandalone = $state(false);
function isExtensionPage(): boolean {
return (
window.location.protocol === "chrome-extension:" ||
window.location.protocol === "moz-extension:"
);
}
function isSeqtaUrl(url: string): boolean {
try {
const u = new URL(url);
return u.hostname.includes("seqta") || u.hostname.endsWith(".edu.au");
} catch {
return false;
}
}
function normalizeBaseUrl(url: string): string {
try {
const u = new URL(url);
return u.origin;
} catch {
return url;
}
}
async function getAppLink(): Promise<string | null> {
let baseUrl: string | undefined;
if (isExtensionPage()) {
baseUrl = undefined;
} else {
baseUrl = normalizeBaseUrl(window.location.href);
if (!isSeqtaUrl(baseUrl)) return null;
}
const { appLink: link } = (await browser.runtime.sendMessage({
type: "getSeqtaSession",
baseUrl,
})) as { appLink: string | null };
return link ?? null;
}
async function generateQrCode() {
errorMessage = null;
qrDataUrl = null;
isLoading = true;
try {
isStandalone = isExtensionPage();
const link = await getAppLink();
if (!link) {
if (isStandalone) {
errorMessage =
"Open SEQTA Learn in a tab and log in, then open settings from that tab to generate a QR code.";
} else {
errorMessage = "Please log in to SEQTA Learn first.";
}
return;
}
const dataUrl = await QRCode.toDataURL(link, { width: 256, margin: 2 });
appLink = link;
qrDataUrl = dataUrl;
showQrModal = true;
} catch (err) {
console.error("[ConnectMobileApp] Failed to generate QR:", err);
errorMessage = "Failed to generate QR code. Please try again.";
} finally {
isLoading = false;
}
}
function closeModal() {
showQrModal = false;
qrDataUrl = null;
appLink = null;
errorMessage = null;
}
function openAppLink() {
if (appLink) window.location.href = appLink;
}
function downloadQrImage() {
if (!qrDataUrl) return;
const link = document.createElement("a");
link.href = qrDataUrl;
link.download = "desqta-login-qr.png";
link.click();
}
</script>
<div class="flex flex-col gap-1 items-end">
<button
type="button"
onclick={generateQrCode}
disabled={isLoading}
class="px-5 py-1.5 text-[0.75rem] text-nowrap shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-opacity">
{isLoading ? "Generating..." : "Generate QR"}
</button>
{#if errorMessage}
<p class="text-xs text-right text-amber-600 dark:text-amber-400">{errorMessage}</p>
{/if}
</div>
{#if showQrModal && qrDataUrl}
<div
use:portal
class="fixed cursor-auto inset-0 z-[10000] flex justify-center items-center bg-black/50 {isStandalone ? 'backdrop-blur-sm' : ''}"
role="button"
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget) closeModal();
}}
onkeydown={(e) => {
if (e.key === "Escape") closeModal();
}}
transition:fade={{ duration: 150 }}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="p-6 mx-4 w-full max-w-sm bg-white rounded-2xl shadow-2xl dark:bg-zinc-800"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}>
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-bold text-zinc-900 dark:text-white">Scan with DesQTA</h2>
<button
type="button"
onclick={closeModal}
class="p-2 rounded-lg transition-colors text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 dark:hover:text-zinc-400 dark:hover:bg-zinc-700"
aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex justify-center p-4 bg-white rounded-xl dark:bg-zinc-900">
<img src={qrDataUrl} alt="SEQTA Learn app link QR code" class="w-64 h-64" />
</div>
<div class="flex flex-col gap-2 mt-4">
<button
type="button"
onclick={openAppLink}
class="px-4 py-2.5 w-full text-sm font-medium text-white bg-indigo-600 rounded-lg transition-colors dark:bg-indigo-500 hover:bg-indigo-700 dark:hover:bg-indigo-600">
Sign into DesQTA Desktop
</button>
<button
type="button"
onclick={downloadQrImage}
class="px-4 py-2 w-full text-xs font-medium rounded-lg border transition-colors text-zinc-500 dark:text-zinc-400 border-zinc-200 dark:border-zinc-600 hover:bg-zinc-50 dark:hover:bg-zinc-800/50">
Download QR as image
</button>
</div>
<p class="mt-2 text-sm text-center text-zinc-600 dark:text-zinc-400">
Or scan this QR code with DesQTA on your phone.
</p>
</div>
</div>
{/if}
@@ -1,73 +0,0 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { animate } from 'motion';
let { onConfirm, onCancel, title, message } = $props<{
onConfirm: () => void;
onCancel: () => void;
title: string;
message: string;
}>();
let modalElement: HTMLElement;
$effect(() => {
if (modalElement) {
animate(
modalElement,
{ scale: [0.9, 1], opacity: [0, 1] },
{
type: 'spring',
stiffness: 300,
damping: 25
}
);
}
});
</script>
<div
class="flex fixed inset-0 z-[10000] justify-center items-center bg-black/50"
style="position: fixed; top: 0; left: 0; right: 0; bottom: 0;"
onclick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
onkeydown={(e) => {
if (e.key === 'Escape') onCancel();
}}
role="button"
tabindex="-1"
transition:fade={{ duration: 150 }}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={modalElement}
class="p-4 mx-4 w-full max-w-md bg-white rounded-2xl shadow-2xl dark:bg-zinc-800"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<h2 class="mb-3 text-xl font-bold text-gray-900 dark:text-white">
{title}
</h2>
<div class="mb-6 text-lg text-gray-700 whitespace-pre-line dark:text-gray-300">
{message}
</div>
<div class="flex gap-3 justify-end">
<button
onclick={onCancel}
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg transition-colors hover:bg-gray-200 dark:bg-zinc-700 dark:text-gray-200 dark:hover:bg-zinc-600"
>
Cancel
</button>
<button
onclick={onConfirm}
class="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg shadow-inner transition-colors hover:bg-green-700 dark:bg-green-500 dark:hover:bg-green-600"
>
Enable
</button>
</div>
</div>
</div>
-227
View File
@@ -1,227 +0,0 @@
<script lang="ts">
import { isValidHotkey, parseHotkey } from '@/plugins/built-in/globalSearch/src/utils/hotkeyUtils';
let { value, onChange } = $props<{
value: string,
onChange: (newValue: string) => void
}>();
let isRecording = $state(false);
let recordedKeys = $state<Set<string>>(new Set());
let inputElement = $state<HTMLInputElement>();
const formatKeyForHotkey = (key: string): string => {
// Map special keys to their hotkey format
const keyMap: Record<string, string> = {
'Control': 'ctrl',
'Meta': 'cmd',
'Alt': 'alt',
'Shift': 'shift',
' ': 'space',
'ArrowUp': 'up',
'ArrowDown': 'down',
'ArrowLeft': 'left',
'ArrowRight': 'right',
'Escape': 'esc',
'Enter': 'enter',
'Tab': 'tab',
'Backspace': 'backspace',
'Delete': 'delete',
};
return keyMap[key] || key.toLowerCase();
};
const formatKeyForDisplay = (key: string): string => {
// Map keys to their display format
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const keyMap: Record<string, string> = {
'ctrl': isMac ? '⌃' : 'Ctrl',
'cmd': '⌘',
'meta': '⌘',
'alt': isMac ? '⌥' : 'Alt',
'shift': isMac ? '⇧' : 'Shift',
'space': 'Space',
'up': '↑',
'down': '↓',
'left': '←',
'right': '→',
'esc': 'Esc',
'enter': 'Enter',
'tab': 'Tab',
'backspace': 'Backspace',
'delete': 'Delete',
};
return keyMap[key.toLowerCase()] || key.toUpperCase();
};
const getHotkeyParts = (hotkeyString: string): string[] => {
if (!hotkeyString || !isValidHotkey(hotkeyString)) {
return [];
}
const parsed = parseHotkey(hotkeyString);
const parts: string[] = [];
// Add modifiers in a consistent order
if (parsed.ctrl) parts.push('ctrl');
if (parsed.meta) parts.push('cmd');
if (parsed.alt) parts.push('alt');
if (parsed.shift) parts.push('shift');
// Add the main key
if (parsed.key) parts.push(parsed.key);
return parts;
};
const startRecording = () => {
isRecording = true;
recordedKeys.clear();
inputElement?.focus();
};
const stopRecording = () => {
if (recordedKeys.size > 0) {
if (recordedKeys.has('esc')) {
onChange('');
isRecording = false;
recordedKeys.clear();
inputElement?.blur();
return;
}
// Build the hotkey string
const modifiers: string[] = [];
let mainKey = '';
for (const key of recordedKeys) {
if (['ctrl', 'cmd', 'alt', 'shift'].includes(key)) {
modifiers.push(key);
} else {
mainKey = key;
}
}
if (mainKey) {
const hotkeyString = [...modifiers, mainKey].join('+');
if (isValidHotkey(hotkeyString)) {
onChange(hotkeyString);
}
}
}
isRecording = false;
recordedKeys.clear();
inputElement?.blur();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!isRecording) return;
e.preventDefault();
e.stopPropagation();
const key = formatKeyForHotkey(e.key);
// Add modifiers
if (e.ctrlKey) recordedKeys.add('ctrl');
if (e.metaKey) recordedKeys.add('cmd');
if (e.altKey) recordedKeys.add('alt');
if (e.shiftKey) recordedKeys.add('shift');
// Add the main key (ignore modifier keys themselves)
if (!['ctrl', 'cmd', 'alt', 'shift'].includes(key)) {
recordedKeys.add(key);
}
// Auto-stop recording if we have a main key
if (!['ctrl', 'cmd', 'alt', 'shift'].includes(key)) {
setTimeout(stopRecording, 100);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (!isRecording) return;
e.preventDefault();
e.stopPropagation();
};
const handleBlur = () => {
if (isRecording) {
stopRecording();
}
};
$effect(() => {
if (isRecording && inputElement) {
inputElement.focus();
}
});
// Get the parts to display
const hotkeyParts = $derived(isRecording
? Array.from(recordedKeys).map(formatKeyForDisplay)
: getHotkeyParts(value).map(formatKeyForDisplay));
</script>
<div class="flex gap-2 items-center">
<div class="relative">
{#if isRecording}
<!-- Recording state -->
<div
class="flex items-center justify-center px-3 py-1.5 text-sm rounded-md dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white border cursor-pointer text-nowrap"
onclick={startRecording}
onkeydown={startRecording}
role="button"
tabindex="0"
>
Press keys...
</div>
{:else if hotkeyParts.length > 0}
<!-- Display current hotkey -->
<div
class="flex gap-1 items-center text-sm rounded-md border-none cursor-pointer dark:text-white"
onclick={startRecording}
onkeydown={startRecording}
role="button"
tabindex="0"
>
{#each hotkeyParts as part}
<div class="size-8 text-sm flex items-center justify-center rounded-md border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30">
{part}
</div>
{/each}
</div>
{:else}
<!-- Empty state -->
<div
class="flex items-center justify-center px-3 py-2 text-sm rounded-md dark:bg-[#38373D]/50 bg-[#DDDDDD] dark:text-white border-none cursor-pointer text-nowrap"
onclick={startRecording}
onkeydown={startRecording}
role="button"
tabindex="0"
>
<span class="text-gray-500 dark:text-gray-400">Click to set</span>
</div>
{/if}
<!-- Hidden input for focus management -->
<input
bind:this={inputElement}
type="text"
readonly
class="absolute inset-0 opacity-0 pointer-events-none"
onkeydown={handleKeyDown}
onkeyup={handleKeyUp}
onblur={handleBlur}
/>
</div>
</div>
<style>
input:focus {
outline: none;
}
</style>
+1 -1
View File
@@ -8,5 +8,5 @@
aria-label="Color Picker Swatch"
onclick={onClick}
style="background: {$settingsState.selectedColor}"
class="w-16 h-8 rounded-md shadow-2xl ring-[1px] ring-[#DDDDDD]/30 dark:ring-[#38373D]/30"
class="w-16 h-8 rounded-md"
></button>
+1 -61
View File
@@ -8,12 +8,11 @@
let select: HTMLSelectElement;
</script>
<div class="select-wrapper relative w-full overflow-hidden rounded-2xl border shadow-2xl">
<select
bind:this={select}
value={state}
onchange={() => onChange(select.value)}
class="select-input w-full appearance-none border-none bg-transparent px-4 py-2.5 pr-10 text-[0.875rem] font-medium transition-colors"
class="px-4 py-1 text-[0.75rem] dark:bg-[#38373D] bg-[#DDDDDD] dark:text-white rounded-md w-full"
>
{#each options as option}
<option value={option.value}>
@@ -21,62 +20,3 @@
</option>
{/each}
</select>
<span class="select-icon pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z" clip-rule="evenodd"></path>
</svg>
</span>
</div>
<style>
.select-wrapper {
background: color-mix(in srgb, var(--background-primary) 88%, transparent);
border-color: color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 72%, transparent);
border-radius: 18px;
color: var(--text-primary);
transition:
background-color 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease,
transform 180ms ease;
}
.select-wrapper:hover {
background: color-mix(in srgb, var(--background-primary) 94%, var(--background-secondary) 6%);
border-color: color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 88%, transparent);
}
.select-wrapper:focus-within {
background: color-mix(in srgb, var(--background-primary) 96%, var(--background-secondary) 4%);
border-color: color-mix(in srgb, var(--text-primary) 22%, var(--theme-offset-bg, var(--background-secondary)) 78%);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary) 12%, transparent);
}
.select-input {
color: var(--text-primary);
outline: none;
text-overflow: ellipsis;
}
.select-input:hover,
.select-input:focus {
background: transparent;
}
.select-input option {
background: var(--background-primary);
color: var(--text-primary);
}
.select-icon {
color: color-mix(in srgb, var(--text-primary) 60%, transparent);
}
.select-input {
color-scheme: light;
}
:global(.dark) .select-input {
color-scheme: dark;
}
</style>
@@ -1,79 +0,0 @@
<script lang="ts">
import { fade } from "svelte/transition";
import { animate } from "motion";
import { onMount } from "svelte";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
let { onClose } = $props<{ onClose: () => void }>();
let modalElement: HTMLElement;
onMount(() => {
return cloudAuth.subscribe((s) => {
if (s.isLoggedIn) onClose();
});
});
$effect(() => {
if (modalElement) {
animate(
modalElement,
{ scale: [0.9, 1], opacity: [0, 1] },
{ type: "spring", stiffness: 300, damping: 25 },
);
}
});
async function handleSignIn() {
await cloudAuth.startLogin();
}
</script>
<div
class="flex fixed inset-0 z-[99999] justify-center items-center bg-black/50"
onclick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
onkeydown={(e) => {
if (e.key === "Escape") onClose();
}}
role="button"
tabindex="-1"
transition:fade={{ duration: 150 }}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={modalElement}
class="p-4 mx-4 w-full max-w-md max-h-[90vh] overflow-y-auto bg-white rounded-2xl shadow-2xl dark:bg-zinc-800 dark:text-white"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<h2 class="mb-3 text-xl font-bold text-zinc-900 dark:text-white">
Sign in to favorite themes
</h2>
<p class="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
Sign in to the Theme Store to save favorites across devices, or create an account to get started.
</p>
<button
type="button"
onclick={handleSignIn}
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
Sign in with BetterSEQTA Cloud
</button>
<p class="mt-2 text-xs text-center text-zinc-400 dark:text-zinc-500">
Opens accounts.betterseqta.org in a new tab
</p>
<div class="flex justify-end mt-4">
<button
type="button"
onclick={onClose}
class="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-700 dark:text-zinc-200 hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
>
Close
</button>
</div>
</div>
</div>
+3 -4
View File
@@ -9,16 +9,16 @@
let percentage = $derived(((state - min) / (max - min)) * 100);
</script>
<div class="relative w-full min-w-0">
<div class="relative mx-auto w-full max-w-lg">
<input
type="range"
min={min}
max={max}
step={step}
bind:value={state}
style={`background: linear-gradient(to right, #30d259ad 0%, #30D259 ${percentage}%, #dddddd ${percentage}%)`}
style={`background: linear-gradient(to right, #30D259 ${percentage}%, #dddddd ${percentage}%)`}
onchange={(e) => onChange(Number(e.currentTarget.value))}
class="w-full h-1 rounded-full appearance-none cursor-pointer slider"
class="w-full h-1 rounded-full appearance-none cursor-pointer dark:bg-[#38373D] bg-[#DDDDDD] slider"
/>
</div>
@@ -38,7 +38,6 @@
height: 24px;
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.3);
background: white;
color: #30d259ad;
cursor: pointer;
border-radius: 50%;
}
+1 -1
View File
@@ -1,4 +1,4 @@
.dark .switch[data-ison="true"],
.switch[data-ison="true"] {
background-color: #30d259;
background-color: #30D259;
}
+8 -1
View File
@@ -30,7 +30,8 @@
</script>
<div
class="flex w-14 p-1 cursor-pointer transition-all duration-150 rounded-full bg-gradient-to-tr select-none shadow-2xl ring-[1px] ring-[#DDDDDD]/30 dark:ring-[#38373D]/30 {state ? 'to-[#30D259]/80 from-[#30D259] dark:from-[#30D259]/40 dark:to-[#30D259]' : 'dark:from-[#38373D]/50 dark:to-[#38373D] to-[#DDDDDD]/50 from-[#DDDDDD]'}"
class="flex w-14 p-1 cursor-pointer transition-all duration-150 rounded-full dark:bg-[#38373D] bg-[#DDDDDD] switch select-none"
data-ison={state}
onclick={() => onChange(!state)}
onkeydown={(e) => e.key === "Enter" && onChange(!state)}
role="switch"
@@ -42,3 +43,9 @@
class="w-6 h-6 bg-white dark:bg-[#FEFEFE] rounded-full drop-shadow-md"
></div>
</div>
<style>
.switch[data-ison="true"] {
background-color: #30D259;
}
</style>
@@ -3,7 +3,8 @@
import './TabbedContainer.css';
import { onMount } from 'svelte';
let { tabs, activeTab = $bindable(0) } = $props<{ tabs: { title: string, Content: any, props?: any }[]; activeTab?: number }>();
let { tabs } = $props<{ tabs: { title: string, Content: any, props?: any }[] }>();
let activeTab = $state(0);
let containerRef: HTMLElement | null = null;
let tabWidth = $state(0);
@@ -42,7 +43,7 @@
<div class="top-0 z-10 text-[0.875rem] pb-0.5 mx-4 px-2 tab-width-container">
<div bind:this={containerRef} class="flex relative">
<MotionDiv
class="absolute top-0 left-0 z-0 h-full bg-gradient-to-tr dark:from-[#38373D]/80 dark:to-[#38373D] from-[#DDDDDD]/80 to-[#DDDDDD] rounded-full opacity-40 tab-width"
class="absolute top-0 left-0 z-0 h-full bg-[#DDDDDD] dark:bg-[#38373D] rounded-full opacity-40 tab-width"
animate={{ x: calcXPos(activeTab) }}
transition={springTransition}
/>
@@ -64,9 +65,8 @@
>
<div class="flex">
{#each tabs as { Content, props }, index}
<div class="absolute focus:outline-none w-full pt-2 transition-opacity duration-300 overflow-y-scroll no-scrollbar pb-2 h-full tab {activeTab === index ? 'opacity-100 active' : 'opacity-0'}"
<div class="absolute focus:outline-none w-full transition-opacity duration-300 overflow-y-scroll no-scrollbar h-full tab {activeTab === index ? 'opacity-100 active' : 'opacity-0'}"
style="left: {index * 100}%;">
<div style="left: {index * 100}%;" class="fixed top-0 w-full h-8 bg-gradient-to-b to-transparent pointer-events-none z-[100] from-white dark:from-zinc-800 dark:to-transparent"></div>
<Content {...props} />
</div>
{/each}
@@ -1,161 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
let { alwaysShowUserName = false, onClick = undefined } = $props<{
alwaysShowUserName?: boolean;
onClick?: () => void;
}>();
let cloudState = $state(cloudAuth.state);
let open = $state(false);
let dropdownEl: HTMLElement;
onMount(() => {
const unsubscribe = cloudAuth.subscribe((state) => {
cloudState = state;
});
return unsubscribe;
});
function handleClickOutside(e: MouseEvent) {
if (dropdownEl && !dropdownEl.contains(e.target as Node)) {
open = false;
}
}
$effect(() => {
if (open) {
const timer = setTimeout(() => {
document.addEventListener("click", handleClickOutside);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener("click", handleClickOutside);
};
}
});
async function handleLogout() {
await cloudAuth.logout();
open = false;
}
async function handleSignIn() {
await cloudAuth.startLogin();
open = false;
}
function handleButtonClick() {
if (onClick) {
onClick();
} else {
open = !open;
}
}
function getInitials(): string {
const u = cloudState.user;
if (!u) return "?";
if (u.displayName) return u.displayName.slice(0, 2).toUpperCase();
if (u.username) return u.username.slice(0, 2).toUpperCase();
if (u.email) return u.email.slice(0, 2).toUpperCase();
return "?";
}
</script>
<div class="relative flex items-center" bind:this={dropdownEl}>
<button
type="button"
onclick={handleButtonClick}
class="flex items-center gap-2 px-3 py-1.5 text-[0.75rem] rounded-lg shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white transition-colors duration-200"
>
{#if cloudState.isLoggedIn}
{#if cloudState.user?.pfpUrl}
<img
src={cloudState.user.pfpUrl}
alt=""
class="w-5 h-5 rounded-full object-cover ring-1 ring-zinc-200 dark:ring-zinc-600"
/>
{:else}
<div class="flex items-center justify-center w-5 h-5 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-[0.6rem]">
{getInitials()}
</div>
{/if}
<span
class={alwaysShowUserName
? "inline max-w-[10rem] truncate text-[0.75rem]"
: "hidden max-w-24 truncate sm:inline text-[0.75rem]"}
>
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
</span>
{:else}
<span class="text-sm font-IconFamily" aria-hidden="true">{'\ued53'}</span>
<span class="text-[0.75rem]">Sign in</span>
{/if}
</button>
{#if !onClick && open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute right-0 top-full mt-2 w-80 rounded-xl border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-xl z-[100] overflow-hidden"
onclick={(e) => e.stopPropagation()}
>
<div class="p-4 border-b border-zinc-200 dark:border-zinc-600">
<h3 class="text-xl font-bold text-zinc-900 dark:text-white">BetterSEQTA Cloud</h3>
<p class="text-base text-zinc-500 dark:text-zinc-400">Sync favorites across devices</p>
</div>
<div class="p-4">
{#if cloudState.isLoggedIn}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-3">
{#if cloudState.user?.pfpUrl}
<img
src={cloudState.user.pfpUrl}
alt=""
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
/>
{:else}
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
{getInitials()}
</div>
{/if}
<div class="min-w-0 flex-1">
<p class="text-base font-medium text-zinc-900 dark:text-white truncate">
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
</p>
{#if cloudState.user?.email && cloudState.user?.email !== (cloudState.user?.displayName || cloudState.user?.username)}
<p class="text-base text-zinc-500 dark:text-zinc-400 truncate">{cloudState.user.email}</p>
{/if}
</div>
</div>
<button
type="button"
onclick={handleLogout}
class="w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-900 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
>
Sign out
</button>
</div>
{:else}
<div class="flex flex-col gap-3">
<p class="text-sm text-zinc-600 dark:text-zinc-400">
Sign in to sync favorites across devices.
</p>
<button
type="button"
onclick={handleSignIn}
class="w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
Sign in with BetterSEQTA Cloud
</button>
<p class="text-xs text-center text-zinc-400 dark:text-zinc-500">
Opens accounts.betterseqta.org in a new tab
</p>
</div>
{/if}
</div>
</div>
{/if}
</div>
@@ -1,111 +0,0 @@
<script lang="ts">
import { cloudAuth } from "@/seqta/utils/CloudAuth";
let {
introText,
onSuccess,
compact = false,
} = $props<{
introText?: string;
onSuccess?: () => void;
/** Smaller padding/text for overlays (e.g. SignInToFavoriteModal) */
compact?: boolean;
}>();
let username = $state("");
let password = $state("");
let loading = $state(false);
let error = $state<string | null>(null);
const inputClass = $derived(
compact
? "w-full px-4 py-2 text-sm rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200"
: "w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200",
);
const btnClass = $derived(
compact
? "w-full px-4 py-2 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 disabled:opacity-50 transition-colors duration-200"
: "w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 disabled:opacity-50 transition-colors duration-200",
);
const linkClass = $derived(
compact
? "inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
: "inline-flex items-center justify-center gap-2 px-4 py-3 text-base font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200",
);
async function handleLogin() {
if (loading) return;
error = null;
if (!username.trim() || !password) {
error = "Please enter username and password";
return;
}
loading = true;
try {
const result = await cloudAuth.login(username.trim(), password);
if (result.success) {
password = "";
onSuccess?.();
} else {
error = result.error ?? "Login failed";
}
} finally {
loading = false;
}
}
</script>
{#if introText}
<p
class="mb-4 text-zinc-600 dark:text-zinc-400 {compact ? 'text-sm' : 'text-base'}"
>
{introText}
</p>
{/if}
<form
class="flex flex-col gap-3"
autocomplete="off"
onsubmit={(e) => {
e.preventDefault();
handleLogin();
}}
>
<input
type="text"
name="betterseqta-cloud-username"
autocomplete="off"
placeholder="Email or username"
bind:value={username}
disabled={loading}
readonly
onfocus={(e) => e.currentTarget.removeAttribute("readonly")}
class={inputClass}
/>
<input
type="password"
name="betterseqta-cloud-password"
autocomplete="new-password"
placeholder="Password"
bind:value={password}
disabled={loading}
readonly
onfocus={(e) => e.currentTarget.removeAttribute("readonly")}
class={inputClass}
/>
{#if error}
<p class="text-red-600 dark:text-red-400 {compact ? 'text-sm' : 'text-base'}">{error}</p>
{/if}
<button type="submit" disabled={loading} class={btnClass}>
{loading ? "Signing in..." : "Sign in"}
</button>
<a
href="https://accounts.betterseqta.org/register"
target="_blank"
rel="noopener noreferrer"
class={linkClass}
>
Create account
</a>
</form>
@@ -29,7 +29,7 @@
{#if coverThemes.length > 0}
<div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade>
<div
class="w-full aspect-[5/1] max-h-[500px]"
class="w-full aspect-8/3"
use:emblaCarouselSvelte={{ options, plugins }}
onemblaInit={onInit}
>
@@ -42,25 +42,9 @@
onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }}
onclick={() => setDisplayTheme(theme)}
>
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-full" />
{#if theme.featured === true}
<div class="absolute top-4 left-4 z-[2] pointer-events-none">
<span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100 shadow-sm"
aria-label="Featured theme"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" />
</svg>
Featured
</span>
</div>
{/if}
<img src={theme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-full" />
<div class='absolute bottom-0 left-0 p-8 z-[1]'>
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2>
{#if theme.author}
<p class="text-sm text-white/90 mt-1 mb-1 line-clamp-1">By {theme.author}</p>
{/if}
<p class='text-lg text-white'>{theme.description}</p>
</div>
<div class='absolute bottom-0 left-0 w-full h-1/2 to-transparent bg-linear-to-t from-black/80'></div>
@@ -3,7 +3,6 @@
import logoDark from '@/resources/icons/betterseqta-light-full.png';
import { closeStore } from '@/seqta/ui/renderStore'
import browser from 'webextension-polyfill';
import CloudHeader from './CloudHeader.svelte';
// Props
let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{
@@ -40,8 +39,6 @@
>
Backgrounds
</button>
<CloudHeader />
</div>
<div class="flex relative gap-2">
+8 -118
View File
@@ -1,129 +1,19 @@
<script lang="ts">
import type { Theme } from '@/interface/types/Theme'
let { theme, onClick } = $props<{ theme: Theme; onClick: () => void }>();
import { fade } from 'svelte/transition';
import { onMount } from 'svelte';
let { theme, onClick, toggleFavorite, isLoggedIn, onRequestSignIn } = $props<{
theme: Theme;
onClick: () => void;
toggleFavorite: (theme: Theme) => void;
isLoggedIn: boolean;
onRequestSignIn?: () => void;
}>();
let menuOpen = $state(false);
let menuRef: HTMLDivElement;
onMount(() => {
const closeMenu = (e: MouseEvent) => {
if (menuOpen && menuRef && !menuRef.contains(e.target as Node)) {
menuOpen = false;
}
};
document.addEventListener('click', closeMenu);
return () => document.removeEventListener('click', closeMenu);
});
function handleCardClick(e: MouseEvent) {
if ((e.target as HTMLElement).closest('[data-theme-menu]')) return;
onClick();
}
function handleFavoriteClick(e: MouseEvent) {
e.stopPropagation();
if (isLoggedIn) {
toggleFavorite(theme);
} else {
onRequestSignIn?.();
}
menuOpen = false;
}
</script>
<div
class="relative z-0 hover:z-20 w-full cursor-pointer"
role="button"
tabindex="-1"
onkeydown={onClick}
onclick={handleCardClick}
>
<div
class="bg-gray-50 w-full transition-all duration-500 ease-out relative group flex flex-col rounded-xl overflow-clip border hover:scale-105 hover:shadow-2xl dark:hover:shadow-white/[0.1] dark:hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto"
transition:fade
>
{#if theme.featured === true}
<div class="absolute top-2 left-2 z-20 pointer-events-none">
<span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100 shadow-sm"
aria-label="Featured theme"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" />
</svg>
Featured
</span>
</div>
{/if}
<!-- Menu dropdown -->
<div class="absolute top-2 right-2 z-20" data-theme-menu bind:this={menuRef}>
<button
type="button"
class="flex justify-center items-center w-8 h-8 rounded-lg bg-black/40 hover:bg-black/60 text-white transition-all"
onclick={(e) => { e.stopPropagation(); menuOpen = !menuOpen; }}
aria-label="Theme options"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" class="w-5 h-5">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
</svg>
</button>
{#if menuOpen}
<div
class="absolute right-0 top-full mt-1 py-1 min-w-[140px] rounded-lg bg-white dark:bg-zinc-800 shadow-lg border border-zinc-200 dark:border-zinc-700"
role="menu"
>
<button
type="button"
class="flex gap-2 items-center w-full px-3 py-2 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-700"
role="menuitem"
onclick={handleFavoriteClick}
title={isLoggedIn ? (theme.is_favorited ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill={theme.is_favorited ? 'currentColor' : 'none'}
stroke="currentColor"
stroke-width="2"
class="w-5 h-5 {theme.is_favorited ? 'text-red-500' : ''}"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{theme.is_favorited ? 'Favorited' : 'Favorite'}
</button>
</div>
{/if}
</div>
<div class="absolute bottom-1 left-3 right-3 z-10 mb-1 flex flex-col gap-0.5">
<span class="text-xl font-bold text-white drop-shadow-md">{theme.name}</span>
{#if theme.author}
<span class="text-xs text-white/85 drop-shadow-md line-clamp-1">By {theme.author}</span>
{/if}
<div class="flex gap-3 text-xs font-medium text-white/90 drop-shadow-sm">
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3.5 h-3.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{(theme.download_count ?? 0).toLocaleString()}
</span>
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{(theme.favorite_count ?? 0).toLocaleString()}
</span>
</div>
<div class="w-full cursor-pointer" role="button" tabindex="-1" onkeydown={onClick} onclick={onClick}>
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border" transition:fade>
<div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white">
{theme.name}
</div>
<div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div>
<div class='w-full'>
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
<img src={theme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
</div>
</div>
</div>
@@ -2,14 +2,7 @@
import type { Theme } from '@/interface/types/Theme'
import ThemeCard from './ThemeCard.svelte';
let { themes, searchTerm, setDisplayTheme, toggleFavorite, isLoggedIn, onRequestSignIn } = $props<{
themes: Theme[];
searchTerm: string;
setDisplayTheme: (theme: Theme) => void;
toggleFavorite: (theme: Theme) => void;
isLoggedIn: boolean;
onRequestSignIn?: () => void;
}>();
let { themes, searchTerm, setDisplayTheme } = $props<{ themes: Theme[]; searchTerm: string, setDisplayTheme: (theme: Theme) => void }>();
let filteredThemes = $derived(themes.filter((theme: Theme) =>
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase())
@@ -19,18 +12,12 @@
<div class="relative" >
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
{#each filteredThemes as theme (theme.id)}
<ThemeCard
{theme}
onClick={() => setDisplayTheme(theme)}
{toggleFavorite}
{isLoggedIn}
{onRequestSignIn}
/>
<ThemeCard theme={theme} onClick={() => setDisplayTheme(theme)} />
{/each}
{#if filteredThemes.length !== 0}
<a href="https://docs.betterseqta.org/theme-creation/" class="block relative z-0 hover:z-20 w-full cursor-pointer">
<div class="bg-zinc-50 h-48 w-full transition-all duration-500 ease-out relative overflow-clip rounded-xl border group group/card flex flex-col justify-center items-center hover:scale-105 hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1]">
<a href="https://betterseqta.gitbook.io/betterseqta-docs" class='w-full cursor-pointer'>
<div class="bg-zinc-50 h-48 w-full transition-all hover:scale-105 duration-500 relative justify-center items-center group group/card flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] rounded-xl overflow-clip border">
<div class="text-2xl font-IconFamily">{'\uecb3'}</div>
<div class="text-xl font-bold text-center transition-all duration-500 dark:text-white">
Got a Theme Idea?
@@ -45,7 +32,7 @@
<div class="absolute top-0 flex flex-col items-center justify-center w-full text-center h-96">
<h1 class="mt-4 text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-5xl">That doesn't exist! 😭😭😭</h1>
<p class="mt-6 text-lg leading-7 text-zinc-600 dark:text-zinc-300">Sorry, we couldn't find the theme you're looking for. Maybe... you could create it?</p>
<a href="https://docs.betterseqta.org/theme-creation/" class='p-2 px-3 mt-4 transition rounded-md cursor-pointer dark:text-white bg-zinc-500/10 hover:scale-105'>
<a href="https://betterseqta.gitbook.io/betterseqta-docs" class='p-2 px-3 mt-4 transition rounded-md cursor-pointer dark:text-white bg-zinc-500/10 hover:scale-105'>
Show me how!
</a>
</div>
+18 -101
View File
@@ -2,7 +2,8 @@
import type { Theme } from '@/interface/types/Theme'
import { fade } from 'svelte/transition';
import { animate } from 'motion';
let { theme, currentThemes, setDisplayTheme, onInstall, onRemove, allThemes, displayTheme, toggleFavorite, isLoggedIn, onRequestSignIn } = $props<{
let { theme, currentThemes, setDisplayTheme, onInstall, onRemove, allThemes, displayTheme } = $props<{
theme: Theme | null;
currentThemes: string[];
setDisplayTheme: (theme: Theme | null) => void;
@@ -10,41 +11,17 @@
onRemove: (themeId: string) => void;
allThemes: Theme[];
displayTheme: Theme | null;
toggleFavorite?: (theme: Theme) => void;
isLoggedIn?: boolean;
onRequestSignIn?: () => void;
}>();
let installing = $state(false);
let modalElement: HTMLElement;
function handleFavoriteClick() {
if (isLoggedIn && toggleFavorite && theme) {
toggleFavorite(theme);
} else {
onRequestSignIn?.();
}
}
function tagsOverlap(a: string[] | undefined, b: string[] | undefined): boolean {
const lowerB = new Set((b ?? []).map((t) => t.toLowerCase()));
return (a ?? []).some((t) => lowerB.has(t.toLowerCase()));
}
const relatedThemes = $derived.by(() => {
const t = theme;
if (!t) return [] as Theme[];
if ((t.tags ?? []).length === 0) return [];
// Function to get related themes
function getRelatedThemes() {
return allThemes
.filter((x: Theme) => !!x && x.id !== t.id && tagsOverlap(t.tags, x.tags))
.sort((a: Theme, b: Theme) => {
const diff = (b.download_count ?? 0) - (a.download_count ?? 0);
if (diff !== 0) return diff;
const byName = a.name.localeCompare(b.name);
if (byName !== 0) return byName;
return a.id.localeCompare(b.id);
})
.filter((t: Theme) => t.id !== theme.id)
.sort((a: Theme, b: Theme) => a.name.localeCompare(theme.name) - b.name.localeCompare(theme.name))
.slice(0, 4);
});
}
$effect(() => {
if (displayTheme) {
@@ -95,69 +72,19 @@
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
{#if theme}
<div class="relative h-auto">
<div class="absolute top-0 right-0 flex gap-1 items-center">
<button class="p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200" onclick={() => hideModal()}>
<button class="absolute top-0 right-0 p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200" onclick={() => hideModal()}>
{'\ued8a'}
</button>
</div>
<div class="flex flex-wrap items-center gap-2 pr-12 mb-2">
<h2 class="text-2xl font-bold">
<h2 class="mb-4 text-2xl font-bold">
{theme.name}
</h2>
{#if theme.featured === true}
<span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100"
aria-label="Featured theme"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" />
</svg>
Featured
</span>
{/if}
</div>
{#if theme.author}
<p class="mb-2 text-sm text-zinc-600 dark:text-zinc-400">
By {theme.author}
</p>
{/if}
<div class="flex gap-4 mb-4 text-sm text-zinc-600 dark:text-zinc-400">
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{(theme.download_count ?? 0).toLocaleString()} downloads
</span>
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{(theme.favorite_count ?? 0).toLocaleString()} favorites
</span>
</div>
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Cover" class="object-cover mb-4 w-full rounded-md" />
<img src={theme.marqueeImage} alt="Theme Cover" class="object-cover mb-4 w-full rounded-md" />
<p class="mb-4 text-gray-700 dark:text-gray-300">
{theme.description}
</p>
<div class="flex flex-wrap gap-2 mt-4 justify-end items-center">
{#if toggleFavorite && theme}
<button
type="button"
class="flex items-center gap-2 px-4 py-2 rounded-full transition-all duration-200 hover:scale-105 active:scale-95 {theme.is_favorited ? 'text-red-500 bg-red-500/10 dark:bg-red-500/20' : 'bg-zinc-200 dark:bg-zinc-700 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600'}"
onclick={handleFavoriteClick}
title={isLoggedIn ? (theme.is_favorited ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
aria-label={theme.is_favorited ? 'Unfavorite' : 'Favorite'}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{theme.is_favorited ? 'Favorited' : 'Favorite'}
</button>
{/if}
{#if currentThemes.includes(theme.id)}
<button onclick={async () => {installing = true; await onRemove(theme.id); installing = false}} class="flex relative justify-center items-center px-4 py-2 w-32 text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200 transition-all duration-200 hover:scale-105 active:scale-95">
<button onclick={async () => {installing = true; await onRemove(theme.id); installing = false}} class="flex relative justify-center items-center px-4 py-2 mt-4 ml-auto w-32 text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200">
{#if installing}
<svg class="absolute w-4 h-4 { installing ? 'opacity-100' : 'opacity-0' }" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
@@ -166,7 +93,7 @@
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Remove</span>
</button>
{:else}
<button onclick={async () => {installing = true; await onInstall(theme.id); installing = false}} class="flex relative justify-center items-center px-4 py-2 w-32 text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200 transition-all duration-200 hover:scale-105 active:scale-95">
<button onclick={async () => {installing = true; await onInstall(theme.id); installing = false}} class="flex relative justify-center items-center px-4 py-2 mt-4 ml-auto w-32 text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200">
{#if installing}
<svg class="absolute w-4 h-4 { installing ? 'opacity-100' : 'opacity-0' }" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
@@ -175,35 +102,25 @@
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Install</span>
</button>
{/if}
</div>
{#if relatedThemes.length > 0}
<div class="my-8 border-b border-zinc-200 dark:border-zinc-700"></div>
<h3 class="mb-4 text-lg font-bold">
Related themes
Similar Themes
</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
{#each relatedThemes as relatedTheme (relatedTheme.id)}
<button onclick={() => { hideModal(relatedTheme) }} class="relative z-0 hover:z-20 w-full cursor-pointer">
<div class="bg-gray-50 w-full transition-all duration-500 ease-out relative group group/card flex flex-col hover:scale-105 hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border">
{#each getRelatedThemes() as relatedTheme (relatedTheme.id)}
<button onclick={() => { hideModal(relatedTheme) }} class="w-full cursor-pointer">
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group group/card flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border">
<div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white transition-all duration-500 group-hover:-translate-y-0.5">
{relatedTheme.name}
</div>
<div class="absolute bottom-0 z-0 w-full h-3/4 to-transparent from-black/80 bg-linear-to-t"></div>
<img src={relatedTheme.marqueeImage || relatedTheme.coverImage} alt="Theme Preview" class="object-cover w-full h-48" />
<img src={relatedTheme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-48" />
</div>
</button>
{/each}
</div>
{/if}
</div>
{:else}
<div class="flex justify-center items-center h-full text-zinc-600 dark:text-zinc-300">
<button class="px-4 py-2 rounded-lg bg-zinc-200 dark:bg-zinc-700 transition-all duration-200 hover:scale-105 active:scale-95" onclick={() => hideModal()}>
Close
</button>
</div>
{/if}
</div>
</div>
</div>
@@ -1,14 +1,11 @@
<script lang="ts">
import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
import { onDestroy, onMount } from 'svelte'
import browser from 'webextension-polyfill'
import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
import { OpenStorePage } from '@/seqta/ui/renderStore'
import { themeUpdates } from '@/interface/hooks/ThemeUpdates'
import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
import { cloudAuth } from '@/seqta/utils/CloudAuth'
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte'
const themeManager = ThemeManager.getInstance();
@@ -16,26 +13,13 @@
let { isEditMode } = $props<{ isEditMode: boolean }>();
let isDragging = $state(false);
let tempTheme = $state(null);
let favoriteStatus = $state<Record<string, boolean>>({});
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
let prevLoggedIn = $state(false);
let showSignInModal = $state(false);
cloudAuth.subscribe((s) => {
const now = s.isLoggedIn;
if (now && !prevLoggedIn && themes) void fetchThemes();
prevLoggedIn = now;
cloudLoggedIn = now;
});
const handleThemeClick = async (theme: CustomTheme, e: MouseEvent) => {
const handleThemeClick = async (theme: CustomTheme) => {
if (isEditMode) return;
if (theme.id === themes?.selectedTheme) {
themeManager.setTransitionPoint(e.clientX, e.clientY);
await themeManager.disableTheme();
themes.selectedTheme = '';
} else {
themeManager.setTransitionPoint(e.clientX, e.clientY);
await themeManager.setTheme(theme.id);
if (!themes) return;
themes.selectedTheme = theme.id;
@@ -85,7 +69,7 @@
try {
const result = JSON.parse(event.target?.result as string);
tempTheme = result;
await themeManager.installTheme(result, { fromStore: false });
await themeManager.installTheme(result);
await fetchThemes();
} catch (error) {
console.error('Error parsing file:', error);
@@ -101,55 +85,11 @@
themes: await themeManager.getAvailableThemes(),
selectedTheme: themeManager.getSelectedThemeId() || '',
}
if (themes && cloudLoggedIn) {
const token = await cloudAuth.getStoredToken();
if (token) {
const status: Record<string, boolean> = {};
await Promise.all(
themes.themes.map(async (t) => {
try {
const res = (await browser.runtime.sendMessage({
type: 'fetchThemeDetails',
themeId: t.id,
token,
})) as { success?: boolean; data?: { theme?: { is_favorited?: boolean } } };
if (res?.success && res?.data?.theme) {
status[t.id] = !!res.data.theme.is_favorited;
}
} catch {
// Theme may not exist on store (e.g. locally created)
}
})
);
favoriteStatus = status;
}
} else {
favoriteStatus = {};
}
}
const handleToggleFavorite = async (theme: CustomTheme, e: MouseEvent) => {
e.stopPropagation();
if (!cloudLoggedIn) {
showSignInModal = true;
return;
}
const token = await cloudAuth.getStoredToken();
if (!token) return;
const isFavorite = !favoriteStatus[theme.id];
const result = (await browser.runtime.sendMessage({
type: 'cloudFavorite',
themeId: theme.id,
token,
action: isFavorite ? 'favorite' : 'unfavorite',
})) as { success?: boolean };
if (result?.success) {
favoriteStatus = { ...favoriteStatus, [theme.id]: isFavorite };
}
}
onMount(async () => {
await fetchThemes();
themeUpdates.addListener(fetchThemes);
})
@@ -187,7 +127,7 @@
{#each themes.themes as theme (theme.id)}
<button
class="relative group w-full aspect-theme flex justify-center items-center rounded-xl transition ring dark:ring-white ring-zinc-300 {theme.id === themes.selectedTheme ? 'dark:ring-2 ring-4' : 'ring-0'}"
onclick={(e) => handleThemeClick(theme, e)}
onclick={() => handleThemeClick(theme)}
>
{#if isEditMode}
<div
@@ -202,18 +142,6 @@
{/if}
{#if !isEditMode}
<div
class="flex absolute right-24 top-1/4 z-20 place-items-center p-2 w-8 h-8 text-center rounded-full opacity-0 transition-all -translate-y-1/2 group-hover:opacity-100 group-hover:top-1/2 {(favoriteStatus[theme.id] ?? false) ? 'text-red-400' : 'text-white/80'} bg-black/50"
onclick={(event) => handleToggleFavorite(theme, event)}
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') handleToggleFavorite(theme, event as any) }}
role="button"
tabindex="-1"
title={cloudLoggedIn ? ((favoriteStatus[theme.id] ?? false) ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={(favoriteStatus[theme.id] ?? false) ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</div>
<div
class="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full delay-[20ms] opacity-0 top-1/4 right-2 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-1/2 -translate-y-1/2"
onclick={(event) => { event.stopPropagation(); OpenThemeCreator(theme.id); closeExtensionPopup() }}
@@ -281,7 +209,3 @@
</button>
</div>
</div>
{#if showSignInModal}
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
{/if}
+18 -101
View File
@@ -1,20 +1,8 @@
import { type DBSchema, type IDBPDatabase, openDB } from "idb";
import { type DBSchema, type IDBPDatabase, openDB } from 'idb';
/**
* Defines the schema for the IndexedDB database used for storing background image data.
*
* @interface BackgroundDB
* @extends {DBSchema}
* @property {object} backgrounds - The object store for background images.
* @property {string} backgrounds.key - The type of the key for the object store (in this case, it's `id` as defined in `keyPath`).
* @property {object} backgrounds.value - The structure of the objects stored.
* @property {string} backgrounds.value.id - The unique identifier for the background image record.
* @property {string} backgrounds.value.type - The MIME type of the image (e.g., "image/png", "image/jpeg").
* @property {Blob} backgrounds.value.blob - The binary large object (Blob) containing the image data.
*/
interface BackgroundDB extends DBSchema {
backgrounds: {
key: string; // Corresponds to the 'id' property due to keyPath: "id"
key: string;
value: {
id: string;
type: string;
@@ -25,100 +13,43 @@ interface BackgroundDB extends DBSchema {
let db: IDBPDatabase<BackgroundDB> | null = null;
/**
* Initializes and opens an IndexedDB connection or returns an existing one.
* If the database doesn't exist or needs an upgrade, the `upgrade` callback
* creates the 'backgrounds' object store with 'id' as the keyPath.
*
* @async
* @returns {Promise<IDBPDatabase<BackgroundDB>>} A promise that resolves with the database instance.
*/
export async function openDatabase(): Promise<IDBPDatabase<BackgroundDB>> {
if (db) return db;
db = await openDB<BackgroundDB>("BackgroundDB", 1, {
db = await openDB<BackgroundDB>('BackgroundDB', 1, {
upgrade(db: IDBPDatabase<BackgroundDB>) {
db.createObjectStore("backgrounds", { keyPath: "id" });
db.createObjectStore('backgrounds', { keyPath: 'id' });
},
});
return db;
}
/**
* Retrieves all background image records from the 'backgrounds' object store in IndexedDB.
*
* @async
* @returns {Promise<Array<{id: string, type: string, blob: Blob}>>} A promise that resolves with an array of all background image records.
*/
export async function readAllData(): Promise<
Array<{ id: string; type: string; blob: Blob }>
> {
export async function readAllData(): Promise<Array<{ id: string; type: string; blob: Blob }>> {
const db = await openDatabase();
return db.getAll("backgrounds");
return db.getAll('backgrounds');
}
/**
* Writes or updates a background image record in the 'backgrounds' object store.
* If a record with the given `id` already exists, it will be updated. Otherwise, a new record is created.
*
* @async
* @param {string} id - The unique identifier for the background image record.
* @param {string} type - The MIME type of the image (e.g., "image/png").
* @param {Blob} blob - The Blob object containing the image data.
* @returns {Promise<void>} A promise that resolves when the data has been successfully written.
*/
export async function writeData(
id: string,
type: string,
blob: Blob,
): Promise<void> {
export async function writeData(id: string, type: string, blob: Blob): Promise<void> {
const db = await openDatabase();
await db.put("backgrounds", { id, type, blob });
await db.put('backgrounds', { id, type, blob });
}
/**
* Deletes a background image record from the 'backgrounds' object store by its ID.
*
* @async
* @param {string} id - The unique identifier of the background image record to delete.
* @returns {Promise<void>} A promise that resolves when the data has been successfully deleted.
*/
export async function deleteData(id: string): Promise<void> {
const db = await openDatabase();
await db.delete("backgrounds", id);
await db.delete('backgrounds', id);
}
/**
* Clears all records from the 'backgrounds' object store in IndexedDB.
*
* @async
* @returns {Promise<void>} A promise that resolves when all data has been successfully cleared.
*/
export async function clearAllData(): Promise<void> {
const db = await openDatabase();
await db.clear("backgrounds");
await db.clear('backgrounds');
}
/**
* Retrieves a single background image record from the 'backgrounds' object store by its ID.
*
* @async
* @param {string} id - The unique identifier of the background image record to retrieve.
* @returns {Promise<{id: string, type: string, blob: Blob} | undefined>} A promise that resolves with the
* background image record if found, or undefined otherwise.
*/
export async function getDataById(
id: string,
): Promise<{ id: string; type: string; blob: Blob } | undefined> {
export async function getDataById(id: string): Promise<{ id: string; type: string; blob: Blob } | undefined> {
const db = await openDatabase();
return db.get("backgrounds", id);
return db.get('backgrounds', id);
}
/**
* Closes the active IndexedDB connection and nullifies the global `db` variable.
* This is important to release resources and allow for proper database management.
*/
export function closeDatabase(): void {
if (db) {
db.close();
@@ -126,31 +57,17 @@ export function closeDatabase(): void {
}
}
/**
* Checks if IndexedDB is supported by the current browser environment.
*
* @returns {boolean} True if IndexedDB is supported, false otherwise.
*/
// Helper function to check if IndexedDB is supported
export function isIndexedDBSupported(): boolean {
return "indexedDB" in window;
return 'indexedDB' in window;
}
/**
* Estimates available storage space and checks if it's sufficient for the specified `requiredSpace`.
* Uses the `navigator.storage.estimate()` API if available.
* If the API is not available or cannot determine space, it defaults to assuming enough space is available.
*
* @async
* @param {number} requiredSpace - The amount of storage space required, in bytes.
* @returns {Promise<boolean>} A promise that resolves with true if enough space is estimated to be available, false otherwise.
*/
export async function hasEnoughStorageSpace(
requiredSpace: number,
): Promise<boolean> {
if ("storage" in navigator && "estimate" in navigator.storage) {
// Helper function to check if there's enough storage space
export async function hasEnoughStorageSpace(requiredSpace: number): Promise<boolean> {
if ('storage' in navigator && 'estimate' in navigator.storage) {
const { quota, usage } = await navigator.storage.estimate();
if (quota !== undefined && usage !== undefined) {
return quota - usage > requiredSpace;
return (quota - usage) > requiredSpace;
}
}
// If we can't determine, assume there's enough space
+1 -28
View File
@@ -1,21 +1,11 @@
type BackgroundUpdateCallback = () => void;
/**
* A singleton class used to notify listeners about generic background updates or events.
* These updates typically signify that UI components or other parts of the application
* might need to refresh or re-evaluate background-related data (e.g., after a new background
* image is added, removed, or changed).
*/
class BackgroundUpdates {
private static instance: BackgroundUpdates;
private listeners: Set<BackgroundUpdateCallback> = new Set();
private constructor() {}
/**
* Gets the singleton instance of the BackgroundUpdates class.
* @returns {BackgroundUpdates} The singleton instance.
*/
public static getInstance(): BackgroundUpdates {
if (!BackgroundUpdates.instance) {
BackgroundUpdates.instance = new BackgroundUpdates();
@@ -23,33 +13,16 @@ class BackgroundUpdates {
return BackgroundUpdates.instance;
}
/**
* Registers a callback function to be invoked when a background update is triggered.
*
* @param {BackgroundUpdateCallback} callback The function to call when a background update occurs.
* This callback takes no arguments and returns void.
*/
public addListener(callback: BackgroundUpdateCallback): void {
this.listeners.add(callback);
}
/**
* Unregisters a previously added callback function.
* After calling this method, the provided callback will no longer be invoked when a background update is triggered.
*
* @param {BackgroundUpdateCallback} callback The callback function to remove from the listeners.
*/
public removeListener(callback: BackgroundUpdateCallback): void {
this.listeners.delete(callback);
}
/**
* Invokes all registered listener callbacks, signifying that a background update has occurred.
* This method should be called whenever a change to background data happens that requires
* other parts of the application to be notified.
*/
public triggerUpdate(): void {
this.listeners.forEach((callback) => callback());
this.listeners.forEach(callback => callback());
}
}
+1 -17
View File
@@ -21,32 +21,16 @@ class SettingsPopup {
return SettingsPopup.instance;
}
/**
* Registers a callback function to be invoked when the settings popup is closed.
*
* @param {SettingsPopupCallback} callback The function to call when the settings popup closes.
* This callback takes no arguments and returns void.
*/
public addListener(callback: SettingsPopupCallback): void {
this.listeners.add(callback);
}
/**
* Unregisters a previously added callback function.
* After calling this method, the provided callback will no longer be invoked when the settings popup closes.
*
* @param {SettingsPopupCallback} callback The callback function to remove from the listeners.
*/
public removeListener(callback: SettingsPopupCallback): void {
this.listeners.delete(callback);
}
/**
* Invokes all registered listener callbacks.
* This method should be called when the settings popup is closed to notify all subscribed components or services.
*/
public triggerClose(): void {
this.listeners.forEach((callback) => callback());
this.listeners.forEach(callback => callback());
}
}
+1 -28
View File
@@ -1,21 +1,11 @@
type ThemeUpdateCallback = () => void;
/**
* A singleton class used to notify listeners about theme-related updates.
* These updates can include events like theme changes, custom theme modifications,
* or any other event that might require UI components to refresh their appearance
* or re-apply theme styles.
*/
class ThemeUpdates {
private static instance: ThemeUpdates;
private listeners: Set<ThemeUpdateCallback> = new Set();
private constructor() {}
/**
* Gets the singleton instance of the ThemeUpdates class.
* @returns {ThemeUpdates} The singleton instance.
*/
public static getInstance(): ThemeUpdates {
if (!ThemeUpdates.instance) {
ThemeUpdates.instance = new ThemeUpdates();
@@ -23,33 +13,16 @@ class ThemeUpdates {
return ThemeUpdates.instance;
}
/**
* Registers a callback function to be invoked when a theme update is triggered.
*
* @param {ThemeUpdateCallback} callback The function to call when a theme update occurs.
* This callback takes no arguments and returns void.
*/
public addListener(callback: ThemeUpdateCallback): void {
this.listeners.add(callback);
}
/**
* Unregisters a previously added callback function.
* After calling this method, the provided callback will no longer be invoked when a theme update is triggered.
*
* @param {ThemeUpdateCallback} callback The callback function to remove from the listeners.
*/
public removeListener(callback: ThemeUpdateCallback): void {
this.listeners.delete(callback);
}
/**
* Invokes all registered listener callbacks, signifying that a theme-related update has occurred.
* This method should be called whenever a change related to themes happens that requires
* other parts of the application to be notified.
*/
public triggerUpdate(): void {
this.listeners.forEach((callback) => callback());
this.listeners.forEach(callback => callback());
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
@import "./components/ColourPicker.css";
@import './components/ColourPicker.css';
@tailwind base;
@tailwind components;
+1 -1
View File
@@ -6,7 +6,7 @@
<title>BetterSEQTA+ Settings</title>
</head>
<body class="h-[600px]">
<div id="app" style="height: 100%"></div>
<div id="app" style="height: 100%;"></div>
<script type="module" src="./index.ts"></script>
</body>
</html>
+15 -20
View File
@@ -1,34 +1,29 @@
import "./index.css";
import Settings from "./pages/settings.svelte";
import IconFamily from "@/resources/fonts/IconFamily.woff";
import browser from "webextension-polyfill";
import renderSvelte from "./main";
import { initializeSettingsState } from "@/seqta/utils/listeners/SettingsState";
import "./index.css"
import Settings from "./pages/settings.svelte"
import IconFamily from '@/resources/fonts/IconFamily.woff'
import browser from "webextension-polyfill"
import renderSvelte from "./main"
function InjectCustomIcons() {
console.info("[BetterSEQTA+] Injecting Icons");
console.info('[BetterSEQTA+] Injecting Icons')
const style = document.createElement("style");
style.setAttribute("type", "text/css");
const style = document.createElement('style')
style.setAttribute('type', 'text/css')
style.innerHTML = `
@font-face {
font-family: 'IconFamily';
src: url('${browser.runtime.getURL(IconFamily)}') format('woff');
font-weight: normal;
font-style: normal;
}`;
document.head.appendChild(style);
}`
document.head.appendChild(style)
}
const mountPoint = document.getElementById("app");
const mountPoint = document.getElementById('app')
if (!mountPoint) {
console.error("Mount point #app not found");
throw new Error("Mount point #app not found");
console.error('Mount point #app not found')
throw new Error('Mount point #app not found')
}
InjectCustomIcons();
(async () => {
await initializeSettingsState();
renderSvelte(Settings, mountPoint, { standalone: true });
})();
InjectCustomIcons()
renderSvelte(Settings, mountPoint, { standalone: true })
+1 -1
View File
@@ -1,4 +1,4 @@
import "./index.css";
import './index.css';
declare module "*.png";
declare module "*.svg";
+8 -10
View File
@@ -1,6 +1,6 @@
import { mount } from "svelte";
import type { SvelteComponent } from "svelte";
import style from "./index.css?inline";
import { mount } from "svelte"
import type { SvelteComponent } from "svelte"
import style from './index.css?inline'
export default function renderSvelte(
Component: SvelteComponent | any,
@@ -13,13 +13,11 @@ export default function renderSvelte(
standalone: false,
...props,
},
});
})
if (mountPoint instanceof ShadowRoot) {
const styleElement = document.createElement("style");
styleElement.textContent = style;
mountPoint.appendChild(styleElement);
}
const styleElement = document.createElement('style')
styleElement.textContent = style
mountPoint.appendChild(styleElement)
return app;
return app
}
+37 -280
View File
@@ -1,51 +1,43 @@
<script lang="ts">
import TabbedContainer from "../components/TabbedContainer.svelte";
import Settings from "./settings/general.svelte";
import Shortcuts from "./settings/shortcuts.svelte";
import Theme from "./settings/theme.svelte";
import browser from "webextension-polyfill";
import TabbedContainer from '../components/TabbedContainer.svelte';
import Settings from './settings/general.svelte';
import Shortcuts from './settings/shortcuts.svelte';
import Theme from './settings/theme.svelte';
import browser from 'webextension-polyfill';
import { standalone as StandaloneStore } from "../utils/standalone.svelte";
import { onMount } from "svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { standalone as StandaloneStore } from '../utils/standalone.svelte';
import { onMount } from 'svelte'
import { initializeSettingsState, settingsState } from '@/seqta/utils/listeners/SettingsState'
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup";
import { OpenAboutPage } from "@/seqta/utils/Openers/OpenAboutPage";
import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup";
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
import { OpenAboutPage } from "@/seqta/utils/Openers/OpenAboutPage"
import { OpenWhatsNewPopup } from "@/seqta/utils/Whatsnew"
import ColourPicker from "../components/ColourPicker.svelte";
import CloudPanel from "../components/CloudPanel.svelte";
import DisclaimerModal from "../components/DisclaimerModal.svelte";
import { settingsPopup } from "../hooks/SettingsPopup";
import ColourPicker from '../components/ColourPicker.svelte'
import { settingsPopup } from '../hooks/SettingsPopup'
let devModeSequence = "";
let settingsActiveTab = $state(0);
let showDisclaimerModal = $state(false);
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
let disclaimerTitle = $state("Confirm");
let disclaimerMessage = $state("");
let devModeSequence = '';
const handleDevModeToggle = () => {
const handleKeyDown = (event: KeyboardEvent) => {
devModeSequence += event.key.toLowerCase();
if (devModeSequence.includes("dev")) {
document.removeEventListener("keydown", handleKeyDown);
if (devModeSequence.includes('dev')) {
document.removeEventListener('keydown', handleKeyDown);
settingsState.devMode = true;
alert("Dev mode is now enabled");
alert('Dev mode is now enabled');
}
};
document.addEventListener("keydown", handleKeyDown);
document.addEventListener('keydown', handleKeyDown);
setTimeout(() => {
document.removeEventListener("keydown", handleKeyDown);
devModeSequence = "";
document.removeEventListener('keydown', handleKeyDown);
devModeSequence = '';
}, 10000);
};
const openColourPicker = () => {
showColourPicker = true;
};
}
const openChangelog = () => {
OpenWhatsNewPopup();
@@ -57,280 +49,45 @@
closeExtensionPopup();
};
/* const openMinecraftServer = () => {
OpenMinecraftServerPopup();
closeExtensionPopup();
}; */
const openPrivacyStatement = () => {
window.open("https://betterseqta.org/privacy", "_blank");
closeExtensionPopup();
};
let { standalone } = $props<{ standalone?: boolean }>();
let showColourPicker = $state<boolean>(false);
let showCloudPanel = $state<boolean>(false);
const openCloudPanel = () => {
showCloudPanel = true;
};
const showDisclaimer = (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => {
disclaimerCallbacks = { onConfirm, onCancel };
disclaimerTitle = title ?? "Confirm";
disclaimerMessage = message ?? "";
showDisclaimerModal = true;
};
onMount(() => {
settingsPopup.addListener(() => {
showColourPicker = false;
showCloudPanel = false;
});
if (standalone) {
if (!standalone) return;
initializeSettingsState();
console.log('settingsState', $settingsState);
StandaloneStore.setStandalone(true);
}
});
</script>
<div
class="w-[384px] no-scrollbar shadow-2xl {$settingsState.DarkMode
? 'dark'
: ''} {standalone ? 'h-[600px]' : 'h-full rounded-xl'} overflow-clip"
>
<div
class="flex relative flex-col gap-2 h-full overflow-clip bg-white dark:bg-zinc-800 dark:text-white"
>
<div
class="grid place-items-center border-b border-b-zinc-200/40 dark:border-b-zinc-700/40"
>
<div class="w-[384px] no-scrollbar shadow-2xl {$settingsState.DarkMode ? 'dark' : ''} { standalone ? 'h-[600px]' : 'h-full rounded-xl' } overflow-clip">
<div class="flex relative flex-col gap-2 h-full overflow-clip bg-white dark:bg-zinc-800 dark:text-white">
<div class="grid place-items-center border-b border-b-zinc-200/40">
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<img
src={browser.runtime.getURL(
"resources/icons/betterseqta-dark-full.png",
)}
class="w-4/5 dark:hidden"
alt="Light logo"
onclick={handleDevModeToggle}
/>
<img src={browser.runtime.getURL('resources/icons/betterseqta-dark-full.png')} class="w-4/5 dark:hidden" alt="Light logo" onclick={handleDevModeToggle} />
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<img
src={browser.runtime.getURL(
"resources/icons/betterseqta-light-full.png",
)}
class="hidden w-4/5 dark:block"
alt="Dark logo"
onclick={handleDevModeToggle}
/>
<img src={browser.runtime.getURL('resources/icons/betterseqta-light-full.png')} class="hidden w-4/5 dark:block" alt="Dark logo" onclick={handleDevModeToggle} />
{#if !standalone}
<div class="flex absolute top-1 right-1 gap-1 items-center">
<button
onclick={openAbout}
class="flex justify-center items-center w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700"
>
{"\ueb73"}
</button>
<button
onclick={openChangelog}
class="flex justify-center items-center w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700"
>
{"\ue929"}
</button>
<button
onclick={openPrivacyStatement}
class="flex justify-center items-center w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700"
aria-label="Privacy Statement"
>
{"\uecba"}
</button>
<!-- <button
onclick={openMinecraftServer}
class="flex justify-center items-center p-1 w-8 h-8 rounded-xl bg-zinc-100 dark:bg-zinc-700"
aria-label="Open Minecraft Server"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 64 70"
fill="none"
class="w-full h-full"
>
<path
d="M0 0 C3.96 0 7.92 0 12 0 C12 3.96 12 7.92 12 12 C10.68 12 9.36 12 8 12 C8 10.68 8 9.36 8 8 C6.68 8 5.36 8 4 8 C4 6.68 4 5.36 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(42,10)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 6.6 4 13.2 4 20 C2.68 20 1.36 20 0 20 C0 13.4 0 6.8 0 0 Z "
fill="currentColor"
transform="translate(54,22)"
/>
<path
d="M0 0 C6.6 0 13.2 0 20 0 C20 1.32 20 2.64 20 4 C13.4 4 6.8 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(22,6)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 5.28 4 10.56 4 16 C2.68 16 1.36 16 0 16 C0 10.72 0 5.44 0 0 Z "
fill="currentColor"
transform="translate(46,26)"
/>
<path
d="M0 0 C5.28 0 10.56 0 16 0 C16 1.32 16 2.64 16 4 C10.72 4 5.44 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(22,14)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C5.32 4 6.64 4 8 4 C8 5.32 8 6.64 8 8 C5.36 8 2.72 8 0 8 C0 5.36 0 2.72 0 0 Z "
fill="currentColor"
transform="translate(6,50)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(14,50)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(18,46)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(10,46)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(50,42)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(22,42)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(14,42)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(26,38)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(18,38)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(30,34)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(22,34)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(34,30)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(26,30)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(38,26)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(30,26)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(42,22)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(34,22)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(38,18)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(18,10)"
/>
</svg>
</button> -->
</div>
<button onclick={openChangelog} class="absolute top-1 right-1 w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700">{'\ue929'}</button>
<button onclick={openAbout} class="absolute top-1 right-10 w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700">{'\ueb73'}</button>
{/if}
</div>
<TabbedContainer
bind:activeTab={settingsActiveTab}
tabs={[
{
title: "Settings",
Content: Settings,
props: { showColourPicker: openColourPicker, showDisclaimer, showCloudPanel: openCloudPanel },
},
{ title: "Shortcuts", Content: Shortcuts },
{ title: "Themes", Content: Theme },
]}
/>
<TabbedContainer tabs={[
{ title: 'Settings', Content: Settings, props: { showColourPicker: openColourPicker } },
{ title: 'Shortcuts', Content: Shortcuts },
{ title: 'Themes', Content: Theme },
]} />
</div>
{#if showColourPicker}
<ColourPicker
hidePicker={() => {
showColourPicker = false;
}}
/>
{/if}
{#if showCloudPanel}
<CloudPanel
hidePanel={() => {
showCloudPanel = false;
}}
/>
<ColourPicker hidePicker={() => { showColourPicker = false }} />
{/if}
</div>
{#if showDisclaimerModal && disclaimerCallbacks}
<DisclaimerModal
title={disclaimerTitle}
message={disclaimerMessage}
onConfirm={() => {
disclaimerCallbacks?.onConfirm();
showDisclaimerModal = false;
disclaimerCallbacks = null;
}}
onCancel={() => {
disclaimerCallbacks?.onCancel();
showDisclaimerModal = false;
disclaimerCallbacks = null;
}}
/>
{/if}
+47 -257
View File
@@ -3,23 +3,16 @@
import Button from "../../components/Button.svelte"
import Slider from "../../components/Slider.svelte"
import Select from "@/interface/components/Select.svelte"
import HotkeyInput from "@/interface/components/HotkeyInput.svelte"
import browser from "webextension-polyfill"
import type { SettingsList } from "@/interface/types/SettingsProps"
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte"
import CloudSettingsSync from "@/interface/components/CloudSettingsSync.svelte"
import CloudHeader from "@/interface/components/store/CloudHeader.svelte"
import { cloudAuth } from "@/seqta/utils/CloudAuth"
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent"
import { getAllPluginSettings } from "@/plugins"
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting } from "@/plugins/core/types"
// Union type representing all possible settings
type SettingType =
@@ -30,38 +23,18 @@
type: 'select',
id: string,
options: string[]
}) |
(Omit<ButtonSetting, 'type'> & {
type: 'button',
id: string
}) |
(Omit<HotkeySetting, 'type'> & {
type: 'hotkey',
id: string
}) |
(Omit<ComponentSetting, 'type'> & {
type: 'component',
id: string,
component: any
});
interface Plugin {
pluginId: string;
name: string;
description: string;
beta?: boolean;
settings: Record<string, SettingType>;
}
const pluginSettings = getAllPluginSettings() as Plugin[];
const pluginSettingsValues = $state<Record<string, Record<string, any>>>({});
let cloudState = $state(cloudAuth.state);
$effect(() => {
const unsub = cloudAuth.subscribe((s) => { cloudState = s; });
return unsub;
});
async function loadPluginSettings() {
for (const plugin of pluginSettings) {
if (Object.keys(plugin.settings).length === 0) continue;
@@ -72,11 +45,7 @@
pluginSettingsValues[plugin.pluginId] = stored[storageKey] || {};
for (const [key, setting] of Object.entries(plugin.settings)) {
if (
pluginSettingsValues[plugin.pluginId][key] === undefined &&
setting.type !== 'button' &&
setting.type !== 'component'
) {
if (pluginSettingsValues[plugin.pluginId][key] === undefined) {
pluginSettingsValues[plugin.pluginId][key] = setting.default;
}
}
@@ -103,24 +72,7 @@
loadPluginSettings();
})
const { showColourPicker, showDisclaimer, showCloudPanel } = $props<{
showColourPicker: () => void;
showDisclaimer: (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => void;
showCloudPanel: () => void;
}>();
async function exportCloudSettingsJsonToFile() {
const payload = await getSnapshotForUpload();
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `betterseqta-plus-settings-export-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
a.click();
URL.revokeObjectURL(url);
}
const { showColourPicker } = $props<{ showColourPicker: () => void }>();
</script>
{#snippet Setting({ title, description, Component, props }: SettingsList) }
@@ -138,15 +90,27 @@
<div class="flex flex-col divide-y divide-zinc-100 dark:divide-zinc-700">
{#each [
{
title: "Connect Mobile App",
description: "Link your SEQTA session to DesQTA — the modern desktop and mobile app for SEQTA Learn",
id: 0,
Component: ConnectMobileApp,
props: {}
title: "Transparency Effects",
description: "Enables transparency effects on certain elements such as blur. (May impact battery life)",
id: 1,
Component: Switch,
props: {
state: $settingsState.transparencyEffects,
onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn
}
},
{
title: "Custom Theme Colour",
description: "Customise the overall theme colour of SEQTA Learn.",
id: 4,
Component: PickerSwatch,
props: {
onClick: showColourPicker
}
},
{
title: "Edit Sidebar Layout",
description: "Reorder pages on the sidebar",
description: "Customise the sidebar layout.",
id: 5,
Component: Button,
props: {
@@ -154,28 +118,9 @@
text: "Edit"
}
},
{
title: "Custom Theme Colour",
description: "Customise the overall theme colour of SEQTA Learn",
id: 4,
Component: PickerSwatch,
props: {
onClick: showColourPicker
}
},
{
title: "Icon Only Sidebar",
description: "Show only icons in the sidebar for a compact layout",
id: 14,
Component: Switch,
props: {
state: $settingsState.iconOnlySidebar ?? false,
onChange: (isOn: boolean) => settingsState.iconOnlySidebar = isOn
}
},
{
title: "Animations",
description: "Enable animations on certain pages",
description: "Enables animations on certain pages.",
id: 6,
Component: Switch,
props: {
@@ -193,38 +138,28 @@
onChange: (isOn: boolean) => settingsState.timeFormat = isOn ? "12" : "24"
}
},
{
title: "Transparency Effects",
description: "Enable transparency effects on certain elements, such as blur (May impact battery life)",
id: 1,
Component: Switch,
props: {
state: $settingsState.transparencyEffects,
onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn
}
},
{
title: "Default Page",
description: "Choose which page loads first when you open SEQTA",
description: "The page to load when SEQTA Learn is opened.",
id: 10,
Component: Select,
props: {
state: $settingsState.defaultPage ?? "home",
onChange: (value: string) => (settingsState.defaultPage = value),
state: $settingsState.defaultPage,
onChange: (value: string) => settingsState.defaultPage = value,
options: [
{ value: "home", label: "Home" },
{ value: "dashboard", label: "Dashboard" },
{ value: "timetable", label: "Timetable" },
{ value: "welcome", label: "Welcome" },
{ value: "messages", label: "Messages" },
{ value: "documents", label: "Documents" },
{ value: "reports", label: "Reports" },
],
},
{ value: 'home', label: 'Home' },
{ value: 'dashboard', label: 'Dashboard' },
{ value: 'timetable', label: 'Timetable' },
{ value: 'welcome', label: 'Welcome' },
{ value: 'messages', label: 'Messages' },
{ value: 'documents', label: 'Documents' },
{ value: 'reports', label: 'Reports' },
]
}
},
{
title: "News Feed Source",
description: "Choose the sources for your news feed",
description: "Choose sources of your news feed.",
id: 11,
Component: Select,
props: {
@@ -233,108 +168,44 @@
options: [
{ value: "australia", label: "Australia" },
{ value: "usa", label: "USA" },
{ value: "uk", label: "UK" },
{ value: "taiwan", label: "Taiwan" },
{ value: "hong_kong", label: "Hong Kong" },
{ value: "panama", label: "Panama" },
{ value: "canada", label: "Canada" },
{ value: "singapore", label: "Singapore" },
{ value: "uk", label: "UK" },
{ value: "japan", label: "Japan" },
{ value: "netherlands", label: "Netherlands" }
]
}
}
] as option}
{@render Setting(option)}
{/each}
<div class="border-none">
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Adaptive Theme Colour</h2>
<p class="text-xs">Change the theme colour based on the current class (e.g. when viewing a course or assessments page)</p>
</div>
<div>
<Switch
state={$settingsState.adaptiveThemeColour ?? false}
onChange={(isOn: boolean) => settingsState.adaptiveThemeColour = isOn}
/>
</div>
</div>
{#if $settingsState.adaptiveThemeColour}
<div class="flex justify-between items-center px-4 py-3 pl-6 border-t border-zinc-100 dark:border-zinc-700/50">
<div class="pr-4">
<h2 class="text-sm font-bold">Soft Gradient</h2>
<p class="text-xs">Use a soft gradient instead of a solid colour when viewing a class</p>
</div>
<div>
<Switch
state={$settingsState.adaptiveThemeGradient ?? false}
onChange={(isOn: boolean) => settingsState.adaptiveThemeGradient = isOn}
/>
</div>
</div>
<div class="flex justify-between items-center px-4 py-3 pl-6 border-t border-zinc-100 dark:border-zinc-700/50">
<div class="pr-4">
<h2 class="text-sm font-bold">Smooth colour transition</h2>
<p class="text-xs">Ease between class/subject colours when navigating instead of switching instantly</p>
</div>
<div>
<Switch
state={$settingsState.adaptiveThemeColourTransition ?? true}
onChange={(isOn: boolean) => settingsState.adaptiveThemeColourTransition = isOn}
/>
</div>
</div>
{/if}
</div>
</div>
{#each pluginSettings as plugin}
<div class="border-none">
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40 {!(plugin as any).disableToggle && Object.keys(plugin.settings).length === 0 ? 'hidden' : ''}">
<div>
<!-- Always show enable toggle if disableToggle is true -->
{#if (plugin as any).disableToggle}
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="flex gap-2 items-center text-sm font-bold">
Enable {plugin.name}
{#if plugin.beta}
<span class="px-2 py-0.5 text-xs font-medium text-orange-800 bg-orange-100 rounded-full border border-orange-300/30 dark:bg-orange-900/30 dark:text-orange-300 dark:border-orange-900/30">
Beta
</span>
{/if}
</h2>
<h2 class="text-sm font-bold">Enable {plugin.name}</h2>
<p class="text-xs">{plugin.description}</p>
</div>
<div>
<Switch
state={pluginSettingsValues[plugin.pluginId]?.enabled ?? true}
onChange={async (value) => {
if (plugin.pluginId === 'assessments-average' && value === true) {
showDisclaimer(
async () => {
await updatePluginSetting(plugin.pluginId, 'enabled', true);
},
() => {},
"Assessment Averages Disclaimer",
"This feature calculates a simple average of your assessment grades. It does not take into account:\n• Assessment weightings\n• Different grading scales\n• Other factors used in official reports\n\nThe displayed average may be inaccurate compared to your actual marks found in reports.\n\nDo you want to enable this feature?"
);
return;
}
await updatePluginSetting(plugin.pluginId, 'enabled', value);
}}
onChange={(value) => updatePluginSetting(plugin.pluginId, 'enabled', value)}
/>
</div>
</div>
{/if}
<!-- Only show other settings if plugin is enabled or has no disableToggle -->
{#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)}
{#each Object.entries(plugin.settings) as [key, setting]}
<!-- Skip the 'enabled' setting and hide cloud-only settings when not signed in -->
{#if key !== 'enabled' && !(key === 'useCloudPfp' && !cloudState.isLoggedIn)}
<!-- Skip the 'enabled' setting if it's part of the settings object -->
{#if key !== 'enabled'}
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">{setting.title || key}</h2>
@@ -347,7 +218,6 @@
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
/>
{:else if setting.type === 'number'}
<div class="w-28 shrink-0">
<Slider
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
@@ -355,11 +225,10 @@
max={setting.max}
step={setting.step}
/>
</div>
{:else if setting.type === 'string'}
<input
type="text"
class="px-2 py-1 text-sm rounded-md dark:bg-[#38373D]/50 bg-[#DDDDDD] dark:text-white border-none"
class="px-2 py-1 text-sm rounded-md dark:bg-[#38373D] bg-[#DDDDDD] dark:text-white"
value={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
oninput={(e) => updatePluginSetting(plugin.pluginId, key, e.currentTarget.value)}
/>
@@ -372,21 +241,6 @@
label: opt.charAt(0).toUpperCase() + opt.slice(1)
}))}
/>
{:else if setting.type === 'button'}
<Button
onClick={() => setting.trigger?.()}
text={setting.title}
/>
{:else if setting.type === 'hotkey'}
<HotkeyInput
value={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
/>
{:else if setting.type === 'component'}
{#if setting.component}
{@const Component = setting.component}
<Component />
{/if}
{/if}
</div>
</div>
@@ -394,30 +248,8 @@
{/each}
{/if}
</div>
</div>
{/each}
<div class="border-none">
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">BetterSEQTA Cloud</h2>
<p class="text-xs">Account & sync</p>
</div>
<div>
<CloudHeader alwaysShowUserName onClick={showCloudPanel} />
</div>
</div>
{#if cloudState.isLoggedIn}
<div class="px-3 pb-3">
<CloudSettingsSync showDisclaimer={(onConfirm, onCancel) => showDisclaimer(onConfirm, onCancel, "Restore from cloud?", "This will replace your local settings with the cloud backup. Continue?")} />
</div>
{/if}
</div>
</div>
<div class="p-1 border-none"></div>
{@render Setting({
title: "BetterSEQTA+",
description: "Enables BetterSEQTA+ features",
@@ -430,8 +262,7 @@
})}
{#if $settingsState.devMode}
<div class="flex-col p-1 my-1 bg-gradient-to-br from-white rounded-xl border shadow-sm to-zinc-100 border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
<div class="flex justify-between items-center px-4 py-3">
<div class="flex items-center justify-between px-4 py-3 mt-4 pt-[1.75rem]">
<div class="pr-4">
<h2 class="text-sm font-bold">Developer Mode</h2>
<p class="text-xs">Enables developer mode, allowing you to test new features and changes.</p>
@@ -445,53 +276,12 @@
<h2 class="text-sm font-bold">Sensitive Hider</h2>
<p class="text-xs">Replace sensitive content with mock data</p>
</div>
<div>
<Switch
state={$settingsState.hideSensitiveContent ?? false}
onChange={(isOn: boolean) => settingsState.hideSensitiveContent = isOn}
/>
</div>
</div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Mock Notices</h2>
<p class="text-xs">Use fake notice data on homepage instead of real data</p>
</div>
<div>
<Switch
state={$settingsState.mockNotices ?? false}
onChange={(isOn: boolean) => settingsState.mockNotices = isOn}
/>
</div>
</div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Show Privacy Notification</h2>
<p class="text-xs">Show the privacy notification popup on next page load</p>
</div>
<div>
<Button
onClick={async () => {
settingsState.privacyStatementShown = false;
settingsState.privacyStatementLastUpdated = undefined;
closeExtensionPopup();
// Small delay to ensure popup is closed before showing notification
await new Promise(resolve => setTimeout(resolve, 100));
await showPrivacyNotification();
}}
text="Show Now"
onClick={() => hideSensitiveContent()}
text="Hide"
/>
</div>
</div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Export cloud settings JSON</h2>
<p class="text-xs">Download the same payload as cloud sync (OAuth tokens stripped). For debugging and server testing.</p>
</div>
<div>
<Button onClick={exportCloudSettingsJsonToFile} text="Export to file" />
</div>
</div>
</div>
{/if}
</div>
+19 -91
View File
@@ -3,10 +3,8 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
import Switch from "@/interface/components/Switch.svelte"
import { onMount } from 'svelte';
import Shortcuts from "@/seqta/content/links.json"
let isLoaded = $state(false);
let fileInput = $state<HTMLInputElement | null>(null);
onMount(async () => {
// Wait for settingsState to be initialized
@@ -23,44 +21,15 @@
});
});
const switchChange = (shortcut: any) => {
const idx = $settingsState.shortcuts.findIndex(s => s.name === shortcut);
if (idx !== -1) {
// Create a new array with the toggled value to ensure reactivity
const updated = settingsState.shortcuts.map(s =>
s.name === shortcut ? { ...s, enabled: !s.enabled } : s
);
settingsState.shortcuts = updated;
} else {
settingsState.shortcuts = [
...settingsState.shortcuts,
{ name: shortcut, enabled: true }
];
}
const switchChange = (index: number) => {
const updatedShortcuts = [...settingsState.shortcuts];
updatedShortcuts[index].enabled = !updatedShortcuts[index].enabled;
settingsState.shortcuts = updatedShortcuts;
}
let isFormVisible = $state(false);
let newTitle = $state("");
let newURL = $state("");
let newIcon = $state<string | null>(null);
function handleIconChange(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (file && file.type === "image/svg+xml") {
const reader = new FileReader();
reader.onload = () => {
newIcon = reader.result as string;
};
reader.readAsText(file);
}
}
const clearIcon = () => {
newIcon = null;
if (fileInput) {
fileInput.value = ""; // Clear the file input so the same file can be re-selected
}
};
const toggleForm = () => {
isFormVisible = !isFormVisible;
@@ -80,13 +49,11 @@
const addNewCustomShortcut = () => {
if (isValidTitle(newTitle) && isValidURL(newURL)) {
const icon = newIcon || newTitle[0];
const newShortcut = { name: newTitle.trim(), url: formatUrl(newURL).trim(), icon };
const newShortcut = { name: newTitle.trim(), url: formatUrl(newURL).trim(), icon: newTitle[0] };
settingsState.customshortcuts = [...settingsState.customshortcuts, newShortcut];
newTitle = "";
newURL = "";
newIcon = null;
isFormVisible = false;
} else {
alert("Please enter a valid title and URL.");
@@ -98,6 +65,15 @@
};
</script>
{#snippet Shortcuts([index, Shortcut]: [string, { name: string, enabled: boolean }]) }
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm">{Shortcut.name}</h2>
</div>
<Switch state={Shortcut.enabled} onChange={() => switchChange(parseInt(index))} />
</div>
{/snippet}
<div class="flex flex-col pt-4 divide-y divide-zinc-100 dark:divide-zinc-700">
{#if isLoaded}
<div>
@@ -129,7 +105,7 @@
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05, duration: 0.2 }}
class="flex gap-2 w-full"
class="w-full"
>
<input
class="p-2 my-2 w-full rounded-lg border-0 transition placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
@@ -137,48 +113,6 @@
placeholder="URL eg. https://google.com"
bind:value={newURL}
/>
<input
bind:this={fileInput}
class="p-2 w-full rounded-lg border-0 transition placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
type="file"
accept=".svg"
onchange={handleIconChange}
hidden
/>
<button
type="button"
class="flex justify-between items-center p-2 my-2 text-left rounded-lg border border-dashed transition text-nowrap text-zinc-500 dark:text-zinc-400 bg-zinc-100 dark:bg-zinc-700/50 hover:bg-zinc-200/50 dark:hover:bg-zinc-700/30 focus:bg-zinc-200/50 dark:focus:bg-zinc-600/50 border-zinc-300 dark:border-zinc-600"
onclick={() => fileInput?.click()}
>
{#if newIcon}
<div class="flex overflow-hidden items-center">
<div class="flex-shrink-0 mr-2 w-6 h-6">
<img src={`data:image/svg+xml;base64,${btoa(newIcon)}`} alt="Selected Icon" class="object-contain w-full h-full" />
</div>
<span class="truncate">Selected Icon</span>
</div>
<span
class="p-1 ml-2 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600"
aria-label="Clear icon"
role="button"
tabindex="0"
onclick={(event) => { event.stopPropagation(); clearIcon(); }}
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
clearIcon();
}
}}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</span>
{:else}
<span class="font-IconFamily">{ '\ued47' }</span>
<span class="ml-2">SVG icon <span class="text-xs italic text-zinc-400 dark:text-zinc-500">(Optional)</span></span>
{/if}
</button>
</MotionDiv>
</div>
{/if}
@@ -202,6 +136,10 @@
</MotionDiv>
</div>
{#each Object.entries($settingsState.shortcuts) as shortcut}
{@render Shortcuts(shortcut)}
{/each}
<!-- Custom Shortcuts Section -->
{#each $settingsState.customshortcuts as shortcut, index}
<div class="flex justify-between items-center px-4 py-3">
@@ -213,16 +151,6 @@
</button>
</div>
{/each}
{#each Object.entries(Shortcuts) as shortcut}
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<!-- Use DisplayName if it exists, otherwise use the key (shortcut[0]) as a fallback -->
<h2 class="text-sm">{shortcut[1].DisplayName || shortcut[0]}</h2>
</div>
<Switch state={$settingsState.shortcuts.find(s => s.name === shortcut[0])?.enabled ?? false} onChange={() => switchChange(shortcut[0])} />
</div>
{/each}
{:else}
<div class="p-4 text-center">
Loading shortcuts...
+2 -5
View File
@@ -21,16 +21,13 @@
<div class="relative w-full">
<button
onclick={() => editMode = !editMode}
class="absolute top-0 right-0 z-10 px-2 h-8 text-lg rounded-xl bg-zinc-100 dark:bg-zinc-700">
<span class="mr-2">{editMode ? 'Done' : 'Edit'}</span>
<span class="font-IconFamily">{editMode ? '\ue9e4' : '\uec38'}</span>
</button>
class="absolute top-0 right-0 z-10 w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700">{editMode ? '\ue9e4' : '\uec38'}</button>
<BackgroundSelector isEditMode={editMode} bind:selectedBackground={selectedBackground} bind:selectNoBackground={selectNoBackground} />
<ThemeSelector isEditMode={editMode} />
</div>
{:else}
<div class="flex justify-center items-center w-full h-full">
<div class="flex items-center justify-center w-full h-full">
<div class="text-lg">
Open SEQTA and use the embedded settings to access theme settings. 🫠
</div>
+15 -94
View File
@@ -15,13 +15,8 @@
import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
import Backgrounds from '../components/store/Backgrounds.svelte'
import { cloudAuth } from '@/seqta/utils/CloudAuth'
import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte'
const themeManager = ThemeManager.getInstance();
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
cloudAuth.subscribe((s) => { cloudLoggedIn = s.isLoggedIn; });
// State variables
let searchTerm = $state('');
@@ -35,7 +30,6 @@
let error = $state<string | null>(null);
let selectedBackground = $state<string | null>(null);
let showSignInOverlay = $state(false);
const fetchCurrentThemes = async () => {
const themes = await themeManager.getAvailableThemes();
@@ -54,65 +48,20 @@
activeTab = tab;
};
/** Featured themes first; within each group, newest by `created_at` (API: Unix seconds). */
function compareStoreThemes(a: Theme, b: Theme): number {
const fa = a.featured === true ? 1 : 0;
const fb = b.featured === true ? 1 : 0;
if (fa !== fb) return fb - fa;
const ca = a.created_at ?? 0;
const cb = b.created_at ?? 0;
if (ca !== cb) return cb - ca;
return a.name.localeCompare(b.name);
}
const toggleFavorite = async (theme: Theme) => {
const token = await cloudAuth.getStoredToken();
if (!token) return;
const isFavorite = !theme.is_favorited;
const result = (await browser.runtime.sendMessage({
type: 'cloudFavorite',
themeId: theme.id,
token,
action: isFavorite ? 'favorite' : 'unfavorite',
})) as { success?: boolean };
if (result?.success) {
const delta = isFavorite ? 1 : -1;
themes = themes.map((t) =>
t.id === theme.id
? { ...t, is_favorited: isFavorite, favorite_count: Math.max(0, (t.favorite_count ?? 0) + delta) }
: t
);
if (displayTheme?.id === theme.id) {
displayTheme = {
...displayTheme,
is_favorited: isFavorite,
favorite_count: Math.max(0, (displayTheme.favorite_count ?? 0) + delta),
};
}
}
};
// Fetch themes via background script (avoids CORS when store runs inside SEQTA page)
// Fetch themes and initialize app
const fetchThemes = async () => {
try {
const token = await cloudAuth.getStoredToken();
const data = (await browser.runtime.sendMessage({
type: 'fetchThemes',
token: token ?? undefined,
})) as {
success?: boolean;
data?: { themes: Theme[] };
error?: string;
};
if (!data?.success || !data?.data?.themes) {
throw new Error(data?.error || 'Failed to fetch themes');
}
themes = [...data.data.themes].sort(compareStoreThemes);
coverThemes = themes.slice(0, 3);
const response = await fetch(`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${(new Date()).getTime()}`, { cache: 'no-store' });
const data = await response.json();
themes = data.themes;
// Shuffle for cover themes
const shuffled = [...themes].sort(() => 0.5 - Math.random());
coverThemes = shuffled.slice(0, 3);
loading = false;
} catch (err) {
console.error('Failed to fetch themes', err);
} catch (error) {
console.error('Failed to fetch themes', error);
setTimeout(fetchThemes, 5000); // Retry after 5 seconds if failure occurs
}
};
@@ -126,14 +75,11 @@
darkMode = $settingsState.DarkMode;
});
// Filter themes (list is already featured-first, then newest; filter preserves order)
let filteredThemes = $derived(
themes.filter(
(theme) =>
// Filter themes based on search term
let filteredThemes = $derived(themes.filter(theme =>
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
theme.description.toLowerCase().includes(searchTerm.toLowerCase()),
),
);
theme.description.toLowerCase().includes(searchTerm.toLowerCase())
));
$effect(() => {
loadBackground();
@@ -145,17 +91,6 @@
console.error(error);
}
});
// Refetch themes when user logs in (from another tab) to get is_favorited
let lastLoggedIn = $state(false);
$effect(() => {
if (cloudLoggedIn && !lastLoggedIn) {
lastLoggedIn = true;
fetchThemes();
} else if (!cloudLoggedIn) {
lastLoggedIn = false;
}
});
</script>
<div class="w-screen h-screen bg-white {darkMode ? 'dark' : ''}">
@@ -176,14 +111,7 @@
{/if}
<!-- ThemeGrid to display filtered themes -->
<ThemeGrid
themes={filteredThemes}
{searchTerm}
{setDisplayTheme}
{toggleFavorite}
isLoggedIn={cloudLoggedIn}
onRequestSignIn={() => (showSignInOverlay = true)}
/>
<ThemeGrid themes={filteredThemes} {searchTerm} {setDisplayTheme} />
{#if displayTheme}
<ThemeModal
@@ -192,9 +120,6 @@
theme={displayTheme}
{displayTheme}
{setDisplayTheme}
{toggleFavorite}
isLoggedIn={cloudLoggedIn}
onRequestSignIn={() => (showSignInOverlay = true)}
onInstall={async () => {
if (displayTheme) {
await themeManager.downloadTheme(displayTheme);
@@ -219,8 +144,4 @@
{/if}
</div>
</div>
{#if showSignInOverlay}
<SignInToFavoriteModal onClose={() => (showSignInOverlay = false)} />
{/if}
</div>
+10 -57
View File
@@ -4,10 +4,7 @@
import { slide } from 'svelte/transition';
import { fade } from 'svelte/transition';
import {
type LoadedCustomTheme,
shouldForceThemeAppearance,
} from '@/types/CustomThemes'
import { type LoadedCustomTheme } from '@/types/CustomThemes'
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
@@ -24,9 +21,9 @@
handleImageVariableChange,
handleCoverImageUpload
} from '../utils/themeImageHandlers';
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
import { themeUpdates } from '../hooks/ThemeUpdates'
import { CloseThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
import { themeUpdates } from '../hooks/ThemeUpdates'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
const { themeID } = $props<{ themeID: string }>()
const themeManager = ThemeManager.getInstance();
@@ -43,9 +40,7 @@
coverImage: null,
isEditable: true,
hideThemeName: false,
forceTheme: undefined,
forceDark: undefined,
adaptiveCssVariables: [],
forceDark: undefined
})
let closedAccordions = $state<string[]>([])
let themeLoaded = $state(false);
@@ -85,13 +80,7 @@
}))
}
theme = {
...loadedTheme,
adaptiveCssVariables: loadedTheme.adaptiveCssVariables ?? [],
forceTheme:
loadedTheme.forceTheme ??
(loadedTheme.forceDark !== undefined ? true : undefined),
}
theme = loadedTheme
themeLoaded = true
} else {
themeLoaded = true
@@ -125,14 +114,6 @@
blob: image.blob
}))
themeClone.coverImage = theme.coverImage
themeClone.userEdited = true
if (shouldForceThemeAppearance(themeClone)) {
themeClone.forceTheme = true;
} else {
themeClone.forceTheme = false;
themeClone.forceDark = undefined;
}
themeManager.clearPreview();
await themeManager.saveTheme(themeClone);
@@ -289,7 +270,7 @@
<h1 class='text-xl font-semibold'>Theme Creator</h1>
<a href='https://docs.betterseqta.org/theme-creation/' target='_blank' rel='noopener noreferrer' class='text-sm font-light text-zinc-500 dark:text-zinc-400'>
<a href='https://betterseqta.gitbook.io/betterseqta-docs' target='_blank' class='text-sm font-light text-zinc-500 dark:text-zinc-400'>
<span class='pr-0.5 no-underline font-IconFamily'>{'\ueb44'}</span>
<span class='underline'>
Need help? Check out the docs!
@@ -336,27 +317,6 @@
<Divider />
<div class="py-3">
<h2 class="text-sm font-bold">Adaptive CSS variables</h2>
<p class="text-xs text-zinc-600 dark:text-zinc-400">
One per line, each must start with <code class="text-xs">--</code>. These receive the same colour as the adaptive accent when &quot;Adaptive theme colour&quot; is enabled in general settings. Use them in Custom CSS, e.g. <code class="text-xs">border-color: var(--my-accent);</code>
</p>
<textarea
placeholder="--my-accent&#10;--class-banner"
value={theme.adaptiveCssVariables?.join('\n') ?? ''}
oninput={(e) => {
const lines = e.currentTarget.value
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
theme = { ...theme, adaptiveCssVariables: lines };
}}
class="p-2 mt-2 w-full min-h-[5rem] font-mono text-sm rounded-lg border-0 transition dark:placeholder-zinc-400 bg-zinc-200 dark:bg-zinc-700 focus:outline-none focus:ring-1 focus:ring-zinc-100 dark:focus:ring-zinc-700 focus:bg-zinc-300/50 dark:focus:bg-zinc-600"
></textarea>
</div>
<Divider />
{#each [
{
type: 'switch',
@@ -372,28 +332,21 @@
title: 'Force Theme',
description: 'Force users to use either dark or light mode',
props: {
state: shouldForceThemeAppearance(theme),
onChange: (value: boolean) => {
if (value) {
theme = { ...theme, forceTheme: true, forceDark: false };
} else {
theme = { ...theme, forceTheme: false, forceDark: undefined };
}
}
state: theme.forceDark !== undefined,
onChange: (value: boolean) => theme = { ...theme, forceDark: value ? false : undefined }
}
},
{
type: 'conditional',
props: {
condition: shouldForceThemeAppearance(theme),
condition: theme.forceDark !== undefined,
children: {
type: 'lightDarkToggle',
title: 'Mode',
description: 'Choose whether to force light or dark mode',
props: {
state: theme.forceDark === true,
onChange: (value: boolean) =>
(theme = { ...theme, forceDark: value, forceTheme: true })
onChange: (value: boolean) => theme = { ...theme, forceDark: value }
}
}
}
+1 -1
View File
@@ -2,6 +2,6 @@ export interface SettingsList {
title: string;
id: number;
description: string;
Component: any /* TODO: Give this a type */;
Component: any; /* TODO: Give this a type */
props?: any;
}
+2 -13
View File
@@ -1,18 +1,7 @@
export type Theme = {
id: string;
name: string;
description: string;
coverImage: string;
marqueeImage?: string;
theme_json_url?: string;
is_favorited?: boolean;
favorite_count?: number;
download_count?: number;
author?: string;
featured?: boolean;
tags?: string[];
/** Unix time in seconds (API list/detail). */
created_at?: number;
/** Unix seconds — last server update (GET /api/themes). */
updated_at?: number;
marqueeImage: string;
id: string;
};
-22
View File
@@ -1,22 +0,0 @@
import type { Action } from "svelte/action";
/**
* Svelte action that moves the element to a different DOM target.
* Defaults to the nearest ShadowRoot so styles remain intact when the app
* is rendered inside a shadow DOM. Falls back to document.body otherwise.
* Keeps all Svelte reactivity/events intact while escaping ancestor stacking contexts.
*/
export const portal: Action<HTMLElement, HTMLElement | ShadowRoot | undefined> = (node, target) => {
const root = node.getRootNode();
const dest = target ?? (root instanceof ShadowRoot ? root : document.body);
dest.appendChild(node);
return {
update(newTarget) {
(newTarget ?? dest).appendChild(node);
},
destroy() {
node.remove();
},
};
};
+1 -1
View File
@@ -16,7 +16,7 @@ export class Standalone {
public setStandalone(value: boolean) {
this._standalone = value;
this.subscribers.forEach((subscriber) => subscriber(value));
this.subscribers.forEach(subscriber => subscriber(value));
}
public get standalone() {
+11 -81
View File
@@ -1,49 +1,23 @@
import type { LoadedCustomTheme } from "@/types/CustomThemes";
import type { LoadedCustomTheme } from '@/types/CustomThemes';
/**
* Generates a random 9-character alphanumeric string to be used as a unique ID for images.
* This helps in identifying and managing custom images within a theme.
*
* @returns {string} A randomly generated unique ID string.
*/
export function generateImageId(): string {
return Math.random().toString(36).substr(2, 9);
}
/**
* Handles the upload of a new custom image from a file input event.
* If a file is selected, it reads the file using FileReader, converts it to a Blob,
* generates a unique ID and a default variable name for it, and then adds this new image
* to the `CustomImages` array within the provided `theme` object.
*
* @param {Event} event The file input change event, typically from an `<input type="file">` element.
* @param {LoadedCustomTheme} theme The current theme object to which the new image will be added.
* @returns {Promise<LoadedCustomTheme> | LoadedCustomTheme} A Promise that resolves with the updated theme object
* containing the new image if a file was processed.
* Returns the original theme object synchronously if no file was selected.
*/
export function handleImageUpload(
event: Event,
theme: LoadedCustomTheme,
): Promise<LoadedCustomTheme> | LoadedCustomTheme {
export function handleImageUpload(event: Event, theme: LoadedCustomTheme): Promise<LoadedCustomTheme> | LoadedCustomTheme {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
input.value = '';
if (file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = async () => {
const imageBlob = await fetch(reader.result as string).then((res) =>
res.blob(),
);
const imageBlob = await fetch(reader.result as string).then(res => res.blob());
const imageId = generateImageId();
const variableName = `custom-image-${theme.CustomImages.length}`;
resolve({
...theme,
CustomImages: [
...theme.CustomImages,
{ id: imageId, blob: imageBlob, variableName, url: null },
],
CustomImages: [...theme.CustomImages, { id: imageId, blob: imageBlob, variableName, url: null }],
});
};
reader.readAsDataURL(file);
@@ -52,75 +26,31 @@ export function handleImageUpload(
return theme;
}
/**
* Removes a custom image from the theme based on its ID.
* It filters out the image with the specified `imageId` from the `CustomImages` array
* in the `theme` object.
*
* @param {string} imageId The unique ID of the custom image to be removed.
* @param {LoadedCustomTheme} theme The current theme object from which the image will be removed.
* @returns {LoadedCustomTheme} A new theme object with the specified image removed from its `CustomImages` array.
* This function is synchronous.
*/
export function handleRemoveImage(
imageId: string,
theme: LoadedCustomTheme,
): LoadedCustomTheme {
export function handleRemoveImage(imageId: string, theme: LoadedCustomTheme): LoadedCustomTheme {
return {
...theme,
CustomImages: theme.CustomImages.filter((image) => image.id !== imageId),
} as LoadedCustomTheme;
}
/**
* Updates the CSS variable name associated with a specific custom image in the theme.
* It finds the image by `imageId` in the `CustomImages` array of the `theme` object
* and updates its `variableName` property.
*
* @param {string} imageId The unique ID of the custom image whose variable name is to be updated.
* @param {string} variableName The new CSS variable name to assign to the image.
* @param {LoadedCustomTheme} theme The current theme object containing the image to be updated.
* @returns {LoadedCustomTheme} A new theme object with the updated image variable name.
* This function is synchronous.
*/
export function handleImageVariableChange(
imageId: string,
variableName: string,
theme: LoadedCustomTheme,
): LoadedCustomTheme {
export function handleImageVariableChange(imageId: string, variableName: string, theme: LoadedCustomTheme): LoadedCustomTheme {
return {
...theme,
CustomImages: theme.CustomImages.map((image) =>
image.id === imageId ? { ...image, variableName } : image,
image.id === imageId ? { ...image, variableName } : image
),
} as LoadedCustomTheme;
}
/**
* Handles the upload of a cover image for the theme from a file input event.
* If a file is selected, it reads the file using FileReader, converts it to a Blob,
* and then updates the `coverImage` property of the provided `theme` object with this new blob.
*
* @param {Event} event The file input change event, typically from an `<input type="file">` element.
* @param {LoadedCustomTheme} theme The current theme object whose cover image will be updated.
* @returns {Promise<LoadedCustomTheme>} A Promise that resolves with the updated theme object
* containing the new cover image if a file was processed.
* Returns a Promise resolving with the original theme object if no file was selected.
*/
export function handleCoverImageUpload(
event: Event,
theme: LoadedCustomTheme,
): Promise<LoadedCustomTheme> {
export function handleCoverImageUpload(event: Event, theme: LoadedCustomTheme): Promise<LoadedCustomTheme> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
input.value = '';
if (file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = async () => {
const imageBlob = await fetch(reader.result as string).then((res) =>
res.blob(),
);
const imageBlob = await fetch(reader.result as string).then(res => res.blob());
resolve({ ...theme, coverImage: imageBlob });
};
reader.readAsDataURL(file);
-27
View File
@@ -1,27 +0,0 @@
import * as pdfjs from "pdfjs-dist";
import browser from "webextension-polyfill";
/** Static copies in `src/public` (see `scripts/copy-pdfjs-assets.mjs`, manifest web_accessible_resources). */
const PDF_WORKER_RESOURCE = "resources/pdfjs/pdf.worker.min.mjs";
const PDF_LEGACY_RESOURCE = "resources/pdfjs/pdf.legacy.min.mjs";
function extensionAssetUrl(relativePath: string): string {
return browser.runtime.getURL(relativePath.replace(/^\/+/, ""));
}
let workerConfigured = false;
/** Required before pdfjs spawns a worker (content-script / extension isolate). */
export function ensurePdfjsWorker(): void {
if (workerConfigured) return;
pdfjs.GlobalWorkerOptions.workerSrc = extensionAssetUrl(PDF_WORKER_RESOURCE);
workerConfigured = true;
}
/** Page-context script on Firefox must load these chrome-extension:// URLs (see web_accessible_resources). */
export function getPdfjsPageContextUrls(): { lib: string; worker: string } {
return {
lib: extensionAssetUrl(PDF_LEGACY_RESOURCE),
worker: extensionAssetUrl(PDF_WORKER_RESOURCE),
};
}
+5 -8
View File
@@ -1,12 +1,9 @@
import { createManifest } from "../../lib/createManifest";
import baseManifest from "./manifest.json";
import pkg from "../../package.json";
import { createManifest } from '../../lib/createManifest'
import baseManifest from './manifest.json'
import pkg from '../../package.json'
export const brave = createManifest(
{
export const brave = createManifest({
...baseManifest,
version: pkg.version,
description: pkg.description,
},
"brave",
);
}, 'brave')
+5 -8
View File
@@ -1,12 +1,9 @@
import { createManifest } from "../../lib/createManifest";
import baseManifest from "./manifest.json";
import pkg from "../../package.json";
import { createManifest } from '../../lib/createManifest'
import baseManifest from './manifest.json'
import pkg from '../../package.json'
export const chrome = createManifest(
{
export const chrome = createManifest({
...baseManifest,
version: pkg.version,
description: pkg.description,
},
"chrome",
);
}, 'chrome')
+5 -8
View File
@@ -1,12 +1,9 @@
import { createManifest } from "../../lib/createManifest";
import baseManifest from "./manifest.json";
import pkg from "../../package.json";
import { createManifest } from '../../lib/createManifest'
import baseManifest from './manifest.json'
import pkg from '../../package.json'
export const edge = createManifest(
{
export const edge = createManifest({
...baseManifest,
version: pkg.version,
description: pkg.description,
},
"edge",
);
}, 'edge')
+8 -8
View File
@@ -1,6 +1,6 @@
import { createManifest } from "../../lib/createManifest";
import baseManifest from "./manifest.json";
import pkg from "../../package.json";
import { createManifest } from '../../lib/createManifest'
import baseManifest from './manifest.json'
import pkg from '../../package.json'
const updatedFirefoxManifest = {
...baseManifest,
@@ -10,13 +10,13 @@ const updatedFirefoxManifest = {
scripts: [baseManifest.background.service_worker],
},
action: {
default_popup: "interface/index.html#settings",
"default_popup": "interface/index.html#settings",
},
browser_specific_settings: {
gecko: {
id: "betterseqta@betterseqta.com",
id: pkg.author.email,
},
},
};
}
}
export const firefox = createManifest(updatedFirefoxManifest, "firefox");
export const firefox = createManifest(updatedFirefoxManifest, 'firefox')
+3 -8
View File
@@ -16,12 +16,12 @@
}
},
"permissions": ["tabs", "notifications", "storage"],
"host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"],
"host_permissions": ["https://newsapi.org/", "*://*/*"],
"background": {
"service_worker": "background.ts"
},
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' http: https: https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com https://newsapi.org"
"extension_pages": "script-src 'self'; object-src 'self'"
},
"content_scripts": [
{
@@ -32,12 +32,7 @@
],
"web_accessible_resources": [
{
"resources": [
"resources/icons/*",
"resources/update-image.webp",
"resources/pdfjs/pdf.worker.min.mjs",
"resources/pdfjs/pdf.legacy.min.mjs"
],
"resources": ["*/*", "resources/*", "seqta/utils/migration/migrate.html", "plugins/built-in/globalSearch/*"],
"matches": ["*://*/*"]
}
]

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