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
209 changed files with 23185 additions and 39634 deletions
+120 -103
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
// 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 */
@@ -206,7 +224,7 @@ module.exports = {
As in practice only commonjs ('cjs') and ecmascript modules ('es6')
are widely used, you can limit the moduleSystems to those.
*/
// moduleSystems: ['cjs', 'es6'],
/*
@@ -231,7 +249,7 @@ module.exports = {
"specify": for each dependency identify whether it only exists before compilation or also after
*/
tsPreCompilationDeps: true,
/* list of extensions to scan that aren't javascript or compile-to-javascript.
Empty by default. Only put extensions in here that you want to take into
account that are _not_ parsable.
@@ -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.
@@ -288,7 +306,7 @@ module.exports = {
a hack.
*/
// exoticRequireStrings: [],
/* options to pass on to enhanced-resolve, the package dependency-cruiser
uses to resolve module references to disk. The values below should be
suitable for most situations
@@ -297,7 +315,7 @@ module.exports = {
there will override the ones specified here.
*/
enhancedResolveOptions: {
/* What to consider as an 'exports' field in package.jsons */
/* What to consider as an 'exports' field in package.jsons */
exportsFields: ["exports"],
/* List of conditions to check for in the exports field.
Only works when the 'exportsFields' array is non-empty.
@@ -330,13 +348,13 @@ module.exports = {
for details
*/
skipAnalysisNotInRules: true,
/* List of built-in modules to use on top of the ones node declares.
See https://github.com/sverweij/dependency-cruiser/blob/main/doc/options-reference.md#builtinmodules-influencing-what-to-consider-built-in--core-modules
for details
*/
builtInModules: {
builtInModules: {
add: [
"bun",
"bun:ffi",
@@ -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
+7 -10
View File
@@ -12,15 +12,12 @@
},
"rules": {
// allow importing ts extensions
"sort-imports": [
"error",
{
"ignoreCase": true,
"ignoreDeclarationSort": true,
"ignoreMemberSort": false,
"memberSyntaxSortOrder": ["none", "all", "multiple", "single"]
}
],
"sort-imports": ["error", {
"ignoreCase": true,
"ignoreDeclarationSort": true,
"ignoreMemberSort": false,
"memberSyntaxSortOrder": ["none", "all", "multiple", "single"]
}],
"import/extensions": [
"error",
"ignorePackages",
@@ -32,4 +29,4 @@
]
},
"plugins": ["import"]
}
}
+47 -48
View File
@@ -2,56 +2,55 @@ 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:
value: |
Before reporting an issue, [please search](https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues) to make sure it has not already been reported (make sure to search closed issues as well!).
- type: markdown
attributes:
value: |
Before reporting an issue, [please search](https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues) to make sure it has not already been reported (make sure to search closed issues as well!).
- type: textarea
attributes:
label: Describe the bug
description: Describe your issue. For general issues and questions you'll get a faster answer [from our Discord.](https://discord.gg/YzmbnCDkat)
validations:
required: true
- type: textarea
attributes:
label: Describe the bug
description: Describe your issue. For general issues and questions you'll get a faster answer [from our Discord.](https://discord.gg/YzmbnCDkat)
validations:
required: true
- type: input
attributes:
label: Extension version
description: What version of the extension are you using?
placeholder: Find it by opening the config menu and clicking the about icon in the top right.
validations:
required: true
- type: input
attributes:
label: Extension version
description: What version of the extension are you using?
placeholder: Find it by opening the config menu and clicking the about icon in the top right.
validations:
required: true
- type: dropdown
attributes:
label: Browser
description: Which Browser are you using?
options:
- Chrome
- Firefox
- Brave
- Safari
- DuckDuckGO
- Microsoft Edge
- Other Chromium-Based Browser
- Other Non-Chromium-Based Browser
validations:
required: true
- type: dropdown
attributes:
label: Browser
description: Which Browser are you using?
options:
- Chrome
- Firefox
- Brave
- Safari
- DuckDuckGO
- Microsoft Edge
- Other Chromium-Based Browser
- Other Non-Chromium-Based Browser
validations:
required: true
- type: checkboxes
attributes:
label: Confirm
options:
- label: This bug report is about an issue with the extension itself. I have not modified the extension nor added any unsupported plugins. If this is not the case, I know that I should post the issue to the extension's Discord support channel instead.
required: true
- type: textarea
attributes:
label: Additional context
description: Screenshots, video or any other information. Include photos of the console if possible
placeholder: |
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false
- type: checkboxes
attributes:
label: Confirm
options:
- label: This bug report is about an issue with the extension itself. I have not modified the extension nor added any unsupported plugins. If this is not the case, I know that I should post the issue to the extension's Discord support channel instead.
required: true
- type: textarea
attributes:
label: Additional context
description: Screenshots, video or any other information. Include photos of the console if possible
placeholder: |
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false
+40 -38
View File
@@ -2,51 +2,53 @@ 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
options:
- label: "Is this feature request related to a Bug report?"
required: false
- type: input
attributes:
- type: checkboxes
attributes:
label: Confirm
options:
- label: "Is this feature request related to a Bug report?"
required: false
- type: input
attributes:
label: Bug report link
description: "If this feature request is related to a bug report, please insert the link to the bug report here"
placeholder: "https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues/..."
validations:
validations:
required: false
- type: markdown
attributes:
value: |
## 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: markdown
attributes:
value: |
## 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
multiple: false
options:
- Graphical
- Functional
- Not Sure
validations:
required: true
- type: dropdown
attributes:
label: Feature type
multiple: false
options:
- Graphical
- Functional
- Not Sure
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
validations:
required: false
- type: input
attributes:
label: Feature Details
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)
validations:
required: false
- type: textarea
attributes:
label: Additional details
description: Anything else you want to add?
validations:
required: false
+19 -19
View File
@@ -2,9 +2,9 @@ name: NodeJS Build
on:
push:
branches: ["main"]
branches: [ "main" ]
pull_request:
branches: ["main"]
branches: [ "main" ]
jobs:
build:
@@ -15,24 +15,24 @@ jobs:
node-version: [20.x]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Build
run: |
npm install --legacy-peer-deps
npm run build
- name: Build
run: |
npm install --legacy-peer-deps
npm run build
- name: Zip dist folder
run: |
zip -r dist.zip dist
- name: Zip dist folder
run: |
zip -r dist.zip dist
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: dist-zip
path: dist.zip
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: dist-zip
path: dist.zip
+11 -11
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
@@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
+19 -18
View File
@@ -1,4 +1,5 @@
#
#
<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" />
@@ -52,19 +53,21 @@
## Creating Custom Themes
If you are looking to create custom themes, I would recommend you start at the official documentation [here](https://betterseqta.gitbook.io/betterseqta-docs). You can see some premade examples along with a compilation script that can be used to allow for CSS frameworks and libraries such as SCSS to be used [here](https://github.com/BetterSEQTA/BetterSEQTA-Theme-Generator).
If you are looking to create custom themes, I would recommend you start at the official documentation [here](https://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 :)
## Getting started
&nbsp;&nbsp;&nbsp; **1. Clone the repository**
1. Clone the repository
```
git clone https://github.com/BetterSEQTA/BetterSEQTA-Plus
```
&nbsp;&nbsp;&nbsp; **2. Install dependencies**
1. Install dependencies
You may install the dependencies like below:
@@ -72,35 +75,34 @@ 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:
But it is recommended to do it like this:
```
npm install --legacy-peer-deps # Only NPM supported
```
### Running Development
&nbsp;&nbsp;&nbsp; **3. Run the dev script (it updates as you save files)**
2. Run the dev script (it updates as you save files)
```
npm run dev # or use your preferred package manager
npm run dev # or use your perferred package manager
```
### Building for production
&nbsp;&nbsp;&nbsp; **4. Run the build script**
2. Run the build script
```
npm run build # or use your preferred package manager
npm run build # or use your perferred package manager
```
&nbsp;&nbsp;&nbsp; **4.1. Package it up (optional)**
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 preferred package manager
npm run zip # This REQUIRES 7-Zip to be installed in order to work. You can also use your perferred package manager
```
&nbsp;&nbsp;&nbsp; **5. Load the extension into chrome**
3. Load the extension into chrome
- Go to `chrome://extensions`
- Enable developer mode
@@ -114,7 +116,7 @@ Just remember, in order to update changes to the extension if you are running in
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.
@@ -128,10 +130,9 @@ The folder structure is as follows:
</a>
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
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
+4 -5
View File
@@ -4,13 +4,12 @@
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: |
| 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
+1 -3
View File
@@ -7,13 +7,11 @@ Welcome to the BetterSEQTA+ documentation! This documentation will help you unde
## 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+
### 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
@@ -49,4 +47,4 @@ To contribute to the documentation:
## License
BetterSEQTA+ is licensed under the [MIT License](../LICENSE).
BetterSEQTA+ is licensed under the [MIT License](../LICENSE).
+1 -7
View File
@@ -22,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
@@ -106,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
@@ -120,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
```
@@ -160,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
@@ -177,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
@@ -200,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
@@ -265,4 +259,4 @@ If you have any questions about contributing, please:
2. Ask in the Discord server
3. Open a GitHub Discussion
Thank you for contributing to BetterSEQTA+! Your efforts help make SEQTA better for students and teachers everywhere.
Thank you for contributing to BetterSEQTA+! Your efforts help make SEQTA better for students and teachers everywhere.
+1 -3
View File
@@ -132,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
@@ -140,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
@@ -179,4 +177,4 @@ bun run dev
Now that you have BetterSEQTA+ installed, you can:
- [Getting Started with Plugins](./plugins/getting-started.md)
- [Contribute to the project](../CONTRIBUTING.md)
- [Contribute to the project](../CONTRIBUTING.md)
+46 -86
View File
@@ -5,7 +5,6 @@ Hey there! 👋 So you want to create a plugin for BetterSEQTA+? That's awesome!
## 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
@@ -17,43 +16,40 @@ 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!
// 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);
});
// 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;
@@ -68,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
@@ -84,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
@@ -102,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({
@@ -115,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
@@ -130,22 +121,22 @@ const settingsInstance = new MyPluginClass();
const myPlugin: Plugin<typeof settings> = {
// ... other plugin details ...
settings: settingsInstance.settings,
run: async (api) => {
// Use the setting
if (api.settings.showMessage) {
// Show the message
}
// Listen for setting changes
api.settings.onChange("showMessage", (newValue) => {
api.settings.onChange('showMessage', (newValue) => {
if (newValue) {
// Show the message
} else {
// Hide the message
}
});
},
}
};
```
@@ -155,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);
});
```
@@ -172,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
@@ -187,7 +178,7 @@ Want to make your plugin look pretty? You can add CSS styles:
```typescript
const myPlugin: Plugin = {
// ... other plugin details ...
// Add your CSS here
styles: `
.my-plugin-message {
@@ -205,10 +196,10 @@ const myPlugin: Plugin = {
to { transform: translateY(0); opacity: 1; }
}
`,
run: async (api) => {
// Your plugin code here
},
}
};
```
@@ -217,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
@@ -250,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
@@ -289,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 checkout the api reference [here](./api-reference.md)
Happy coding and feel free to checkout the api reference [here](./api-reference.md)
+78 -130
View File
@@ -7,13 +7,9 @@ This document provides detailed technical information about BetterSEQTA+'s plugi
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({
@@ -21,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
@@ -35,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";
timetable.appendChild(button);
},
);
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
@@ -128,7 +91,7 @@ const seqtaPlugin: Plugin<typeof settings> = {
timetableUnregister();
pageUnregister();
};
},
}
};
export default seqtaPlugin;
@@ -139,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",
@@ -169,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
@@ -188,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;
@@ -221,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,
@@ -236,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;
@@ -261,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;
@@ -304,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
@@ -327,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
}
@@ -335,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);
@@ -344,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
@@ -359,8 +308,7 @@ 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
- Follow the plugin structure shown above
- Follow the plugin structure shown above
-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',
],
};
+2 -35
View File
@@ -1,49 +1,16 @@
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}`;
return `export default '${dataURL}';`;
},
};
};
+12 -45
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
if(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)
},
};
}
}
+15 -28
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
}
+21 -54
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; // Let Vite handle other modules normally
},
};
return null
}
}
}
+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;
}
+55 -161
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 match = file.match(/@([\d\.]+)-/);
console.log(
"Matching file:",
file,
"Version found:",
match ? match[1] : "None",
);
const versions = files.map(file => {
const match = file.match(/@([\d\.]+)-/);
console.log('Matching file:', file, 'Version found:', match ? match[1] : 'None');
if (!match) return null;
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 fullVersion = match[1]; // Original version (e.g., 3.4.5.1)
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
return { fullVersion, semverVersion };
}).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;
// 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;
console.log('Final selected latest version:', latestVersion);
return latestVersion;
}
/**
* 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);
runPublishCommand(browsers);
+11 -49
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) {
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);
}
}
});
}
},
// log all of the staticImportedUrls
const importers = modules[0]._clientModule.importers
importers.forEach((importer) => {
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]
}
+3 -18
View File
@@ -1,6 +1,6 @@
{
"name": "betterseqtaplus",
"version": "3.4.7",
"version": "3.4.6.1",
"type": "module",
"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",
@@ -36,7 +36,7 @@
"@babel/plugin-transform-runtime": "^7.26.9",
"@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.0.91",
"@crxjs/vite-plugin": "2.0.0-beta.32",
"@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",
@@ -64,17 +64,6 @@
"@codemirror/view": "^6.36.4",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/forms": "^0.5.10",
"@tiptap/core": "^2.14.0",
"@tiptap/extension-bubble-menu": "^2.14.0",
"@tiptap/extension-dropcursor": "^2.14.0",
"@tiptap/extension-image": "^2.14.0",
"@tiptap/extension-link": "^2.14.0",
"@tiptap/extension-placeholder": "^2.14.0",
"@tiptap/extension-task-item": "^2.14.0",
"@tiptap/extension-task-list": "^2.14.0",
"@tiptap/extension-typography": "^2.14.0",
"@tiptap/starter-kit": "^2.14.0",
"@tiptap/suggestion": "^2.14.0",
"@tsconfig/svelte": "^5.0.4",
"@types/chrome": "^0.0.308",
"@types/color": "^4.2.0",
@@ -86,14 +75,12 @@
"@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",
@@ -103,7 +90,6 @@
"mathjs": "^14.4.0",
"million": "^3.1.11",
"motion": "^12.4.12",
"motion-start": "^0.1.15",
"postcss": "^8.5.3",
"react": "17",
"react-best-gradient-color-picker": "3.0.11",
@@ -111,7 +97,6 @@
"rss-parser": "^3.13.0",
"sortablejs": "^1.15.6",
"svelte": "^5.22.6",
"svelte-hero-icons": "^5.2.0",
"typescript": "^5.8.2",
"uuid": "^11.1.0",
"vite": "^6.2.1",
+32 -47
View File
@@ -1,80 +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 * as plugins from "@/plugins"
import { main } from "@/seqta/main"
export let MenuOptionsOpen = false;
var IsSEQTAPage = false;
let hasSEQTAText = false;
export let MenuOptionsOpen = 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()
}
/**
* Initializes BetterSEQTA+ on a SEQTA page.
*
* This function performs the following steps:
* 1. Verifies that the current page is a SEQTA page.
* 2. Injects CSS styles for document loading.
* 3. Changes the page's favicon.
* 4. Initializes the extension's settings state.
* 5. Sets default storage if settings are not already defined.
* 6. Calls the main function to apply core BetterSEQTA+ modifications.
* 7. Initializes legacy and new plugins if the extension is enabled.
* 8. Logs success or error messages during initialization.
*/
async function init() {
const hasSEQTATitle = document.title.includes("SEQTA Learn");
const hasSEQTATitle = document.title.includes("SEQTA Learn")
if (hasSEQTAText && hasSEQTATitle && !IsSEQTAPage) {
// Verify we are on a SEQTA page
IsSEQTAPage = true;
console.info("[BetterSEQTA+] Verified SEQTA Page");
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 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
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) {
// Initialize legacy plugins
plugins.Monofile()
// Initialize new plugin system
await plugins.initializePlugins();
}
console.info(
"[BetterSEQTA+] Successfully initialised BetterSEQTA+, starting to load assets.",
);
)
} catch (error: any) {
console.error(error);
console.error(error)
}
}
}
}
+107 -92
View File
@@ -1,68 +1,63 @@
import browser from "webextension-polyfill";
import browser from 'webextension-polyfill'
import type { SettingsState } from "@/types/storage";
import { fetchNews } from "./background/news";
import { fetchNews } from './background/news';
function reloadSeqtaPages() {
const result = browser.tabs.query({});
function open(tabs: any) {
const result = browser.tabs.query({})
function open (tabs: any) {
for (let tab of tabs) {
if (tab.title.includes("SEQTA Learn")) {
if (tab.title.includes('SEQTA Learn')) {
browser.tabs.reload(tab.id);
}
}
}
result.then(open, console.error);
result.then(open, console.error)
}
// @ts-ignore
browser.runtime.onMessage.addListener(
(request: any, _: any, sendResponse: (response?: any) => void) => {
switch (request.type) {
case "reloadTabs":
reloadSeqtaPages();
break;
browser.runtime.onMessage.addListener((request: any, _: any, sendResponse: (response?: any) => void) => {
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);
}
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);
});
break;
});
return true;
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 "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");
}
return false;
},
);
case 'sendNews':
fetchNews(request.source ?? 'australia', sendResponse);
return true;
default:
console.log('Unknown request type');
}
return false;
});
const DefaultValues: SettingsState = {
onoff: true,
@@ -91,31 +86,66 @@ const DefaultValues: 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: true,
assessmentsAverage: true,
defaultPage: "home",
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",
newsSource: 'australia',
};
function SetStorageValue(object: any) {
@@ -128,8 +158,7 @@ function convertBksliderToSpeed(bksliderinput: number): number {
const minBase = 50;
const maxBase = 150;
const scaledValue =
2 + ((maxBase - bksliderinput) / (maxBase - minBase)) ** 4;
const scaledValue = 2 + ((maxBase - bksliderinput) / (maxBase - minBase)) ** 4;
const baseSpeed = 3;
const speed = baseSpeed / scaledValue;
@@ -137,64 +166,50 @@ function convertBksliderToSpeed(bksliderinput: number): number {
}
async function migrateLegacySettings() {
const storage = (await browser.storage.local.get(
null,
)) as unknown as SettingsState;
const storage = await browser.storage.local.get(null) as unknown as SettingsState;
// Animated Background Migration
if ("animatedbk" in storage || "bksliderinput" in storage) {
if ('animatedbk' in storage || 'bksliderinput' in storage) {
const animatedSettings = {
enabled: storage.animatedbk ?? true,
speed: storage.bksliderinput
? convertBksliderToSpeed(parseFloat(storage.bksliderinput))
: 1,
speed: storage.bksliderinput ? convertBksliderToSpeed(parseFloat(storage.bksliderinput)) : 1
};
await browser.storage.local.set({
"plugin.animated-background.settings": animatedSettings,
});
await browser.storage.local.set({ 'plugin.animated-background.settings': animatedSettings });
}
// Assessments Average Migration
if ("assessmentsAverage" in storage || "lettergrade" in storage) {
if ('assessmentsAverage' in storage || 'lettergrade' in storage) {
const assessmentsSettings = {
enabled: storage.assessmentsAverage ?? true,
lettergrade: storage.lettergrade ?? false,
lettergrade: storage.lettergrade ?? false
};
await browser.storage.local.set({
"plugin.assessments-average.settings": assessmentsSettings,
});
await browser.storage.local.set({ 'plugin.assessments-average.settings': assessmentsSettings });
}
if ("selectedTheme" in storage) {
if ('selectedTheme' in storage) {
const themesSettings = { enabled: true };
await browser.storage.local.set({
"plugin.themes.settings": themesSettings,
});
await browser.storage.local.set({ 'plugin.themes.settings': themesSettings });
}
if (storage.notificationCollector !== false) {
await browser.storage.local.set({
"plugin.notificationCollector.settings": { enabled: true },
});
await browser.storage.local.set({ 'plugin.notificationCollector.settings': { enabled: true } });
} else {
await browser.storage.local.set({
"plugin.notificationCollector.settings": { enabled: false },
});
await browser.storage.local.set({ 'plugin.notificationCollector.settings': { enabled: false } });
}
const keysToRemove = [
"animatedbk",
"bksliderinput",
"assessmentsAverage",
"lettergrade",
'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"]);
browser.storage.local.remove(['justupdated']);
browser.storage.local.remove(['data']);
if (event.reason == "install" || event.reason == "update") {
if ( event.reason == 'install' || event.reason == 'update' ) {
browser.storage.local.set({ justupdated: true });
migrateLegacySettings();
}
+15 -52
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,41 +49,25 @@ 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, 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`;
fetchAustraliaNews(url, sendResponse);
@@ -111,7 +76,7 @@ export async function fetchNews(source: string, sendResponse: any) {
const parser = new Parser();
let feeds: string[];
console.log("fetchNews", source);
console.log('fetchNews', source)
if (rssFeedsByCountry[source.toLowerCase()]) {
// If the source is a country, fetch from predefined feeds
@@ -120,9 +85,7 @@ export async function fetchNews(source: string, sendResponse: any) {
// If the source is a URL, use it directly
feeds = [source];
} else {
throw new Error(
"Invalid source. Provide a country code or a valid RSS feed URL.",
);
throw new Error("Invalid source. Provide a country code or a valid RSS feed URL.");
}
const articlesPromises = feeds.map(async (feedUrl) => {
+3 -5
View File
@@ -15,7 +15,7 @@
* along with EvenBetterSEQTA. If not, see <https://www.gnu.org/licenses/>.
*/
@use "injected/popup.scss";
@use 'injected/popup.scss';
html {
background: #161616 !important;
@@ -77,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 {
@@ -93,4 +91,4 @@ body:has(.outside-container:not(.hide))
top: 115% !important;
background: var(--text-primary) !important;
color: var(--theme-primary) !important;
}
}
+1 -1
View File
@@ -1 +1 @@
import "./documentload.scss";
import './documentload.scss';
+2 -11
View File
@@ -15,7 +15,7 @@
* along with EvenBetterSEQTA. If not, see <https://www.gnu.org/licenses/>.
*/
body {
body {
background: transparent;
}
@@ -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 {
@@ -113,11 +111,4 @@ body {
text-decoration: underline;
transition: text-shadow 0.5s;
}
}
.cke_panel_listItem > a {
&:hover {
background: #3d3d3e !important;
}
}
+75 -666
View File
@@ -12,13 +12,6 @@
font-family: Rubik, sans-serif !important;
}
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
pointer-events: none;
}
.hidden {
display: none;
}
@@ -671,12 +664,14 @@ td.colourBar {
}
#toolbar span:has(.search) {
position: relative;
/* Makes sure the pseudo-element is positioned relative to this element */
}
#toolbar .search {
padding-left: 30px;
}
#toolbar span:has(.search)::before {
content: "\eca5";
/* Unicode for the search icon */
position: absolute;
left: 8px;
z-index: 10;
@@ -689,7 +684,7 @@ td.colourBar {
}
#container #content .search {
width: 100%;
border-radius: 8px;
border-radius: 16px;
background: var(--background-primary);
}
#container #content .uiButton {
@@ -763,7 +758,7 @@ ol > [data-label] {
margin-left: 4px;
margin-bottom: 4px;
}
[class*="Message__Message___"] > .uiFrameWrapper .iframeWrapper {
[class*="Message__Message___"] > .uiFrameWrapper .iframeWrapper {
background: transparent;
}
[class*="Viewer__newMessage___"] {
@@ -926,7 +921,6 @@ div > ol:has(.uiFileHandlerWrapper) {
.notices-container {
input {
border: none;
color: var(--text-color) !important;
&:hover {
border: none;
@@ -952,23 +946,6 @@ div > ol:has(.uiFileHandlerWrapper) {
opacity: 0.8;
}
#content:has(#main > .course) #toolbar {
top: 72px;
left: 0px;
z-index: 10;
@media (min-width: 1401px) {
position: absolute;
left: 402px;
}
}
#main > .course .content {
@media (min-width: 1400px) {
padding-top: 2.5rem;
}
}
#main > .notices > .notice > .contents {
background: var(--background-primary);
}
@@ -1062,7 +1039,6 @@ div > ol:has(.uiFileHandlerWrapper) {
max-width: 1050px;
margin: auto;
width: 1050px;
overflow-x: hidden;
display: flex;
flex-direction: column;
gap: 32px;
@@ -1109,11 +1085,9 @@ div > ol:has(.uiFileHandlerWrapper) {
transition: 200ms;
width: 100%;
height: 15em;
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(130px, 1fr);
display: flex;
flex-direction: row;
border-radius: 16px;
overflow-x: auto;
.day:first-child {
border-bottom-left-radius: 16px;
@@ -1176,6 +1150,11 @@ div > ol:has(.uiFileHandlerWrapper) {
font-size: 20px;
font-weight: 400;
}
.notices-container h2 {
margin: 20px;
font-size: 20px;
font-weight: 400;
}
.notice {
position: relative;
padding: 20px;
@@ -1246,6 +1225,17 @@ div > ol:has(.uiFileHandlerWrapper) {
position: relative;
transition: 200ms;
}
.customshortcut::after {
content: "Custom Shortcut";
position: absolute;
top: -4px;
right: -15px;
font-size: 8px;
padding: 2px 5px;
background: var(--better-alert-highlight);
border-radius: 8px;
color: white;
}
.shortcut:hover {
background: var(--auto-background);
}
@@ -1346,6 +1336,20 @@ div > ol:has(.uiFileHandlerWrapper) {
font-size: 10px !important;
font-weight: 500;
}
.day-empty {
font-size: 30px;
display: flex;
width: 100%;
border-radius: 16px 0;
align-items: center;
}
.day-empty img {
margin: 20px;
height: 50%;
}
.day-empty p {
margin: 0;
}
.waitWindow {
background: var(--better-main);
}
@@ -1371,13 +1375,10 @@ div > ol:has(.uiFileHandlerWrapper) {
margin: 20px auto 0px;
cursor: pointer;
}
.dark
[class*="notifications__detailsBody___"]
> [class*="notifications__subtitle___"] {
.dark [class*="notifications__detailsBody___"] > [class*="notifications__subtitle___"] {
color: #c1bcbc;
}
[class*="notifications__detailsBody___"]
> [class*="notifications__subtitle___"] {
[class*="notifications__detailsBody___"] > [class*="notifications__subtitle___"] {
font-size: 12px;
}
[class*="notifications__notifications___"] > button {
@@ -1393,9 +1394,7 @@ div > ol:has(.uiFileHandlerWrapper) {
height: 25px;
width: 24px;
}
[class*="notifications__notifications___"]
> button
> [class*="notifications__bubble___"] {
[class*="notifications__notifications___"] > button > [class*="notifications__bubble___"] {
background: var(--better-alert-highlight);
width: 25px;
height: 25px;
@@ -1524,9 +1523,9 @@ div > ol:has(.uiFileHandlerWrapper) {
background: var(--background-primary);
}
[class*="Input__Input___"]::before,
.navigator .bar.flat::before,
.ais-btnSearch::before {
content: "";
/* Unicode for the search icon */
transform: translateY(-50%);
color: currentColor;
font-size: 16px;
@@ -1615,13 +1614,6 @@ iframe.userHTML {
[class*="Thermoscore__Thermoscore___"] {
background-image: unset;
background: var(--auto-background);
border-radius: 8px;
}
.dark [class*="Thermoscore__Thermoscore___"] {
border: 2px solid rgba(255, 255, 255, 0.3);
}
[class*="AssessmentItem__meta___"] {
padding-bottom: 8px;
}
#toolbar {
color: var(--text-primary);
@@ -1636,6 +1628,9 @@ iframe.userHTML {
.dailycal > .times > .time {
padding: 0 !important;
}
.navigator {
border-top-right-radius: 16px;
}
.programmeNavigator > .navigator,
.programmeNavigator > .navigator > li > ul {
background: var(--background-primary);
@@ -1644,132 +1639,10 @@ iframe.userHTML {
.programmeNavigator > .navigator > .week > .lessons > .lesson:hover {
background: var(--auto-background);
}
.pane .navigator::after {
content: unset !important;
}
.programmeNavigator {
box-shadow: 0 0 40px 0px rgba(0,0,0,0.05);
.navigator {
padding: 6px !important;
.bar.flat::before {
z-index: 10;
left: 8px;
margin: 0;
position: absolute;
top: 50%;
}
&::after {
content: "";
position: fixed;
z-index: 1;
top: 70px;
width: 390px;
height: 60px;
background: linear-gradient(to bottom, var(--background-primary) 50%, rgba(0, 0, 0, 0));
pointer-events: none;
}
.search {
padding: 10px;
padding-left: 30px;
top: 8px;
margin-top: -50px;
z-index: 2;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.1);
}
.bar {
top: 13px;
padding-right: 4px;
position: sticky;
pointer-events: none;
button {
position: unset;
pointer-events: auto;
}
}
.meta {
border-radius: 8px;
}
.cover {
padding: 12px;
}
.cover,
.lesson {
border-radius: 8px;
transition: background 0.1s ease-out;
position: relative;
color: var(--text-primary) !important;
z-index: 1;
&:hover {
background: transparent !important;
}
&.selected {
background: transparent !important;
}
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 8px;
background: var(--auto-background);
opacity: 0;
scale: 0.95;
transition: opacity 0.2s ease-out, scale 0.1s ease-out;
z-index: -1;
pointer-events: none;
}
&:hover::before {
opacity: 0.5;
scale: 1;
}
&.selected::before {
opacity: 1;
scale: 1;
}
}
}
}
.pane {
.content:has(.programmeNavigator) {
margin: 0;
}
.programmeNavigator .navigator {
.search {
border-radius: 8px;
}
&::before {
top: 83px;
}
}
}
.dark .programmeNavigator .navigator {
.search {
background: var(--background-secondary) !important;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.1), inset 0px 0px 15px 0px rgba(0, 0, 0, 0.1) !important;
}
.programmeNavigator > .navigator > .week > .lessons > .lesson.selected,
.programmeNavigator > .navigator > .cover.selected {
background: transparent;
color: var(--text-primary) !important;
}
.dark #main > .course > .content > h1 {
text-shadow: 0 0 10px black;
@@ -1819,19 +1692,12 @@ ul {
.programmeNavigator {
width: 400px;
background: var(--background-primary);
border-top-right-radius: 16px;
position: relative;
}
#userActions > .details > .code {
text-transform: initial;
}
div:has(> [class*="AssessmentDetails__AssessmentDetails___"]) {
padding: 4px;
}
[class*="SelectedAssessment__due___"] {
border-radius: 8px !important;
background: var(--background-primary) !important;
}
[class*="SelectedAssessment__SelectedAssessment___"] {
color: var(--text-primary);
}
@@ -1844,9 +1710,7 @@ div:has(> [class*="AssessmentDetails__AssessmentDetails___"]) {
> [class*="SelectedAssessment__meta___"] {
border-bottom: 1px solid var(--better-main);
}
[class*="TabSet__TabSet___"]
> ol[class*="TabSet__tabs___"]
> li[class*="TabSet__selected___"] {
[class*="TabSet__TabSet___"] > ol[class*="TabSet__tabs___"] > li[class*="TabSet__selected___"] {
border-bottom-color: var(--better-main);
}
[class*="TabSet__TabSet___"] > ol[class*="TabSet__tabs___"] {
@@ -1982,15 +1846,6 @@ div.entry.class[style*="width: 46.5%"] {
min-width: 0;
width: auto !important;
}
div.entry.tutorial {
border-radius: 4px;
}
div.entry.event {
border-radius: 4px;
}
.uiFileHandler .uiButton {
border-radius: 32px !important;
color: var(--text-primary) !important;
@@ -2204,10 +2059,6 @@ div.bar.flat {
}
}
.dark .cke_combo_button {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="white" viewBox="0 0 24 24"><path d="M6.984 9.984h10.031l-5.016 5.016z"/></svg>') !important;
}
.quicktable {
border-radius: 12px;
}
@@ -2296,23 +2147,6 @@ body {
}
[class*="Viewer__Viewer___"] {
background: unset;
[class*="ReadingPane__ReadingPane___"] {
> [class*="Message__unread___"] > header {
box-shadow: none !important;
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 4px;
background: var(--better-main);
}
}
}
}
.weekend {
display: none !important;
@@ -2347,9 +2181,7 @@ body {
border-radius: 1600px;
}
[class*="MessageList__MessageList___"]
> ol
> li[class*="MessageList__selected___"]
[class*="MessageList__MessageList___"] > ol > li[class*="MessageList__selected___"]
[class*="MessageList__unread___"] {
box-shadow: none;
}
@@ -2358,9 +2190,7 @@ body {
box-shadow: none;
}
[class*="MessageList__MessageList___"]
> ol
> li[class*="MessageList__unread___"]::before,
[class*="MessageList__MessageList___"] > ol > li[class*="MessageList__unread___"]::before,
[class*="MessageList__MessageList___"] > ol > li::before {
content: "";
position: absolute;
@@ -2372,9 +2202,7 @@ body {
transition: width 0.1s;
}
[class*="MessageList__MessageList___"]
> ol
> li[class*="MessageList__unread___"]::before {
[class*="MessageList__MessageList___"] > ol > li[class*="MessageList__unread___"]::before {
width: 3px;
}
.connectedNotificationsWrapper > div > button {
@@ -2455,13 +2283,9 @@ body {
background: var(--background-secondary);
}
[class*="MessageList__MessageList___"]
> ol
> li[class*="MessageList__selected___"] {
[class*="MessageList__MessageList___"] > ol > li[class*="MessageList__selected___"] {
background: rgb(228 225 225);
color: var(--text-primary);
box-shadow: none !important;
position: relative;
}
.NewsArticle {
border-radius: 16px !important;
@@ -2605,17 +2429,21 @@ body {
}
[data-label="inbox"] > [class*="LabelList__name___"]::before {
content: "\eb70";
/* Unicode for the search icon */
color: currentColor;
font-size: 16px;
margin-right: 8px;
/* Adjusted to margin-right for the icon to be on the left */
font-family: "IconFamily";
pointer-events: none;
}
[data-label="outbox"] > [class*="LabelList__name___"]::before {
content: "\eca6";
/* Unicode for the search icon */
color: currentColor;
font-size: 16px;
margin-right: 8px;
/* Adjusted to margin-right for the icon to be on the left */
font-family: "IconFamily";
pointer-events: none;
}
@@ -2624,14 +2452,17 @@ body {
color: currentColor;
font-size: 16px;
margin-right: 8px;
/* Adjusted to margin-right for the icon to be on the left */
font-family: "IconFamily";
pointer-events: none;
}
[data-label="trash"] > [class*="LabelList__name___"]::before {
content: "\ed2c";
/* Unicode for the search icon */
color: currentColor;
font-size: 16px;
margin-right: 8px;
/* Adjusted to margin-right for the icon to be on the left */
font-family: "IconFamily";
pointer-events: none;
}
@@ -2731,11 +2562,9 @@ body {
transform: translateY(-6px) rotate(-45deg);
}
.day-empty {
font-size: 30px;
display: flex;
align-items: center;
height: 15em;
width: 100%;
border-radius: 16px 0;
img {
margin: 20px;
@@ -2744,7 +2573,6 @@ body {
p {
margin: 0;
font-size: 30px;
}
}
.upcoming-submittedtext {
@@ -2954,6 +2782,7 @@ body {
cursor: pointer;
}
.day {
width: 100%;
display: flex;
flex-direction: column;
box-shadow:
@@ -2981,6 +2810,18 @@ body {
.dark .day h3 {
color: #c1bcbc;
}
.day-empty {
font-size: 30px;
display: flex;
align-items: center;
}
.day-empty img {
margin: 20px;
height: 50%;
}
.day-empty p {
margin: 0;
}
.upcoming-items {
background: var(--background-primary);
@@ -3270,7 +3111,7 @@ body {
margin: 0 auto;
}
.whatsnewImg {
margin: 0 auto;
margin: 8px auto;
width: 90%;
border-radius: 16px;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.3);
@@ -3289,26 +3130,6 @@ body {
text-indent: -1em;
padding-left: 1em;
}
.whatsnewTextContainer .beta {
padding-left: 0.5rem;
padding-right: 0.5rem;
padding-top: 0.125rem;
padding-bottom: 0.125rem;
margin-left: 2px;
font-size: 0.75rem;
font-weight: 500;
color: #9a3412;
background-color: #ffedd569;
border-radius: 9999px;
border: 1px solid rgba(253, 186, 140, 0.3);
background-color: #ffedd5;
border-color: rgba(253, 186, 140, 0.3);
}
.dark .whatsnewTextContainer .beta {
border-color: rgb(124 45 18 / 0.3);
background-color: rgb(124 45 18 / 0.3);
color: rgb(253 186 116);
}
.whatsnewTextHeader {
font-size: 1.4em !important;
color: #4dd868;
@@ -3534,415 +3355,3 @@ body {
-ms-overflow-style: none;
scrollbar-width: none !important;
}
#menu ul {
-ms-overflow-style: none;
scrollbar-width: none !important;
}
.notice-modal-content {
border: none !important;
}
.notice-unified-content.notice-modal-state {
border: none !important;
}
// Notice card hover effects for main page cards
.notice-unified-content.notice-card-state:not([data-transitioning]) {
cursor: pointer;
&:hover {
background: var(--background-secondary) !important;
border-color: rgba(255, 255, 255, 0.2) !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15) !important;
}
&:last-child {
margin-bottom: 0;
}
}
.notice-badge {
padding: 4px 10px;
border-radius: 16px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.notice-staff {
font-size: 12px;
color: var(--text-secondary);
opacity: 0.7;
}
.notice-preview {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
// Modal styles
.notice-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.notice-modal-transition {
position: fixed;
z-index: 10001;
transition: none; // Controlled by motion animations
}
.notice-modal-content {
background: var(--background-primary);
border-radius: 16px;
max-width: 600px;
max-height: 80vh;
width: 100%;
overflow: hidden;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
&.notice-transitioning {
max-width: none;
max-height: none;
width: 100%;
height: 100%;
position: relative;
}
}
.notice-unified-content {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--background-primary);
display: flex;
flex-direction: column;
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.dark .notice-unified-content {
color: white;
}
.notice-unified-content {
h1, h2, h3, h4, h5, h6 {
margin: 0 !important;
padding: 0 !important;
font-weight: inherit !important;
color: inherit !important;
text-shadow: none !important;
}
.notice-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-shrink: 0;
margin-bottom: 12px;
gap: 16px;
}
.notice-content-title {
font-size: 20px !important; // Nice middle ground - not too big, not too small
font-weight: 600 !important;
color: var(--text-primary) !important;
margin: 0 0 12px 0 !important;
line-height: 1.3 !important;
flex-shrink: 0;
}
.notice-content-body {
font-size: 14px !important;
color: var(--text-secondary) !important;
line-height: 1.5 !important;
margin: 0 !important;
flex: 1;
display: block;
// Force stable layout dimensions - content renders at full size always
min-width: 600px; // Ensure tables have consistent width for layout
width: 100%;
}
// The ONLY difference between states is clipping!
&.notice-card-state {
.notice-content-body {
// Clip to show only 2 lines but keep full layout
overflow: hidden;
max-height: 3em; // ~2 lines worth of height
}
}
&.notice-modal-state {
.notice-close-btn {
opacity: 1;
}
.notice-content-body {
// Show full content with scrolling
overflow-y: auto;
// Custom scrollbar for long content
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
// Style content elements nicely
p {
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
}
a {
color: var(--theme-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
ul, ol {
margin: 12px 0;
padding-left: 20px;
}
li {
margin-bottom: 4px;
}
}
}
}
.notice-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.notice-badge-row {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
button.notice-close-btn {
position: absolute !important;
font-size: 20px !important;
top: 12px;
right: 12px;
background: var(--background-secondary);
border: none;
width: 32px;
height: 32px;
border-radius: 50% !important;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 18px;
color: var(--text-primary);
transition: all 0.2s ease !important;
flex-shrink: 0;
opacity: 0;
&:hover {
background: var(--background-tertiary);
transform: scale(1.1);
}
}
.notice-modal-badge-row {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.notice-modal-badge {
padding: 6px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
}
.notice-modal-staff {
font-size: 14px;
color: var(--text-secondary);
opacity: 0.8;
}
.notice-modal-close {
background: var(--background-secondary);
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 18px;
color: var(--text-primary);
transition: all 0.2s ease;
flex-shrink: 0;
&:hover {
background: var(--background-tertiary);
transform: scale(1.1);
}
}
.notice-modal-title {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
margin: 16px 20px 20px 20px;
line-height: 1.3;
flex-shrink: 0;
}
.notice-modal-body {
padding: 0 20px 20px 20px;
font-size: 15px;
line-height: 1.6;
color: var(--text-secondary);
flex: 1;
overflow-y: auto;
// Custom scrollbar
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
// Style content elements
p {
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
}
a {
color: var(--theme-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
ul, ol {
margin: 12px 0;
padding-left: 20px;
}
li {
margin-bottom: 4px;
}
}
// Dark mode adjustments
.dark {
.notice-card {
border-color: rgba(255, 255, 255, 0.05);
&:hover {
border-color: rgba(255, 255, 255, 0.1);
}
}
.notice-modal-content {
border-color: rgba(255, 255, 255, 0.05);
}
}
// Mobile responsiveness
@media (max-width: 768px) {
.notice-modal-overlay {
padding: 10px;
}
.notice-modal-content {
max-height: 90vh;
}
.notice-modal-title {
font-size: 20px;
margin: 12px 16px 16px 16px;
}
.notice-modal-body {
padding: 0 16px 16px 16px;
}
.notice-card {
padding: 12px;
}
.notice-preview {
font-size: 13px;
}
}
h2.home-subtitle {
margin: 20px;
font-size: 20px;
font-weight: 400;
}
+2 -4
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;
}
+2 -2
View File
@@ -25,7 +25,7 @@
padding-top: 2px;
}
.sub:has(ul > li.hasChildren.active) > .nav > .back {
.sub:has(ul>li.hasChildren.active) > .nav > .back {
display: none !important;
}
@@ -40,4 +40,4 @@
#menu > ul:has(li.hasChildren.active) > li.active {
background: transparent !important;
}
}
+1 -1
View File
@@ -12,4 +12,4 @@ html:not(.dark) {
--theme-primary: #ffffff;
--theme-secondary: #e5e7eb;
--text-primary: black;
}
}
+14 -10
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,32 +37,38 @@ 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,
.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 {
backdrop-filter: blur(8px);
}
}
}
}
+8 -8
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;
}
@@ -33,4 +33,4 @@ declare module "*.gif?base64" {
declare module "*.svg?base64" {
const value: string;
export default value;
}
}
+1 -1
View File
@@ -2,6 +2,6 @@
let { onClick, text } = $props<{ onClick: () => void, text: string, [key: string]: any }>();
</script>
<button onclick={onClick} class='px-5 py-1.5 text-[0.75rem] shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg'>
<button onclick={onClick} class='px-4 py-1 text-[0.75rem] dark:bg-[#38373D] bg-[#DDDDDD] dark:text-white rounded-md'>
{text}
</button>
+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>
+30 -45
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,49 +35,37 @@ 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);
let updatedPresets;
if (existingIndex === 0) {
// No need to update if the selected color is already the first element
return;
@@ -90,19 +78,16 @@ export default function Picker({
} else {
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
@@ -112,12 +97,12 @@ export default function Picker({
value={customThemeColor ?? ""}
onChange={(color: string) => {
if (customOnChange) {
customOnChange(color);
setCustomThemeColor(color);
customOnChange(color)
setCustomThemeColor(color)
} else {
setCustomThemeColor(color);
setCustomThemeColor(color)
}
}}
/>
);
)
}
-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>
+12 -14
View File
@@ -8,17 +8,15 @@
let select: HTMLSelectElement;
</script>
<div class="border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 shadow-2xl rounded-lg w-full overflow-clip">
<select
bind:this={select}
value={state}
onchange={() => onChange(select.value)}
class="px-4 py-1 text-[0.75rem] dark:text-white w-full border-none bg-transparent focus:ring-0 focus:bg-white/20 dark:focus:bg-black/10"
>
{#each options as option}
<option value={option.value}>
{option.label}
</option>
{/each}
</select>
</div>
<select
bind:this={select}
value={state}
onchange={() => onChange(select.value)}
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}>
{option.label}
</option>
{/each}
</select>
+2 -3
View File
@@ -16,9 +16,9 @@
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%;
}
+2 -2
View File
@@ -1,4 +1,4 @@
.dark .switch[data-ison="true"],
.switch[data-ison="true"] {
background-color: #30d259;
}
background-color: #30D259;
}
+9 -2
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"
@@ -41,4 +42,10 @@
bind:this={handle}
class="w-6 h-6 bg-white dark:bg-[#FEFEFE] rounded-full drop-shadow-md"
></div>
</div>
</div>
<style>
.switch[data-ison="true"] {
background-color: #30D259;
}
</style>
+1 -1
View File
@@ -1,3 +1,3 @@
.tab-width {
width: var(--tab-width);
}
}
@@ -43,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}
/>
@@ -65,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'}"
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>
<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}%;">
<Content {...props} />
</div>
{/each}
@@ -14,14 +14,12 @@
let isDragging = $state(false);
let tempTheme = $state(null);
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;
@@ -129,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
+19 -102
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,33 +57,19 @@ 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
return true;
}
}
+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());
}
}
+3 -19
View File
@@ -1,13 +1,13 @@
type SettingsPopupCallback = () => void;
/**
/**
* This is a singleton that triggers an update when the settings popup is closed.
* This is used to close the colour picker.
* Usage:
* settingsPopup.addListener(() => {
* console.log('Settings popup closed');
* });
*/
*/
class SettingsPopup {
private static instance: SettingsPopup;
private listeners: Set<SettingsPopupCallback> = new Set();
@@ -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 @@
export let selectedBackground = $state<string | null>(null);
export let selectedBackground = $state<string | null>(null);
+2 -2
View File
@@ -1,4 +1,4 @@
@import "./components/ColourPicker.css";
@import './components/ColourPicker.css';
@tailwind base;
@tailwind components;
@@ -47,4 +47,4 @@ input {
.editorHeight {
height: calc(100vh - 58px);
}
}
+2 -2
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>
</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 })
+2 -2
View File
@@ -1,6 +1,6 @@
import "./index.css";
import './index.css';
declare module "*.png";
declare module "*.svg";
declare module "*.jpeg";
declare module "*.jpg";
declare module "*.jpg";
+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
}
+5 -3
View File
@@ -7,7 +7,7 @@
import { standalone as StandaloneStore } from '../utils/standalone.svelte';
import { onMount } from 'svelte'
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
import { initializeSettingsState, settingsState } from '@/seqta/utils/listeners/SettingsState'
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
import { OpenAboutPage } from "@/seqta/utils/Openers/OpenAboutPage"
@@ -52,19 +52,21 @@
let { standalone } = $props<{ standalone?: boolean }>();
let showColourPicker = $state<boolean>(false);
onMount(async () => {
onMount(() => {
settingsPopup.addListener(() => {
showColourPicker = false;
});
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="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} />
+82 -140
View File
@@ -3,7 +3,6 @@
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"
@@ -13,37 +12,23 @@
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 =
type SettingType =
(Omit<BooleanSetting, 'type'> & { type: 'boolean', id: string }) |
(Omit<StringSetting, 'type'> & { type: 'string', id: string }) |
(Omit<NumberSetting, 'type'> & { type: 'number', id: string }) |
(Omit<SelectSetting<string>, 'type'> & {
type: 'select',
id: string,
(Omit<SelectSetting<string>, 'type'> & {
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>;
}
@@ -60,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;
}
}
@@ -203,97 +184,72 @@
{/each}
{#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>
<p class="text-xs">{plugin.description}</p>
</div>
<div>
<Switch
state={pluginSettingsValues[plugin.pluginId]?.enabled ?? true}
onChange={(value) => updatePluginSetting(plugin.pluginId, 'enabled', value)}
/>
</div>
{#if (plugin as any).disableToggle}
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Enable {plugin.name}</h2>
<p class="text-xs">{plugin.description}</p>
</div>
{/if}
{#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)}
{#each Object.entries(plugin.settings) as [key, setting]}
<!-- 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>
<p class="text-xs">{setting.description || ''}</p>
</div>
<div>
{#if setting.type === 'boolean'}
<Switch
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
/>
{:else if setting.type === 'number'}
<Slider
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
min={setting.min}
max={setting.max}
step={setting.step}
/>
{: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"
value={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
oninput={(e) => updatePluginSetting(plugin.pluginId, key, e.currentTarget.value)}
/>
{:else if setting.type === 'select'}
<Select
<div>
<Switch
state={pluginSettingsValues[plugin.pluginId]?.enabled ?? true}
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 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>
<p class="text-xs">{setting.description || ''}</p>
</div>
<div>
{#if setting.type === 'boolean'}
<Switch
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
options={(setting.options as string[]).map(opt => ({
value: opt,
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>
/>
{:else if setting.type === 'number'}
<Slider
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
min={setting.min}
max={setting.max}
step={setting.step}
/>
{:else if setting.type === 'string'}
<input
type="text"
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)}
/>
{:else if setting.type === 'select'}
<Select
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
options={(setting.options as string[]).map(opt => ({
value: opt,
label: opt.charAt(0).toUpperCase() + opt.slice(1)
}))}
/>
{/if}
</div>
{/if}
{/each}
{/if}
</div>
</div>
</div>
{/if}
{/each}
{/if}
</div>
{/each}
<div class="p-1 border-none"></div>
{@render Setting({
title: "BetterSEQTA+",
description: "Enables BetterSEQTA+ features",
@@ -306,39 +262,25 @@
})}
{#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="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>
</div>
<div>
<Switch state={$settingsState.devMode} onChange={(isOn: boolean) => settingsState.devMode = isOn} />
</div>
<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>
</div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Sensitive Hider</h2>
<p class="text-xs">Replace sensitive content with mock data</p>
</div>
<div>
<Button
onClick={() => hideSensitiveContent()}
text="Hide"
/>
</div>
<div>
<Switch state={$settingsState.devMode} onChange={(isOn: boolean) => settingsState.devMode = isOn} />
</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">Sensitive Hider</h2>
<p class="text-xs">Replace sensitive content with mock data</p>
</div>
<div>
<Button
onClick={() => hideSensitiveContent()}
text="Hide"
/>
</div>
</div>
{/if}
+24 -90
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,38 +21,15 @@
});
});
const switchChange = (shortcut: any) => {
const value = $settingsState.shortcuts.find(s => s.name === shortcut);
if (value) {
value.enabled = !value.enabled;
settingsState.shortcuts = settingsState.shortcuts;
} 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;
@@ -74,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.");
@@ -92,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>
@@ -123,56 +105,14 @@
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"
type="text"
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>
<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"
type="text"
placeholder="URL eg. https://google.com"
bind:value={newURL}
/>
</MotionDiv>
</div>
{/if}
@@ -196,14 +136,8 @@
</MotionDiv>
</div>
{#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 Object.entries($settingsState.shortcuts) as shortcut}
{@render Shortcuts(shortcut)}
{/each}
<!-- Custom Shortcuts Section -->
@@ -222,4 +156,4 @@
Loading shortcuts...
</div>
{/if}
</div>
</div>
+2 -2
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;
}
}
+1 -1
View File
@@ -4,4 +4,4 @@ export type Theme = {
coverImage: string;
marqueeImage: string;
id: string;
};
};
+25 -25
View File
@@ -1,36 +1,36 @@
import type { Subscriber, Unsubscriber } from "svelte/store";
export class Standalone {
private static instance: Standalone;
private _standalone = $state(false);
private subscribers = new Set<Subscriber<boolean>>();
private static instance: Standalone;
private _standalone = $state(false);
private subscribers = new Set<Subscriber<boolean>>();
private constructor() {}
private constructor() {}
public static getInstance(): Standalone {
if (!Standalone.instance) {
Standalone.instance = new Standalone();
}
return Standalone.instance;
}
public static getInstance(): Standalone {
if (!Standalone.instance) {
Standalone.instance = new Standalone();
}
return Standalone.instance;
}
public setStandalone(value: boolean) {
this._standalone = value;
this.subscribers.forEach((subscriber) => subscriber(value));
}
public setStandalone(value: boolean) {
this._standalone = value;
this.subscribers.forEach(subscriber => subscriber(value));
}
public get standalone() {
return this._standalone;
}
public get standalone() {
return this._standalone;
}
public subscribe(run: Subscriber<boolean>): Unsubscriber {
this.subscribers.add(run);
run(this._standalone);
public subscribe(run: Subscriber<boolean>): Unsubscriber {
this.subscribers.add(run);
run(this._standalone);
return () => {
this.subscribers.delete(run);
};
}
return () => {
this.subscribers.delete(run);
};
}
}
export const standalone = Standalone.getInstance();
export const standalone = Standalone.getInstance();
+12 -82
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,79 +26,35 @@ 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);
});
}
return Promise.resolve(theme);
}
}
+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')
+7 -7
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: pkg.author.email,
},
},
};
}
}
export const firefox = createManifest(updatedFirefoxManifest, "firefox");
export const firefox = createManifest(updatedFirefoxManifest, 'firefox')
+1 -1
View File
@@ -32,7 +32,7 @@
],
"web_accessible_resources": [
{
"resources": ["resources/icons/*", "resources/update-image.webp"],
"resources": ["*/*", "resources/*", "seqta/utils/migration/migrate.html", "plugins/built-in/globalSearch/*"],
"matches": ["*://*/*"]
}
]
+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 opera = createManifest(
{
export const opera = createManifest({
...baseManifest,
version: pkg.version,
description: pkg.description,
},
"opera",
);
}, 'opera')
+7 -7
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 updatedSafariManifest = {
...baseManifest,
@@ -8,12 +8,12 @@ const updatedSafariManifest = {
description: pkg.description,
browser_specific_settings: {
safari: {
strict_min_version: "15.4",
strict_max_version: "*",
strict_min_version: '15.4',
strict_max_version: '*',
},
// ^^^ https://developer.apple.com/documentation/safariservices/safari_web_extensions/optimizing_your_web_extension_for_safari#3743239
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_specific_settings#safari_properties
},
};
}
export const safari = createManifest(updatedSafariManifest, "safari");
export const safari = createManifest(updatedSafariManifest, 'safari')
+40 -80
View File
@@ -3,8 +3,8 @@ class ReactFiber {
this.selector = selector;
this.debug = options.debug || false;
this.nodes = [...document.querySelectorAll(selector)]; // Support multiple elements
this.fibers = this.nodes.map((node) => this.getFiberNode(node));
this.components = this.fibers.map((fiber) => this.getOwnerComponent(fiber));
this.fibers = this.nodes.map(node => this.getFiberNode(node));
this.components = this.fibers.map(fiber => this.getOwnerComponent(fiber));
if (this.debug) {
console.log("Selected Nodes:", this.nodes);
@@ -19,10 +19,8 @@ class ReactFiber {
getFiberNode(node) {
if (!node) return null;
const fiberKey = Object.getOwnPropertyNames(node).find(
(name) =>
name.startsWith("__reactFiber") ||
name.startsWith("__reactInternalInstance"),
const fiberKey = Object.getOwnPropertyNames(node).find(name =>
name.startsWith('__reactFiber') || name.startsWith('__reactInternalInstance')
);
return fiberKey ? node[fiberKey] : null;
}
@@ -30,10 +28,7 @@ class ReactFiber {
getOwnerComponent(fiberNode) {
let current = fiberNode;
while (current) {
if (
current.stateNode &&
(current.stateNode.setState || current.stateNode.forceUpdate)
) {
if (current.stateNode && (current.stateNode.setState || current.stateNode.forceUpdate)) {
return current.stateNode;
}
current = current.return;
@@ -47,7 +42,7 @@ class ReactFiber {
if (key === undefined) {
return state;
} else if (typeof key === "string") {
} else if (typeof key === 'string') {
return state?.[key];
} else if (Array.isArray(key)) {
const filteredState = {};
@@ -62,25 +57,23 @@ class ReactFiber {
}
setState(update) {
this.components.forEach((component) => {
this.components.forEach(component => {
if (component?.setState) {
if (typeof update === "function") {
if (typeof update === 'function') {
// Functional update
component.setState((prevState) => {
component.setState(prevState => {
const newState = update(prevState);
if (this.debug)
console.log("✅ Updated State (Functional):", newState);
if (this.debug) console.log("✅ Updated State (Functional):", newState);
return newState;
});
} else {
// Object update (merge with existing state)
component.setState((prevState) => {
component.setState(prevState => {
const newState = {
...prevState,
...update,
...update
};
if (this.debug)
console.log("✅ Updated State (Object Merge):", newState);
if (this.debug) console.log("✅ Updated State (Object Merge):", newState);
return newState;
});
}
@@ -100,7 +93,7 @@ class ReactFiber {
}
setProp(propName) {
this.fibers.forEach((fiber) => {
this.fibers.forEach(fiber => {
if (fiber?.memoizedProps) {
fiber.memoizedProps[propName] = value;
}
@@ -109,7 +102,7 @@ class ReactFiber {
}
forceUpdate() {
this.components.forEach((component) => {
this.components.forEach(component => {
if (component?.forceUpdate) {
component.forceUpdate();
if (this.debug) console.log("🔄 Forced React Re-render");
@@ -120,12 +113,12 @@ class ReactFiber {
}
function makeSerializable(obj) {
if (typeof obj !== "object" || obj === null) {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map((item) => makeSerializable(item));
return obj.map(item => makeSerializable(item));
}
const serializableObj = {};
@@ -133,17 +126,17 @@ function makeSerializable(obj) {
if (Object.hasOwn(obj, key)) {
let value = obj[key];
if (typeof value === "function") {
value = "[Function]";
if (typeof value === 'function') {
value = '[Function]';
} else if (value instanceof HTMLElement) {
value = {
type: "HTMLElement",
type: 'HTMLElement',
id: value.id,
tagName: value.tagName,
tagName: value.tagName
}; // Replace DOM node with ID/tag info
} else if (typeof value === "symbol") {
} else if (typeof value === 'symbol') {
value = value.toString();
} else if (typeof value === "object" && value !== null) {
} else if (typeof value === 'object' && value !== null) {
value = makeSerializable(value);
}
@@ -153,11 +146,17 @@ function makeSerializable(obj) {
return serializableObj;
}
window.addEventListener("message", (event) => {
window.addEventListener('message', (event) => {
if (event.data.type === "reactFiberRequest") {
const { selector, action, payload, debug, messageId } = event.data;
const fiberInstance = ReactFiber.find(selector, {
const {
selector,
action,
payload,
debug,
messageId
} = event.data;
const fiberInstance = ReactFiber.find(selector, {
debug
});
let response;
@@ -168,7 +167,7 @@ window.addEventListener("message", (event) => {
case "setState":
// Handle both function and object updates
if (payload.updateFn) {
const updateFn = new Function('return ' + payload.updateFn)();
const updateFn = eval(`(${payload.updateFn})`);
fiberInstance.setState(updateFn);
} else {
fiberInstance.setState(payload.updateObject);
@@ -192,53 +191,14 @@ window.addEventListener("message", (event) => {
response = null;
}
if (response !== null && typeof response === "object") {
if (response !== null && typeof response === 'object') {
response = makeSerializable(response);
}
window.postMessage(
{
type: "reactFiberResponse",
response,
messageId,
},
"*",
);
} else if (event.data.type === "triggerKeyboardEvent") {
// Handle keyboard event triggering from content script
const { key, code, altKey, ctrlKey, metaKey, shiftKey, keyCode } = event.data;
const keyboardEvent = new KeyboardEvent('keydown', {
key,
code,
keyCode: keyCode || 0,
which: keyCode || 0,
altKey: altKey || false,
ctrlKey: ctrlKey || false,
metaKey: metaKey || false,
shiftKey: shiftKey || false,
bubbles: true,
cancelable: true
});
document.dispatchEvent(keyboardEvent);
} else if (event.data.type === "ckeditorSetData") {
// Handle CKEditor data setting
const { editorId, content } = event.data;
if (window.CKEDITOR && window.CKEDITOR.instances && window.CKEDITOR.instances[editorId]) {
window.CKEDITOR.instances[editorId].setData(content);
} else {
console.warn(`[pageState] CKEditor instance '${editorId}' not found`);
}
} else if (event.data.type === "ckeditorGetData") {
const { editorId } = event.data;
if (window.CKEDITOR && window.CKEDITOR.instances && window.CKEDITOR.instances[editorId]) {
const data = window.CKEDITOR.instances[editorId].getData();
window.postMessage({
type: "ckeditorGetDataResponse",
data,
}, "*");
}
window.postMessage({
type: "reactFiberResponse",
response,
messageId,
}, "*");
}
});
});
@@ -1,11 +1,7 @@
import { BasePlugin } from "../../core/settings";
import { type Plugin } from "@/plugins/core/types";
import {
defineSettings,
numberSetting,
Setting,
} from "@/plugins/core/settingsHelpers";
import styles from "./styles.css?inline";
import { BasePlugin } from '../../core/settings';
import { type Plugin } from '@/plugins/core/types';
import { defineSettings, numberSetting, Setting } from '@/plugins/core/settingsHelpers';
import styles from './styles.css?inline';
const settings = defineSettings({
speed: numberSetting({
@@ -14,8 +10,8 @@ const settings = defineSettings({
description: "Controls how fast the background moves",
min: 0.1,
max: 2,
step: 0.05,
}),
step: 0.05
})
});
class AnimatedBackgroundPluginClass extends BasePlugin<typeof settings> {
@@ -26,10 +22,10 @@ class AnimatedBackgroundPluginClass extends BasePlugin<typeof settings> {
const instance = new AnimatedBackgroundPluginClass();
const animatedBackgroundPlugin: Plugin<typeof settings> = {
id: "animated-background",
name: "Animated Background",
description: "Adds an animated background to BetterSEQTA+",
version: "1.0.0",
id: 'animated-background',
name: 'Animated Background',
description: 'Adds an animated background to BetterSEQTA+',
version: '1.0.0',
disableToggle: true,
styles: styles,
settings: instance.settings,
@@ -38,7 +34,7 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
// Create the background elements
const container = document.getElementById("container");
const menu = document.getElementById("menu");
if (!container || !menu) {
return () => {};
}
@@ -46,12 +42,12 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
const backgrounds = [
{ classes: ["bg"] },
{ classes: ["bg", "bg2"] },
{ classes: ["bg", "bg3"] },
{ classes: ["bg", "bg3"] }
];
backgrounds.forEach(({ classes }) => {
const bk = document.createElement("div");
classes.forEach((cls) => bk.classList.add(cls));
classes.forEach(cls => bk.classList.add(cls));
container.insertBefore(bk, menu);
});
@@ -59,27 +55,24 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
updateAnimationSpeed(api.settings.speed);
// Listen for speed changes
const speedUnregister = api.settings.onChange(
"speed",
updateAnimationSpeed,
);
const speedUnregister = api.settings.onChange('speed', updateAnimationSpeed);
// Return cleanup function
return () => {
speedUnregister.unregister();
// Remove background elements
const backgrounds = document.getElementsByClassName("bg");
Array.from(backgrounds).forEach((element) => element.remove());
const backgrounds = document.getElementsByClassName('bg');
Array.from(backgrounds).forEach(element => element.remove());
};
},
}
};
function updateAnimationSpeed(speed: number) {
const bgElements = document.getElementsByClassName("bg");
const bgElements = document.getElementsByClassName('bg');
Array.from(bgElements).forEach((element, index) => {
const baseSpeed = index === 0 ? 3 : index === 1 ? 4 : 5;
(element as HTMLElement).style.animationDuration = `${baseSpeed / speed}s`;
});
}
export default animatedBackgroundPlugin;
export default animatedBackgroundPlugin;
@@ -28,4 +28,4 @@
100% {
transform: translateX(5%) rotate(-60deg);
}
}
}
@@ -7,18 +7,18 @@ export function CreateBackground() {
// Creating and inserting 3 divs containing the background applied to the pages
const container = document.getElementById("container");
const menu = document.getElementById("menu");
if (!container || !menu) return;
const backgrounds = [
{ classes: ["bg"] },
{ classes: ["bg", "bg2"] },
{ classes: ["bg", "bg3"] },
{ classes: ["bg", "bg3"] }
];
backgrounds.forEach(({ classes }) => {
const bk = document.createElement("div");
classes.forEach((cls) => bk.classList.add(cls));
classes.forEach(cls => bk.classList.add(cls));
container.insertBefore(bk, menu);
});
}
}
@@ -1,6 +1,6 @@
export function RemoveBackground() {
const backgrounds = document.getElementsByClassName("bg");
// Convert HTMLCollection to Array and remove each element
Array.from(backgrounds).forEach((element) => element.remove());
}
Array.from(backgrounds).forEach(element => element.remove());
}
@@ -1,9 +1,5 @@
import { BasePlugin } from "@/plugins/core/settings";
import {
booleanSetting,
defineSettings,
Setting,
} from "@/plugins/core/settingsHelpers";
import { booleanSetting, defineSettings, Setting } from "@/plugins/core/settingsHelpers";
import { type Plugin } from "@/plugins/core/types";
import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm";
@@ -12,7 +8,7 @@ const settings = defineSettings({
lettergrade: booleanSetting({
default: false,
title: "Letter Grades",
description: "Display the average as a letter instead of a percentage",
description: "Display the average as a letter instead of a percentage"
}),
});
@@ -38,105 +34,62 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
"#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']",
true,
10,
1000,
1000
);
// Helper function to find actual class names by their base pattern
const getClassByPattern = (
element: Element | Document,
basePattern: string,
): string => {
const getClassByPattern = (element: Element | Document, basePattern: string): string => {
// Find all classes on the element
const classes = Array.from(element.querySelectorAll("*"))
.flatMap((el) => Array.from(el.classList))
.filter((className) => className.startsWith(basePattern));
return classes.length ? classes[0] : "";
const classes = Array.from(element.querySelectorAll('*'))
.flatMap(el => Array.from(el.classList))
.filter(className => className.startsWith(basePattern));
return classes.length ? classes[0] : '';
};
// Find actual class names from the DOM
const sampleAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___']",
);
const sampleAssessmentItem = document.querySelector("[class*='AssessmentItem__AssessmentItem___']");
if (!sampleAssessmentItem) return;
// Extract all necessary class patterns from a sample assessment item
const assessmentItemClass =
Array.from(sampleAssessmentItem.classList).find((c) =>
c.startsWith("AssessmentItem__AssessmentItem___"),
) || "";
const metaContainerClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__metaContainer___",
);
const metaClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__meta___",
);
const simpleResultClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__simpleResult___",
);
const titleClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__title___",
);
const assessmentItemClass = Array.from(sampleAssessmentItem.classList)
.find(c => c.startsWith('AssessmentItem__AssessmentItem___')) || '';
const metaContainerClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__metaContainer___');
const metaClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__meta___');
const simpleResultClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__simpleResult___');
const titleClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__title___');
// Get Thermoscore classes
const thermoscoreElement = document.querySelector(
"[class*='Thermoscore__Thermoscore___']",
);
const thermoscoreElement = document.querySelector("[class*='Thermoscore__Thermoscore___']");
if (!thermoscoreElement) return;
const thermoscoreClass =
Array.from(thermoscoreElement.classList).find((c) =>
c.startsWith("Thermoscore__Thermoscore___"),
) || "";
const fillClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__fill___",
);
const textClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__text___",
);
const thermoscoreClass = Array.from(thermoscoreElement.classList)
.find(c => c.startsWith('Thermoscore__Thermoscore___')) || '';
const fillClass = getClassByPattern(thermoscoreElement, 'Thermoscore__fill___');
const textClass = getClassByPattern(thermoscoreElement, 'Thermoscore__text___');
// Find assessment list
const assessmentsList = document.querySelector(
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
);
const assessmentsList = document.querySelector("#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']");
if (!assessmentsList) return;
const gradeElements = document.querySelectorAll(
"[class*='Thermoscore__text___']",
);
const gradeElements = document.querySelectorAll("[class*='Thermoscore__text___']");
if (!gradeElements.length) return;
// Parse and average grades
const letterToNumber: Record<string, number> = {
"A+": 100,
A: 95,
"A-": 90,
"B+": 85,
B: 80,
"B-": 75,
"C+": 70,
C: 65,
"C-": 60,
"D+": 55,
D: 50,
"D-": 45,
"E+": 40,
E: 35,
"E-": 30,
"A+": 100, A: 95, "A-": 90,
"B+": 85, B: 80, "B-": 75,
"C+": 70, C: 65, "C-": 60,
"D+": 55, D: 50, "D-": 45,
"E+": 40, E: 35, "E-": 30,
F: 0,
};
function parseGrade(text: string): number {
const str = text.trim().toUpperCase();
if (str.includes("/")) {
const [raw, max] = str.split("/").map((n) => parseFloat(n));
const [raw, max] = str.split("/").map(n => parseFloat(n));
return (raw / max) * 100;
}
if (str.includes("%")) {
@@ -159,23 +112,16 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
const avg = total / count;
const rounded = Math.ceil(avg / 5) * 5;
const numberToLetter = Object.entries(letterToNumber).reduce(
(acc, [k, v]) => {
acc[v] = k;
return acc;
},
{} as Record<number, string>,
);
const numberToLetter = Object.entries(letterToNumber).reduce((acc, [k, v]) => {
acc[v] = k;
return acc;
}, {} as Record<number, string>);
const letterAvg = numberToLetter[rounded] ?? "N/A";
const display = api.settings.lettergrade
? letterAvg
: `${avg.toFixed(2)}%`;
const display = api.settings.lettergrade ? letterAvg : `${avg.toFixed(2)}%`;
// Prevent duplicate
const existing = assessmentsList.querySelector(
`[class*='AssessmentItem__title___']`,
);
const existing = assessmentsList.querySelector(`[class*='AssessmentItem__title___']`);
if (existing?.textContent === "Subject Average") return;
// Use the dynamic class names in the HTML template
@@ -198,7 +144,7 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
assessmentsList.insertBefore(averageElement!, assessmentsList.firstChild);
});
},
}
};
export default assessmentsAveragePlugin;
export default assessmentsAveragePlugin;
@@ -1,384 +0,0 @@
<script lang="ts">
import { determineStatus, formatDate, getGradeValue } from "./utils";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import confetti from "canvas-confetti";
export let data: any;
interface FilterOptions {
subject: string;
sortBy: "due" | "grade" | "subject" | "title";
}
function percentageToLetter(percentage: number): string {
const letterMap: Record<number, string> = {
100: "A+",
95: "A",
90: "A-",
85: "B+",
80: "B",
75: "B-",
70: "C+",
65: "C",
60: "C-",
55: "D+",
50: "D",
45: "D-",
40: "E+",
35: "E",
30: "E-",
0: "F",
};
const rounded = Math.ceil(percentage / 5) * 5;
return letterMap[rounded] || "F";
}
let currentFilters: FilterOptions = {
subject: "all",
sortBy: "due",
};
let filteredAssessments: any[] = [];
let statusGroups: Record<string, any[]> = {};
function updateAssessments() {
filteredAssessments = data.assessments.filter((a: any) => {
const subjectMatch =
currentFilters.subject === "all" || a.code === currentFilters.subject;
return subjectMatch;
});
filteredAssessments.sort((a: any, b: any) => {
switch (currentFilters.sortBy) {
case "due":
return new Date(a.due).getTime() - new Date(b.due).getTime();
case "grade":
const gradeA = getGradeValue(a);
const gradeB = getGradeValue(b);
if (gradeA === null && gradeB === null) return 0;
if (gradeA === null) return 1;
if (gradeB === null) return -1;
return gradeB - gradeA;
case "subject":
return a.code.localeCompare(b.code);
case "title":
return a.title.localeCompare(b.title);
default:
return 0;
}
});
statusGroups = {
UPCOMING: [],
DUE_SOON: [],
OVERDUE: [],
SUBMITTED: [],
MARKS_RELEASED: [],
};
filteredAssessments.forEach((assessment) => {
const status = determineStatus(assessment);
if (statusGroups[status]) {
statusGroups[status].push(assessment);
}
});
}
function getDueDateClass(assessment: any): string {
const status = determineStatus(assessment);
switch (status) {
case "OVERDUE":
return "overdue";
case "DUE_SOON":
return "due-soon";
case "UPCOMING":
return "upcoming";
default:
return "";
}
}
function markAssessmentCompleted(assessment: any) {
const completedKey = "betterseqta-completed-assessments";
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
if (!completed.includes(assessment.id)) {
completed.push(assessment.id);
localStorage.setItem(completedKey, JSON.stringify(completed));
updateAssessments();
checkForCelebration();
}
}
function unmarkAssessmentCompleted(assessment: any) {
const completedKey = "betterseqta-completed-assessments";
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
const index = completed.indexOf(assessment.id);
if (index > -1) {
completed.splice(index, 1);
localStorage.setItem(completedKey, JSON.stringify(completed));
updateAssessments();
}
}
function checkForCelebration() {
const overdueCount = statusGroups.OVERDUE?.length || 0;
const dueSoonCount = statusGroups.DUE_SOON?.length || 0;
if (overdueCount === 0 && dueSoonCount === 0) {
setTimeout(() => {
try {
const duration = 100;
const end = Date.now() + duration;
(function frame() {
confetti({
particleCount: 17,
angle: 60,
spread: 65,
drift: 0.8,
startVelocity: 40,
scalar: 2,
gravity: 2,
decay: 0.97,
ticks: 300,
origin: { x: 0, y: 1 },
disableForReducedMotion: true,
});
confetti({
particleCount: 17,
angle: 120,
spread: 65,
drift: -0.8,
startVelocity: 40,
scalar: 2,
decay: 0.97,
ticks: 300,
gravity: 2,
origin: { x: 1, y: 1 },
disableForReducedMotion: true,
});
if (Date.now() < end) {
requestAnimationFrame(frame);
}
}());
} catch (e) {
console.log("Confetti celebration failed:", e);
}
}, 500);
} else if (overdueCount === 0 || dueSoonCount === 0) {
setTimeout(() => {
try {
confetti({
particleCount: 100,
spread: 70,
origin: { y: 0.6 },
scalar: 0.9,
disableForReducedMotion: true,
});
} catch (e) {
console.log("Confetti celebration failed:", e);
}
}, 500);
}
}
function isManuallyCompleted(assessmentId: string): boolean {
const completedKey = "betterseqta-completed-assessments";
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
return completed.includes(assessmentId);
}
function handleCardClick(assessment: any, event: Event) {
if ((event.target as HTMLElement).closest(".card-menu")) {
return;
}
window.location.hash = `#?page=/assessments/${assessment.programmeID}:${assessment.metaclassID}&item=${assessment.id}`;
}
let openMenuId: string | null = null;
function toggleMenu(assessmentId: string, event: Event) {
event.stopPropagation();
openMenuId = openMenuId === assessmentId ? null : assessmentId;
}
function closeAllMenus() {
openMenuId = null;
}
$: {
if (data) {
updateAssessments();
}
}
const columns = [
{
key: "UPCOMING",
title: "Upcoming",
className: "column-upcoming",
icon: "📅",
},
{
key: "DUE_SOON",
title: "Due Soon",
className: "column-due-soon",
icon: "⏰",
},
{
key: "OVERDUE",
title: "Overdue",
className: "column-overdue",
icon: "🚨",
},
{
key: "SUBMITTED",
title: "Submitted",
className: "column-submitted",
icon: "📝",
},
{
key: "MARKS_RELEASED",
title: "Marked",
className: "column-marked",
icon: "✅",
},
];
</script>
<svelte:window on:click={closeAllMenus} />
<div id="grid-view-container">
<div class="grid-view-header">
<h1 class="grid-view-title">Assessments</h1>
<div class="grid-view-filters">
<select class="filter-select" bind:value={currentFilters.subject}>
<option value="all">All Subjects</option>
{#each data.subjects as subject}
<option value={subject.code}>{subject.code} - {subject.title}</option>
{/each}
</select>
<select class="filter-select" bind:value={currentFilters.sortBy}>
<option value="due">Sort by Due Date</option>
<option value="grade">Sort by Grade</option>
<option value="subject">Sort by Subject</option>
<option value="title">Sort by Title</option>
</select>
</div>
</div>
<div id="main-grid-content">
{#if filteredAssessments.length === 0}
<div class="empty-state">
<div class="empty-icon">📋</div>
<p>No assessments found matching your filters</p>
</div>
{:else}
<div class="kanban-board">
{#each columns as column}
{#if statusGroups[column.key]?.length > 0}
<div class="kanban-column-parent">
<div class="kanban-column {column.className}">
<div class="column-header">
<div class="column-title">
{column.icon} {column.title}
<span class="column-count">{statusGroups[column.key].length}</span>
</div>
</div>
<div class="column-cards" id="{column.key.toLowerCase()}-cards">
{#each statusGroups[column.key] as assessment}
{@const status = determineStatus(assessment)}
{@const dueDateClass = getDueDateClass(assessment)}
{@const isCompleted = isManuallyCompleted(assessment.id)}
{@const color = data.colors[assessment.code] || "#6366f1"}
<div
class="assessment-card"
data-subject={assessment.code}
data-status={status}
style="--subject-color: {color}"
on:click={(e) => handleCardClick(assessment, e)}
role="button"
tabindex="0"
on:keydown={(e) => e.key === 'Enter' && handleCardClick(assessment, e)}
>
<div class="card-labels">
<span class="card-label label-subject">{assessment.code}</span>
{#if assessment.submitted}
<span class="card-label label-submitted" style="background: #10b981; color: white;">Submitted</span>
{/if}
{#if isCompleted && status === "MARKS_RELEASED" && !assessment.results}
<span class="card-label label-completed" style="background: #059669; color: white;">Completed</span>
{/if}
</div>
{#if status !== "MARKS_RELEASED" || isCompleted}
<div class="card-menu">
<button
class="menu-button"
data-assessment-id={assessment.id}
on:click={(e) => toggleMenu(assessment.id, e)}
aria-label="Open menu"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2"/>
<circle cx="12" cy="12" r="2"/>
<circle cx="12" cy="19" r="2"/>
</svg>
</button>
<div class="menu-dropdown" style="display: {openMenuId === assessment.id ? 'block' : 'none'};">
{#if status !== "MARKS_RELEASED"}
<button class="menu-item mark-completed" on:click={() => markAssessmentCompleted(assessment)}>
Mark as Completed
</button>
{:else if isCompleted}
<button class="menu-item mark-not-completed" on:click={() => unmarkAssessmentCompleted(assessment)}>
Mark as Not Complete
</button>
{/if}
</div>
</div>
{/if}
<h3 class="assessment-title">{assessment.title}</h3>
{#if !assessment.results && !isCompleted}
<div class="assessment-meta">
<div class="due-date {dueDateClass}">
📅 {formatDate(assessment.due, assessment.submitted)}
</div>
</div>
{/if}
{#if assessment.results}
<div class="card-footer">
<div class="Thermoscore__Thermoscore___WFpL3" style="--fill-colour: {color}">
<div style="width: {assessment.results.percentage}%" class="Thermoscore__fill___ojxDI">
<div title="{assessment.results.percentage}%" class="Thermoscore__text___XSR_M">
{(() => {
const allSettings = settingsState.getAll() as unknown as any;
const letterGradeSetting = allSettings["plugin.assessments-average.settings"]?.lettergrade;
return letterGradeSetting
? percentageToLetter(assessment.results.percentage)
: `${assessment.results.percentage}%`;
})()}
</div>
</div>
</div>
</div>
{/if}
</div>
{/each}
</div>
</div>
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>
@@ -1,8 +0,0 @@
<script lang="ts">
export let error: string;
</script>
<div class="error-container">
<p class="error-text">Failed to load assessments</p>
<p style="color: #94a3b8; font-size: 0.875rem;">{error}</p>
</div>
@@ -1,78 +0,0 @@
<div id="grid-view-container">
<div class="grid-view-header">
<h1 class="grid-view-title">Assessments</h1>
<div class="grid-view-filters">
<select class="filter-select" disabled>
<option value="all">Loading subjects...</option>
</select>
<select class="filter-select" disabled>
<option value="due">Sort by Due Date</option>
</select>
</div>
</div>
<div id="main-grid-content">
<div class="kanban-board">
{#each columns as column}
<div class="kanban-column-parent">
<div class="kanban-column {column.className}">
<div class="column-header">
<div class="column-title">
{column.icon} {column.title}
<span class="column-count">...</span>
</div>
</div>
<div class="column-cards" id="{column.key.toLowerCase()}-cards">
{#each Array(column.skeletonCount) as _}
<div class="assessment-card">
<div class="skeleton-element skeleton-label"></div>
<div class="skeleton-element skeleton-title"></div>
<div class="skeleton-element skeleton-title-line2"></div>
<div class="skeleton-element skeleton-meta"></div>
{#if column.key === "MARKS_RELEASED"}
<div class="skeleton-footer">
<div class="skeleton-element" style="height: 16px; width: 100%;"></div>
</div>
{/if}
</div>
{/each}
</div>
</div>
</div>
{/each}
</div>
</div>
</div>
<script lang="ts">
const columns = [
{
key: "UPCOMING",
title: "Upcoming",
className: "column-upcoming",
icon: "📅",
skeletonCount: 3,
},
{
key: "DUE_SOON",
title: "Due Soon",
className: "column-due-soon",
icon: "⏰",
skeletonCount: 2,
},
{
key: "OVERDUE",
title: "Overdue",
className: "column-overdue",
icon: "🚨",
skeletonCount: 1,
},
{
key: "MARKS_RELEASED",
title: "Marked",
className: "column-marked",
icon: "✅",
skeletonCount: 4,
},
];
</script>
@@ -1,131 +0,0 @@
interface Subject {
code: string;
programme: number;
metaclass: number;
title: string;
}
interface PrefItem {
name: string;
value: string;
}
let cache: { time: number; data: any } | null = null;
const CACHE_MS = 10 * 60 * 1000;
const student = 69;
async function fetchJSON(url: string, body: any) {
const res = await fetch(`${location.origin}${url}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body),
});
return res.json();
}
async function loadSubjects() {
const res = await fetchJSON("/seqta/student/load/subjects?", {});
return res.payload
.filter((s: any) => s.active === 1)
.flatMap((s: any) => s.subjects);
}
async function loadPrefs(student: number) {
const res = await fetchJSON("/seqta/student/load/prefs?", {
request: "userPrefs",
asArray: true,
user: student,
});
const colors: Record<string, string> = {};
res.payload.forEach((p: PrefItem) => {
if (p.name.startsWith("timetable.subject.colour.")) {
const code = p.name.replace("timetable.subject.colour.", "");
colors[code] = p.value;
}
});
return colors;
}
async function loadUpcoming(student: number) {
const res = await fetchJSON("/seqta/student/assessment/list/upcoming?", {
student,
});
return res.payload;
}
async function loadPast(student: number, subjects: Subject[]) {
const map: Record<number, any> = {};
await Promise.all(
subjects.map(async (s) => {
const res = await fetchJSON("/seqta/student/assessment/list/past?", {
programme: s.programme,
metaclass: s.metaclass,
student,
});
if (res.payload.tasks) {
res.payload.tasks.forEach((t: any) => {
map[t.id] = t;
});
}
}),
);
return map;
}
async function loadSubmissions(student: number, assessments: any[]) {
const submissionMap: Record<number, boolean> = {};
await Promise.all(
assessments.map(async (assessment) => {
try {
const res = await fetchJSON(
"/seqta/student/assessment/submissions/get",
{
assessment: assessment.id,
metaclass: assessment.metaclassID,
student,
},
);
submissionMap[assessment.id] = res.payload && res.payload.length > 0;
} catch (error) {
console.warn(
`Failed to fetch submission for assessment ${assessment.id}:`,
error,
);
submissionMap[assessment.id] = false;
}
}),
);
return submissionMap;
}
export async function getAssessmentsData() {
if (cache && Date.now() - cache.time < CACHE_MS) return cache.data;
const [subjects, colors, upcoming] = await Promise.all([
loadSubjects(),
loadPrefs(student),
loadUpcoming(student),
]);
const pastMap = await loadPast(student, subjects);
const map: Record<number, any> = {};
upcoming.forEach((a: any) => {
map[a.id] = { ...a };
});
Object.values(pastMap).forEach((t: any) => {
if (map[t.id]) Object.assign(map[t.id], t);
else map[t.id] = t;
});
const allAssessments = Object.values(map);
const submissions = await loadSubmissions(student, allAssessments);
allAssessments.forEach((assessment: any) => {
assessment.submitted = submissions[assessment.id] || false;
});
const data = { assessments: allAssessments, subjects, colors };
cache = { time: Date.now(), data };
return data;
}
@@ -1,86 +0,0 @@
import type { Plugin } from "../../core/types";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { getAssessmentsData } from "./api";
import { renderSkeletonLoader, renderErrorState } from "./ui";
import styles from "./styles.css?inline";
import { delay } from "@/seqta/utils/delay";
const assessmentsOverviewPlugin: Plugin<{}> = {
id: "assessments-overview",
name: "Assessments Overview",
description:
"Adds an overview option to the assessments page that organizes assessments by status",
version: "1.0.0",
settings: {},
disableToggle: false,
styles,
run: async () => {
const menu = (await waitForElm(
'[data-key="assessments"] > .sub > ul',
true,
100,
60,
)) as HTMLElement;
const gridItem = document.createElement("li");
gridItem.className = "item";
const label = document.createElement("label");
label.textContent = "Overview";
gridItem.appendChild(label);
menu.insertBefore(gridItem, menu.children[1] || null);
if (window.location.hash.includes("/assessments/overview")) {
loadGridView();
}
const clickHandler = (e: Event) => {
e.preventDefault();
loadGridView();
};
gridItem.addEventListener("click", clickHandler);
async function loadGridView() {
await delay(1);
window.history.pushState({}, "", "/#?page=/assessments/overview");
document.title = "Overview ― SEQTA Learn";
const main = document.getElementById("main");
if (!main) return;
document
.querySelectorAll('[data-key="assessments"] .item')
.forEach((item) => {
item.classList.remove("active");
});
gridItem.classList.add("active");
document
.querySelector('[data-key="assessments"]')
?.classList.add("active");
main.innerHTML = '<div id="grid-view-container"></div>';
const container = document.getElementById(
"grid-view-container",
) as HTMLElement;
renderSkeletonLoader(container);
try {
const data = await getAssessmentsData();
const { renderGrid } = await import("./ui");
renderGrid(container, data);
} catch (err) {
console.error("Failed to load assessments:", err);
renderErrorState(
container,
err instanceof Error ? err.message : "Unknown error",
);
}
}
return () => {
gridItem.removeEventListener("click", clickHandler);
gridItem.remove();
};
},
};
export default assessmentsOverviewPlugin;
@@ -1,798 +0,0 @@
#grid-view-container {
background: transparent;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.grid-view-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
flex-shrink: 0;
}
.grid-view-title {
font-size: 1.875rem !important;
font-weight: 700;
color: #1a1a1a;
margin: 0;
}
/* Dark mode support */
.dark .grid-view-title {
color: #f8fafc;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.grid-view-filters {
display: flex;
gap: 0.75rem;
align-items: center;
}
.filter-select {
background: #ffffff !important;
border: 2px solid #e2e8f0;
border-radius: 8px;
color: #1a1a1a;
padding: 0.75rem 1rem;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s ease;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
min-width: 180px;
}
.filter-select:focus {
outline: none;
border-color: #d41e3a;
box-shadow: 0 0 0 3px rgba(212, 30, 58, 0.1);
}
.filter-select:hover {
border-color: #cbd5e1;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Dark mode dropdowns */
.dark .filter-select {
background: var(--background-primary) !important;
border-color: var(--background-secondary);
color: var(--text-primary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.dark .filter-select:focus {
border-color: #d41e3a;
box-shadow: 0 0 0 3px rgba(212, 30, 58, 0.2);
}
.dark .filter-select:hover {
border-color: var(--background-secondary);
background: var(--background-secondary);
}
.dark .filter-select option {
background: var(--background-primary);
color: var(--text-primary);
}
#main-grid-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* Kanban Board Layout */
.kanban-board {
display: flex;
gap: 1.5rem;
overflow-x: auto;
padding: 1rem;
flex: 0 1 auto;
}
.kanban-column-parent {
flex: 0 0 320px;
}
.kanban-column {
max-height: 100%;
background: #f8fafc;
border-radius: 12px;
box-shadow: 0 0 0 2px #e2e8f0;
display: flex;
flex-direction: column;
min-height: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* Dark mode columns */
.dark .kanban-column {
background: var(--background-primary);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
.column-header {
padding: 1rem 1.25rem;
border-bottom: 2px solid #e2e8f0;
background: #ffffff;
border-radius: 12px 12px 0 0;
position: sticky;
top: 0;
z-index: 10;
}
/* Dark mode column headers */
.dark .column-header {
background: var(--background-secondary);
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.column-title {
font-size: 1rem;
font-weight: 600;
color: #1a1a1a;
margin: 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.dark .column-title {
color: var(--text-primary);
}
.column-count {
background: #e2e8f0;
color: #64748b;
padding: 0.25rem 0.5rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
}
.dark .column-count {
background: var(--background-secondary);
color: var(--text-primary);
}
.column-cards {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 0;
overflow-y: auto;
}
/* Assessment Cards */
.assessment-card {
background: #ffffff;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
cursor: pointer;
position: relative;
border-left: 4px solid var(--subject-color, #d41e3a);
border: 1px solid #e2e8f0;
}
.assessment-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
border-color: #cbd5e1;
}
/* Dark mode cards */
.dark .assessment-card {
background: var(--background-secondary);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
.dark .assessment-card:hover {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.15);
}
.card-labels {
display: flex;
gap: 0.25rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.card-label {
padding: 0.25rem 0.75rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
color: #ffffff;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.label-subject {
background: var(--subject-color, #d41e3a);
}
.card-menu {
position: absolute;
top: 0.75rem;
right: 0.75rem;
z-index: 20;
}
.menu-button {
background: transparent !important;
border: none !important;
padding: 0.25rem !important;
cursor: pointer;
border-radius: 4px;
color: #64748b;
transition: all 0.2s ease;
display: flex !important;
align-items: center;
justify-content: center;
width: 24px !important;
height: 24px !important;
margin: 0 !important;
position: static !important;
transform: none !important;
box-shadow: none !important;
outline: none !important;
}
.menu-button:hover {
background: #f1f5f9 !important;
color: #1a1a1a;
}
.menu-button svg {
width: 16px !important;
height: 16px !important;
fill: currentColor !important;
display: block !important;
}
.dark .menu-button {
color: var(--text-primary);
opacity: 0.7;
}
.dark .menu-button:hover {
background: rgba(255, 255, 255, 0.1) !important;
opacity: 1;
}
.menu-dropdown {
position: absolute;
top: 100%;
right: 0;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 140px;
z-index: 30;
margin-top: 4px;
}
.dark .menu-dropdown {
background: var(--background-secondary);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.menu-item {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
background: transparent;
border: none;
text-align: left;
cursor: pointer;
font-size: 0.875rem;
color: #1a1a1a;
transition: background-color 0.2s ease;
white-space: nowrap;
}
.menu-item:hover {
background: #f8fafc;
}
.dark .menu-item {
color: var(--text-primary);
}
.dark .menu-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.menu-item.mark-completed {
color: #059669;
font-weight: 500;
}
.dark .menu-item.mark-completed {
color: #10b981;
}
.menu-item.mark-not-completed {
color: #dc2626;
font-weight: 500;
}
.dark .menu-item.mark-not-completed {
color: #ef4444;
}
.assessment-title {
font-size: 0.875rem;
font-weight: 600;
color: #1a1a1a;
margin: 0 0 0.75rem 0;
line-height: 1.4;
padding-right: 2rem; /* Make room for menu button */
}
.dark .assessment-title {
color: var(--text-primary);
}
.assessment-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 0.75rem;
font-size: 0.75rem;
color: #64748b;
}
.dark .assessment-meta {
color: var(--text-primary);
opacity: 0.7;
}
.due-date {
display: flex;
align-items: center;
gap: 0.25rem;
font-weight: 500;
}
.due-date.overdue {
color: #dc2626;
}
.due-date.due-soon {
color: #d97706;
}
.due-date.upcoming {
color: #059669;
}
.card-footer {
display: flex;
align-items: center;
justify-content: flex-start;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #e5e7eb;
}
.dark .card-footer {
border-top-color: rgba(255, 255, 255, 0.1);
}
.grade-display {
font-weight: 700;
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
border-radius: 6px;
}
.grade-good {
background: rgba(16, 185, 129, 0.1);
color: #059669;
border: 1px solid rgba(16, 185, 129, 0.2);
}
.grade-average {
background: rgba(245, 158, 11, 0.1);
color: #d97706;
border: 1px solid rgba(245, 158, 11, 0.2);
}
.grade-bad {
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
border: 1px solid rgba(239, 68, 68, 0.2);
}
.grade-empty {
color: #64748b;
font-style: italic;
font-weight: 500;
background: #f1f5f9;
border: 1px solid #e2e8f0;
}
.dark .grade-empty {
color: var(--text-primary);
opacity: 0.7;
background: var(--background-secondary);
border-color: var(--background-secondary);
}
/* Column-specific styling */
.column-upcoming .column-header {
background: linear-gradient(135deg, #ffffff 0%, #f0f9ff 100%);
}
.column-due-soon .column-header {
background: linear-gradient(135deg, #ffffff 0%, #fffbeb 100%);
}
.column-overdue .column-header {
background: linear-gradient(135deg, #ffffff 0%, #fef2f2 100%);
}
.column-submitted .column-header {
background: linear-gradient(135deg, #ffffff 0%, #fef3c7 100%);
}
.column-marked .column-header {
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%);
}
/* Dark mode column headers */
.dark .column-upcoming .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #1e3a8a 100%);
}
.dark .column-due-soon .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #92400e 100%);
}
.dark .column-overdue .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #991b1b 100%);
}
.dark .column-submitted .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #92400e 100%);
}
.dark .column-marked .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #065f46 100%);
}
/* Subject filter view */
.subject-section {
margin-bottom: 2rem;
}
.subject-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding: 1rem;
background: #ffffff;
border-radius: 8px;
border: 2px solid #e2e8f0;
border-left: 4px solid var(--subject-color, #d41e3a);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.dark .subject-header {
background: var(--background-secondary);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
.subject-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.subject-title {
font-size: 1.125rem;
font-weight: 600;
color: #1a1a1a;
margin: 0;
}
.dark .subject-title {
color: var(--text-primary);
}
.subject-code {
font-size: 0.875rem;
color: #64748b;
}
.dark .subject-code {
color: var(--text-primary);
opacity: 0.7;
}
/* Loading and error states */
.loading-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 4rem 0;
background: #ffffff;
border-radius: 12px;
border: 2px solid #e2e8f0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.dark .loading-container {
background: var(--background-primary);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.loading-spinner {
width: 2.5rem;
height: 2.5rem;
border: 3px solid #e2e8f0;
border-top: 3px solid #d41e3a;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.dark .loading-spinner {
border-color: rgba(255, 255, 255, 0.1);
border-top-color: #d41e3a;
}
.loading-text {
margin-top: 1rem;
color: #64748b;
font-size: 0.875rem;
font-weight: 500;
}
.dark .loading-text {
color: var(--text-primary);
opacity: 0.7;
}
.error-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 4rem 0;
background: #ffffff;
border-radius: 12px;
border: 2px solid #fecaca;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.dark .error-container {
background: var(--background-primary);
border-color: #991b1b;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.error-text {
color: #ef4444;
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.empty-state {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 2rem;
color: #64748b;
font-size: 0.875rem;
text-align: center;
}
.dark .empty-state {
color: var(--text-primary);
opacity: 0.7;
}
.empty-column {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
color: #64748b;
font-size: 0.875rem;
text-align: center;
min-height: 200px;
}
.dark .empty-column {
color: var(--text-primary);
opacity: 0.7;
}
.empty-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
opacity: 0.5;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
.skeleton-element {
background: linear-gradient(
90deg,
#f1f5f9 0%,
#e2e8f0 50%,
#f1f5f9 100%
);
background-size: 1000px 100%;
animation: shimmer 2s infinite linear;
border-radius: 4px;
}
.dark .skeleton-element {
background: linear-gradient(
90deg,
var(--background-primary) 0%,
var(--background-secondary) 50%,
var(--background-primary) 100%
);
background-size: 1000px 100%;
}
.skeleton-label {
height: 20px;
width: 60px;
margin-bottom: 0.75rem;
border-radius: 6px;
}
.skeleton-title {
height: 16px;
width: 80%;
margin-bottom: 0.5rem;
}
.skeleton-title-line2 {
height: 16px;
width: 60%;
margin-bottom: 0.75rem;
}
.skeleton-meta {
height: 12px;
width: 40%;
margin-top: 0.75rem;
}
.skeleton-footer {
height: 8px;
width: 100%;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #e5e7eb;
}
.dark .skeleton-footer {
border-top-color: rgba(255, 255, 255, 0.1);
}
/* Responsive design */
@media (max-width: 768px) {
#grid-view-container {
padding: 1rem;
}
.grid-view-header {
flex-direction: column;
gap: 1rem;
align-items: stretch;
}
.grid-view-filters {
justify-content: center;
flex-wrap: wrap;
}
.kanban-board {
flex-direction: column;
gap: 1rem;
}
.kanban-column {
flex: none;
max-height: none;
}
.filter-select {
min-width: 140px;
}
}
@media (max-width: 480px) {
.grid-view-title {
font-size: 1.5rem !important;
}
.filter-select {
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
min-width: 120px;
}
.assessment-card {
padding: 0.75rem;
}
.card-footer {
flex-direction: column;
gap: 0.5rem;
align-items: stretch;
}
}
/* Scrollbar styling for webkit browsers */
.column-cards::-webkit-scrollbar {
width: 6px;
}
.column-cards::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
.column-cards::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.column-cards::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Dark mode scrollbars */
.dark .column-cards::-webkit-scrollbar-track {
background: var(--background-secondary);
}
.dark .column-cards::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
.dark .column-cards::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
@@ -1,45 +0,0 @@
import renderSvelte from "@/interface/main";
import AssessmentsOverview from "./AssessmentsOverview.svelte";
import SkeletonLoader from "./SkeletonLoader.svelte";
import ErrorState from "./ErrorState.svelte";
import { unmount } from "svelte";
let currentApp: any = null;
export function renderGrid(container: HTMLElement, data: any) {
if (currentApp) {
unmount(currentApp);
}
container.innerHTML = "";
container.className = "";
currentApp = renderSvelte(AssessmentsOverview, container, { data });
}
export function renderSkeletonLoader(container: HTMLElement) {
if (currentApp) {
unmount(currentApp);
}
container.innerHTML = "";
container.className = "";
currentApp = renderSvelte(SkeletonLoader, container);
}
export function renderLoadingState(container: HTMLElement) {
renderSkeletonLoader(container);
}
export function renderErrorState(container: HTMLElement, error: string) {
if (currentApp) {
unmount(currentApp);
}
container.innerHTML = "";
container.className = "";
currentApp = renderSvelte(ErrorState, container, { error });
}
@@ -1,101 +0,0 @@
export function formatDate(dateStr: string, submitted?: boolean): string {
const d = new Date(dateStr);
const now = new Date();
const diffTime = d.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0 && !submitted) {
const overdueDays = Math.abs(diffDays);
if (overdueDays === 1) return "1 day overdue";
return `${overdueDays} days overdue`;
}
if (diffDays === 0) return "Today";
if (diffDays === 1) return "Tomorrow";
if (diffDays <= 7) {
const weekdayName = d.toLocaleDateString(undefined, { weekday: "long" });
return diffDays < 0 ? `Last ${weekdayName}` : weekdayName;
}
return d.toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
export function determineStatus(item: any): string {
if (
item.status === "MARKS_RELEASED" ||
item.grade ||
(item.percentage !== undefined && item.percentage !== null) ||
(item.achieved !== undefined && item.achieved !== null)
) {
return "MARKS_RELEASED";
}
const completedKey = "betterseqta-completed-assessments";
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
if (completed.includes(item.id)) {
return "MARKS_RELEASED";
}
if (item.submitted) {
return "SUBMITTED";
}
const now = new Date();
const due = new Date(item.due);
const diffTime = due.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return "OVERDUE";
}
if (diffDays <= 7) {
return "DUE_SOON";
}
return "UPCOMING";
}
export function getGradeValue(assessment: any): number | null {
if (
assessment.results?.percentage !== undefined &&
assessment.results.percentage !== null
) {
return assessment.results.percentage;
}
if (assessment.percentage !== undefined && assessment.percentage !== null) {
return assessment.percentage;
}
if (
assessment.achieved !== undefined &&
assessment.outOf !== undefined &&
assessment.achieved !== null &&
assessment.outOf !== null &&
assessment.outOf > 0
) {
return (assessment.achieved / assessment.outOf) * 100;
}
if (
assessment.results?.achieved !== undefined &&
assessment.results?.outOf !== undefined &&
assessment.results.achieved !== null &&
assessment.results.outOf !== null &&
assessment.results.outOf > 0
) {
return (assessment.results.achieved / assessment.results.outOf) * 100;
}
return null;
}
@@ -1,65 +0,0 @@
<script lang="ts">
import Editor from './Editor/Editor.svelte';
import EditorStyles from './Editor/EditorStyles.css?raw';
import EditorOverrideStyles from './Editor/EditorOverrideStyles.css?raw';
import TiptapStyles from './Editor/TiptapStyles.css?raw';
import { onMount } from 'svelte';
import { settingsState } from '@/seqta/utils/listeners/SettingsState';
interface Props {
onchange: (value: string) => void;
initialContent?: string;
scale?: number; // Scale factor for the editor (1.0 = normal, 1.2 = 120%, etc.)
}
let { onchange, initialContent = '', scale = 1.3 }: Props = $props();
let content = $state('');
let betterEditor = $state<HTMLElement | null>(null);
// Watch for content changes and call the callback
$effect(() => {
if (onchange) {
onchange(content);
}
});
onMount(async () => {
if (betterEditor) {
const styles = EditorStyles + EditorOverrideStyles + TiptapStyles;
const scalingCSS = `
.better-editor {
--scale-factor: ${scale};
}
.better-editor .editor-prose {
transform-origin: top left;
zoom: ${scale};
-moz-transform: scale(${scale});
-moz-transform-origin: 0 0;
}
/* For Firefox which doesn't support zoom */
@-moz-document url-prefix() {
.better-editor .editor-prose {
transform: scale(${scale});
width: ${100 / scale}%;
}
}
`;
const styleElement = document.createElement('style');
styleElement.textContent = styles + scalingCSS;
betterEditor.appendChild(styleElement);
}
});
</script>
<div
class="h-full better-editor {settingsState.DarkMode ? 'dark' : ''}"
bind:this={betterEditor}
style="font-size: {scale * 16}px; --editor-scale: {scale};"
>
<Editor bind:content {initialContent} />
</div>
@@ -1,154 +0,0 @@
<script lang="ts">
import Placeholder from '@tiptap/extension-placeholder';
import Commands from './Plugins/Commands/command';
import { Dropcursor } from '@tiptap/extension-dropcursor';
import Image from '@tiptap/extension-image'
import BubbleMenu from '@tiptap/extension-bubble-menu';
import Typography from '@tiptap/extension-typography';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import { Editor } from '@tiptap/core';
import CommandList from './Plugins/Commands/CommandList.svelte';
import suggestion from './Plugins/Commands/suggestion';
import { slashVisible } from './Plugins/Commands/stores';
import { get } from 'svelte/store';
import BubbleMenuComponent from './Plugins/BubbleMenu.svelte';
import { onMount, onDestroy } from 'svelte';
import EditorStyles from './EditorOverrideStyles.css?raw';
// Make htmlContent bindable from parent components
let { content = $bindable(''), initialContent = '' } = $props<{ content: string; initialContent?: string }>();
let commandListInstance = $state<any>(null);
let element = $state<HTMLElement | null>(null);
let editor = $state<Editor | null>(null);
onMount(() => {
editor = new Editor({
element: element!,
content: initialContent || '',
editorProps: {
attributes: {
class: 'focus:outline-none px-3 md:px-0',
},
handleKeyDown: (_, event) => {
// Handle keyboard events when slash menu is visible
if (get(slashVisible) && commandListInstance) {
if (event.key === 'Enter' || event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const handled = commandListInstance.handleKeydown(event, editor);
if (handled) {
return true; // Prevent TipTap from handling this event
}
}
}
return false; // Let TipTap handle other events
},
},
extensions: [
StarterKit,
Placeholder.configure({
placeholder: ({ node }: { node: any }) => {
if (node.type.name === 'heading') {
return 'Heading';
} else if (node.type.name === 'paragraph') {
return "Type '/' for commands";
}
return 'Type something...';
},
}),
TaskList,
TaskItem,
Link,
Typography,
Commands.configure({
suggestion,
}),
BubbleMenu.configure({
element: document.querySelector('.menu') as HTMLElement,
}),
Dropcursor.configure({ width: 5, color: '#ddeeff' }),
Image.configure({
allowBase64: true,
}),
],
onTransaction: () => {
// force re-render so `editor.isActive` works as expected
editor = editor;
},
onUpdate: ({ editor }: { editor: Editor }) => {
// Update the htmlContent with the editor's HTML plus CSS
const editorHTML = editor.getHTML();
content = `<div class="editor-prose">${editorHTML}<${''}style>${EditorStyles}</${''}style></div>`;
},
});
});
onMount(() => {
if (initialContent) {
content = initialContent;
}
});
onDestroy(() => {
if (editor) {
editor.destroy();
}
});
function handleKeydownCapture(event: KeyboardEvent) {
if (commandListInstance && editor && get(slashVisible)) {
if (event.key === 'Escape') {
if (commandListInstance.handleKeydown(event, editor)) {
event.preventDefault();
event.stopPropagation();
}
}
}
}
function handleClick(event: MouseEvent) {
if (!editor) return;
// Check if the click happened in empty space below content
const editorElement = element;
if (!editorElement) return;
const clickY = event.clientY;
// Get the last node in the editor
const lastNode = editorElement.lastElementChild;
if (lastNode) {
const lastNodeRect = lastNode.getBoundingClientRect();
// If click is below the last content node, move cursor to end
if (clickY > lastNodeRect.bottom) {
const docSize = editor.state.doc.content.size;
editor.commands.setTextSelection(docSize);
editor.commands.focus();
event.preventDefault();
}
}
}
</script>
<div class="relative h-full">
<div
class="w-full min-h-full editor-prose"
bind:this={element}
onkeydown={handleKeydownCapture}
onclick={handleClick}
role="textbox"
tabindex="-1">
</div>
<CommandList bind:this={commandListInstance} />
</div>
<BubbleMenuComponent bind:editor />
@@ -1,398 +0,0 @@
.editor-prose {
font-family:
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
'Noto Sans',
sans-serif !important;
line-height: 1.6 !important;
color: #374151 !important;
font-size: 14px !important;
border: none !important;
padding: 0 !important;
margin: 0 !important;
box-sizing: border-box !important;
.dark & * {
color: #d1d5db !important;
}
* {
color: #374151 !important;
}
h1,
h2,
h3,
h4,
h5,
h6,
p,
ul,
ol {
width: 100% !important;
min-width: 2px !important;
box-sizing: border-box !important;
}
h1 {
font-size: 1.5rem !important;
font-weight: 700 !important;
margin: 0.75rem 0 0.5rem 0 !important;
line-height: 1.3 !important;
color: #111827 !important;
padding: 0 !important;
border: none !important;
background: none !important;
text-shadow: none !important;
.dark & {
color: #f9fafb !important;
}
}
h2 {
font-size: 1.25rem !important;
font-weight: 600 !important;
margin: 0.6rem 0 0.4rem 0 !important;
line-height: 1.4 !important;
color: #1f2937 !important;
padding: 0 !important;
border: none !important;
background: none !important;
text-shadow: none !important;
.dark & {
color: #e5e7eb !important;
}
}
h3 {
font-size: 1.125rem !important;
font-weight: 600 !important;
margin: 0.5rem 0 0.3rem 0 !important;
line-height: 1.4 !important;
color: #374151 !important;
padding: 0 !important;
border: none !important;
background: none !important;
text-shadow: none !important;
.dark & {
color: #d1d5db !important;
}
}
p {
margin: 0.4rem 0 !important;
line-height: 1.6 !important;
font-size: 0.875rem !important;
padding: 0 !important;
border: none !important;
background: none !important;
color: inherit !important;
text-shadow: none !important;
}
ul {
margin: 0.5rem 0 !important;
padding-left: 1.25rem !important;
list-style-type: disc !important;
border: none !important;
background: none !important;
ul {
list-style-type: circle !important;
ul {
list-style-type: square !important;
}
}
&[data-type='taskList'] {
list-style: none !important;
padding: 0 !important;
margin: 0.5rem 0 !important;
border: none !important;
background: none !important;
p {
margin: 0 !important;
font-size: 0.875rem !important;
line-height: 1.5 !important;
padding: 0 !important;
border: none !important;
background: none !important;
color: inherit !important;
text-shadow: none !important;
}
li {
display: flex !important;
align-items: flex-start !important;
margin: 0.25rem 0 !important;
padding: 0 !important;
border: none !important;
background: none !important;
color: inherit !important;
text-shadow: none !important;
list-style: none !important;
> label {
flex: 0 0 auto !important;
margin-right: 0.5rem !important;
margin-top: 0.125rem !important;
user-select: none !important;
padding: 0 !important;
border: none !important;
background: none !important;
input[type='checkbox'] {
width: 1rem !important;
height: 1rem !important;
border-radius: 0.25rem !important;
border: 2px solid #d1d5db !important;
background-color: #fff !important;
cursor: pointer !important;
appearance: none !important;
-webkit-appearance: none !important;
-moz-appearance: none !important;
position: relative !important;
margin: 0 !important;
padding: 0 !important;
box-shadow: none !important;
&:hover {
border-color: #3b82f6 !important;
}
&:checked {
background-color: #3b82f6 !important;
border-color: #3b82f6 !important;
&::after {
content: '' !important;
position: absolute !important;
left: 0.125rem !important;
top: 0.0625rem !important;
width: 0.375rem !important;
height: 0.625rem !important;
border: 2px solid white !important;
border-top: 0 !important;
border-left: 0 !important;
transform: rotate(45deg) !important;
}
}
.dark & {
border-color: #4b5563 !important;
background-color: #374151 !important;
&:hover {
border-color: #60a5fa !important;
}
&:checked {
background-color: #60a5fa !important;
border-color: #60a5fa !important;
}
}
}
}
> div {
flex: 1 1 auto !important;
margin: 0 !important;
padding: 0 !important;
border: none !important;
background: none !important;
color: inherit !important;
}
}
}
li {
margin: 0.25rem 0 !important;
line-height: 1.5 !important;
font-size: 0.875rem !important;
display: list-item !important;
list-style-type: disc !important;
padding: 0 !important;
border: none !important;
background: none !important;
color: inherit !important;
text-shadow: none !important;
}
}
ol {
margin: 0.5rem 0 !important;
padding-left: 1.25rem !important;
list-style-type: decimal !important;
border: none !important;
background: none !important;
li {
margin: 0.25rem 0 !important;
line-height: 1.5 !important;
font-size: 0.875rem !important;
display: list-item !important;
list-style-type: decimal !important;
padding: 0 !important;
border: none !important;
background: none !important;
color: inherit !important;
text-shadow: none !important;
}
}
strong {
font-weight: 600 !important;
color: #111827 !important;
text-shadow: none !important;
.dark & {
color: #f9fafb !important;
}
}
em {
font-style: italic !important;
text-shadow: none !important;
}
a {
color: #3b82f6 !important;
text-decoration: underline !important;
text-decoration-color: rgba(59, 130, 246, 0.3) !important;
text-shadow: none !important;
background: none !important;
border: none !important;
padding: 0 !important;
margin: 0 !important;
&:hover {
text-decoration-color: #3b82f6 !important;
background: none !important;
}
.dark & {
color: #60a5fa !important;
&:hover {
text-decoration-color: #60a5fa !important;
}
}
}
blockquote {
padding: 0.2rem 1rem !important;
margin: 1rem 0 !important;
font-style: italic !important;
color: #6b7280 !important;
text-align: left !important;
border-right: none !important;
border-top: none !important;
border-bottom: none !important;
box-shadow: none !important;
text-shadow: none !important;
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background-color: #d1d5db;
z-index: 1;
border-radius: 0.5rem;
}
.dark &::before {
background-color: #4b5563;
}
.dark & {
color: #9ca3af !important;
}
}
pre {
background-color: #f3f4f6 !important;
color: #1f2937 !important;
padding: 1rem !important;
border-radius: 0.5rem !important;
margin: 1rem 0 !important;
overflow-x: auto !important;
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace !important;
font-size: 0.875rem !important;
line-height: 1.5 !important;
text-align: left !important;
white-space: pre !important;
border: none !important;
box-shadow: none !important;
text-shadow: none !important;
.dark & {
background-color: rgba(35, 36, 41, 0.5) !important;
border: 1px solid rgba(35, 36, 41, 0.5) !important;
color: #e5e7eb !important;
}
code {
background-color: transparent !important;
color: inherit !important;
padding: 0 !important;
border-radius: 0 !important;
font-size: inherit !important;
font-family: inherit !important;
border: none !important;
margin: 0 !important;
.dark & {
background-color: transparent !important;
color: inherit !important;
}
}
}
hr {
border: none !important;
border-top: 1px solid #e5e7eb !important;
margin: 1rem 0 !important;
width: 100% !important;
background: none !important;
height: 0 !important;
padding: 0 !important;
.dark & {
border-top-color: #3f4854 !important;
}
}
code {
background-color: #f3f4f6 !important;
color: #d97706 !important;
padding: 0.125rem 0.25rem !important;
border-radius: 0.25rem !important;
font-size: 0.8125rem !important;
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace !important;
border: none !important;
margin: 0 !important;
text-shadow: none !important;
.dark & {
background-color: #374151 !important;
color: #fbbf24 !important;
}
}
}
@@ -1,256 +0,0 @@
/* Editor-specific styles (animations, transitions, editor-only features) - !these are not applied to sent messages! */
/* Nested content styling with animated borders */
.editor-prose li > *:not(:first-child) {
position: relative;
margin-left: -0.5rem;
}
.editor-prose li:not(:has(> label)) > *:not(:first-child)::before {
content: '';
position: absolute;
left: -0.75rem;
top: 0;
bottom: 0;
width: 1.5px;
background-color: #e5e7eb7e;
transform-origin: top;
animation: expandDown 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.dark .editor-prose li > *:not(:first-child)::before {
background-color: #4b55637b;
}
/* Special handling for nested lists to extend the line properly */
.editor-prose li > ul,
.editor-prose li > ol {
margin-left: -0.5rem;
}
.editor-prose li > ul::before,
.editor-prose li > ol::before {
bottom: -0.25rem; /* Extend slightly below for better visual connection */
}
@keyframes expandDown {
0% {
transform: scaleY(0);
opacity: 0;
}
100% {
transform: scaleY(1);
opacity: 1;
}
}
/* Placeholders for editor-only */
.editor-prose p::before,
.editor-prose h1::before,
.editor-prose h2::before,
.editor-prose h3::before,
.editor-prose h4::before,
.editor-prose h5::before,
.editor-prose h6::before {
content: attr(data-placeholder);
color: #9ca3af;
float: left;
height: 0;
}
.dark .editor-prose p::before,
.dark .editor-prose h1::before,
.dark .editor-prose h2::before,
.dark .editor-prose h3::before,
.dark .editor-prose h4::before,
.dark .editor-prose h5::before,
.dark .editor-prose h6::before {
color: #6b7280;
}
.bnEditor {
outline: none;
padding-inline: 50px;
border-radius: 8px;
/* Define a set of colors to be used throughout the app for consistency
see https://atlassian.design/foundations/color for more info */
--N800: #172b4d; /* Dark neutral used for tooltips and text on light background */
--N40: #dfe1e6; /* Light neutral used for subtle borders and text on dark background */
}
/*
bnRoot should be applied to all top-level elements
This includes the Prosemirror editor, but also <div> element such as
Tippy popups that are appended to document.body directly
*/
.bnRoot {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.bnRoot *,
.bnRoot *::before,
.bnRoot *::after {
-webkit-box-sizing: inherit;
-moz-box-sizing: inherit;
box-sizing: inherit;
}
/* reset styles, they will be set on blockContent */
.defaultStyles p,
.defaultStyles h1,
.defaultStyles h2,
.defaultStyles h3,
.defaultStyles li {
all: unset !important;
margin: 0;
padding: 0;
font-size: inherit;
/* min width to make sure cursor is always visible */
min-width: 2px !important;
}
.defaultStyles {
font-size: 16px;
font-weight: normal;
font-family:
'Inter',
'SF Pro Display',
-apple-system,
BlinkMacSystemFont,
'Open Sans',
'Segoe UI',
'Roboto',
'Oxygen',
'Ubuntu',
'Cantarell',
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.dragPreview {
position: absolute;
top: -1000px;
}
@keyframes fadeInScale {
0% {
opacity: 0;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
}
/* Animate headers only */
.editor-prose h1,
.editor-prose h2,
.editor-prose h3,
.editor-prose h4,
.editor-prose h5,
.editor-prose h6 {
animation: fadeInScale 0.2s cubic-bezier(0.4, 0, 0.2, 1);
transform-origin: left center;
}
/* Smooth transitions for all interactive elements */
.editor-prose {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Bold and italic transitions */
.editor-prose strong,
.editor-prose em {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Selected node styling (Notion-like) */
.ProseMirror-selectednode {
box-shadow: 0 0 0 4px #3b82f6;
border-radius: 4px;
background-color: rgba(59, 130, 246, 0.05);
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.dark .ProseMirror-selectednode {
box-shadow: 0 0 0 2px #3a3e44;
background-color: rgba(96, 165, 250, 0.08);
}
/* Ensure selected nodes have proper spacing */
.ProseMirror-selectednode {
margin: 2px;
}
/* Drag and drop containment */
.editor-prose {
position: relative;
overflow: hidden;
contain: layout style;
}
/* Image drag styling */
.editor-prose img.tiptap-image {
cursor: grab;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 4px;
max-width: 100%;
height: auto;
}
.editor-prose img.tiptap-image:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: scale(1.02);
}
.editor-prose img.tiptap-image:active {
cursor: grabbing;
transform: scale(0.98);
}
/* Dropcursor styling */
.tiptap-dropcursor {
pointer-events: none;
border-radius: 2px;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Prevent drag operations outside editor */
.editor-prose * {
-webkit-user-drag: auto;
-moz-user-drag: auto;
user-drag: auto;
}
/* Ensure only images within editor are draggable */
.editor-prose img {
-webkit-user-drag: element;
-moz-user-drag: element;
user-drag: element;
}
/* Prevent text selection during drag */
.editor-prose.ProseMirror-dragover * {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
}
@@ -1,196 +0,0 @@
<script lang="ts">
import { Icon, Bold, Italic, Strikethrough, CodeBracket, ChevronDown } from 'svelte-hero-icons';
import { M } from 'motion-start';
import type { Editor } from '@tiptap/core';
let { editor = $bindable() } = $props<{ editor: Editor | null }>();
// Turn into dropdown state
let showTurnInto = $state(false);
// Turn into options
const turnIntoOptions = [
{ id: 'paragraph', label: 'Text', icon: 'T', iconClass: 'font-mono' },
{ id: 'heading1', label: 'Heading 1', icon: 'H1', iconClass: 'font-bold' },
{ id: 'heading2', label: 'Heading 2', icon: 'H2', iconClass: 'font-bold' },
{ id: 'heading3', label: 'Heading 3', icon: 'H3', iconClass: 'font-bold' },
{ id: 'separator' },
{ id: 'bulletList', label: 'Bulleted list', icon: '•' },
{ id: 'orderedList', label: 'Numbered list', icon: '1.' },
{ id: 'taskList', label: 'To-do list', icon: '☐' },
{ id: 'separator' },
{ id: 'codeBlock', label: 'Code', icon: '</>' },
{ id: 'blockquote', label: 'Quote', icon: '"' }
];
function getCurrentBlockType(): string {
if (!editor) return 'Text';
if (editor.isActive('heading', { level: 1 })) return 'Heading 1';
if (editor.isActive('heading', { level: 2 })) return 'Heading 2';
if (editor.isActive('heading', { level: 3 })) return 'Heading 3';
if (editor.isActive('bulletList')) return 'Bulleted list';
if (editor.isActive('orderedList')) return 'Numbered list';
if (editor.isActive('taskList')) return 'To-do list';
if (editor.isActive('codeBlock')) return 'Code';
if (editor.isActive('blockquote')) return 'Quote';
return 'Text';
}
function turnInto(type: string) {
if (!editor) return;
switch (type) {
case 'paragraph':
editor.chain().focus().setParagraph().run();
break;
case 'heading1':
editor.chain().focus().toggleHeading({ level: 1 }).run();
break;
case 'heading2':
editor.chain().focus().toggleHeading({ level: 2 }).run();
break;
case 'heading3':
editor.chain().focus().toggleHeading({ level: 3 }).run();
break;
case 'bulletList':
editor.chain().focus().toggleBulletList().run();
break;
case 'orderedList':
editor.chain().focus().toggleOrderedList().run();
break;
case 'taskList':
editor.chain().focus().toggleTaskList().run();
break;
case 'codeBlock':
editor.chain().focus().toggleCodeBlock().run();
break;
case 'blockquote':
editor.chain().focus().toggleBlockquote().run();
break;
}
showTurnInto = false;
}
function handleKeydown(event: KeyboardEvent) {
// Close modals/dropdowns on Escape
if (event.key === 'Escape') {
if (showTurnInto) {
showTurnInto = false;
event.preventDefault();
}
}
}
function handleClick(event: MouseEvent) {
// Close turn into dropdown if clicking outside
if (showTurnInto && !(event.target as Element).closest('.turn-into-dropdown')) {
showTurnInto = false;
}
}
</script>
<svelte:window onkeydown={handleKeydown} onclick={handleClick} />
<!-- Main Bubble Menu -->
<M.div
class="flex gap-1 items-center p-1 rounded-lg border shadow-xl backdrop-blur-lg menu dark:bg-zinc-900/90 bg-white/90 dark:border-zinc-700/30 border-zinc-200/50"
layout
transition={{ duration: 0.3, ease: "easeInOut" }}
>
{#if editor}
<M.div
class="flex gap-1 items-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<!-- Turn Into Dropdown -->
<div class="relative turn-into-dropdown">
<M.button
onclick={() => showTurnInto = !showTurnInto}
class="flex gap-1 items-center px-3 py-2 text-sm rounded-md transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800"
title="Turn into"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{getCurrentBlockType()}
<M.div
animate={{ rotate: showTurnInto ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<Icon src={ChevronDown} size="14" />
</M.div>
</M.button>
{#if showTurnInto}
<M.div
class="absolute left-0 top-full z-50 mt-1 w-48 bg-white rounded-lg border shadow-xl dark:bg-zinc-800 border-zinc-200/40 dark:border-zinc-700/40"
initial={{ opacity: 0, y: -10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ duration: 0.15 }}
>
{#each turnIntoOptions as option}
{#if option.id === 'separator'}
<div class="my-1 h-px bg-zinc-200/60 dark:bg-zinc-600/60"></div>
{:else}
<button
onclick={() => turnInto(option.id)}
class="flex gap-2 items-center px-3 py-2 w-full text-sm text-left transition-colors hover:bg-zinc-100/60 dark:hover:bg-zinc-700/40"
>
<span class="{option.iconClass || ''}">{option.icon}</span>
{option.label}
</button>
{/if}
{/each}
</M.div>
{/if}
</div>
<div class="mx-1 w-px h-6 bg-zinc-300 dark:bg-zinc-600"></div>
<M.button
onclick={() => editor.chain().focus().toggleBold().run()}
class="p-2 rounded-md transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800 {editor.isActive('bold') ? 'bg-zinc-200 dark:bg-zinc-700' : '' }"
title="Bold"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<Icon src={Bold} size="16" />
</M.button>
<M.button
onclick={() => editor.chain().focus().toggleItalic().run()}
class="p-2 rounded-md transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800 {editor.isActive('italic') ? 'bg-zinc-200 dark:bg-zinc-700' : '' }"
title="Italic"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<Icon src={Italic} size="16" />
</M.button>
<M.button
onclick={() => editor.chain().focus().toggleStrike().run()}
class="p-2 rounded-md transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800 {editor.isActive('strike') ? 'bg-zinc-200 dark:bg-zinc-700' : '' }"
title="Strikethrough"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<Icon src={Strikethrough} size="16" />
</M.button>
<M.button
onclick={() => editor.chain().focus().toggleCode().run()}
class="p-2 rounded-md transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800 {editor.isActive('code') ? 'bg-zinc-200 dark:bg-zinc-700' : '' }"
title="Code"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<Icon src={CodeBracket} size="16" />
</M.button>
</M.div>
{/if}
</M.div>
@@ -1,172 +0,0 @@
<script lang="ts">
import { slashVisible, slashItems, slashLocation, slashProps, selectedIndex } from './stores';
import { fly } from 'svelte/transition';
import { get } from 'svelte/store';
let height = $state(0);
let elements = $state<any[]>([]);
export function handleKeydown(event: any, editor: any) {
if (!get(slashVisible)) return;
if (event.key === 'ArrowUp') {
event.preventDefault();
upHandler();
return true;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
downHandler();
return true;
}
if (event.key === 'Enter') {
event.preventDefault();
selectItem(editor);
return true;
}
return false;
}
function upHandler() {
const currentIndex = get(selectedIndex);
const itemsLength = get(slashItems).length;
const newIndex = currentIndex === 0 ? itemsLength - 1 : currentIndex - 1;
selectedIndex.set(newIndex);
}
function downHandler() {
const currentIndex = get(selectedIndex);
const itemsLength = get(slashItems).length;
const newIndex = currentIndex === itemsLength - 1 ? 0 : currentIndex + 1;
selectedIndex.set(newIndex);
}
$effect(() => {
const element = elements[$selectedIndex];
if (!element) return;
const container = element.closest('.overflow-auto');
if (!container) return;
const elementRect = element.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const elementTop = elementRect.top - containerRect.top + container.scrollTop;
const elementBottom = elementTop + elementRect.height;
const containerHeight = containerRect.height;
// Check if element is outside visible area
if (elementTop < container.scrollTop) {
// Element is above visible area
container.scrollTop = elementTop - 8;
} else if (elementBottom > container.scrollTop + containerHeight) {
// Element is below visible area
container.scrollTop = elementBottom - containerHeight + 8;
}
});
function selectItem(editor: any) {
const item = get(slashItems)[get(selectedIndex)];
if (item) {
let range = get(slashProps).range;
item.command({ editor, range });
slashVisible.set(false);
}
}
function closeSlashMenu() {
slashVisible.set(false);
selectedIndex.set(0);
}
function handleItemClick(item: any) {
const editor = get(slashProps).editor;
const range = get(slashProps).range;
slashVisible.set(false);
selectedIndex.set(0);
item.command({ editor, range });
}
function getCommandIcon(title: string): string {
const icons: Record<string, string> = {
'To Dos':
'<svg class="w-5 h-5 text-blue-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd"></path></svg>',
'Heading 1':
'<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path><text x="2" y="18" font-size="12" font-weight="bold" fill="currentColor">H1</text></svg>',
'Heading 2':
'<svg class="w-5 h-5 text-purple-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path><text x="2" y="18" font-size="12" font-weight="bold" fill="currentColor">H2</text></svg>',
'Heading 3':
'<svg class="w-5 h-5 text-purple-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path><text x="2" y="18" font-size="12" font-weight="bold" fill="currentColor">H3</text></svg>',
'Bullet List':
'<svg class="w-5 h-5 text-green-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"></path></svg>',
'Numbered List':
'<svg class="w-5 h-5 text-orange-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"></path></svg>',
Text: '<svg class="w-5 h-5 text-zinc-300" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 0v12h8V4H6z" clip-rule="evenodd"></path><path d="M8 6h4M8 8h4M8 10h2"></path></svg>',
Quote:
'<svg class="w-5 h-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0-3a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0-3a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0-3a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm8-3a3 3 0 11-6 0 3 3 0 016 0z" clip-rule="evenodd"></path></svg>',
'Code Block':
'<svg class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.316 3.051a1 1 0 01.633 1.265l-4 12a1 1 0 11-1.898-.632l4-12a1 1 0 011.265-.633zM5.707 6.293a1 1 0 010 1.414L3.414 10l2.293 2.293a1 1 0 11-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0zm8.586 0a1 1 0 011.414 0l3 3a1 1 0 010 1.414l-3 3a1 1 0 11-1.414-1.414L16.586 10l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>',
Divider:
'<svg class="w-5 h-5 text-zinc-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"></path></svg>',
'Bold Text':
'<svg class="w-5 h-5 text-red-400" fill="currentColor" viewBox="0 0 20 20"><path d="M6 4v12h3.5c2.5 0 4.5-2 4.5-4.5S12 7 9.5 7H9V4H6zm3 5.5h.5c.8 0 1.5.7 1.5 1.5s-.7 1.5-1.5 1.5H9V9.5z"></path></svg>',
'Italic Text':
'<svg class="w-5 h-5 text-pink-400" fill="currentColor" viewBox="0 0 20 20"><path d="M8 4h4l-2 12H6l2-12z"></path></svg>',
Link: '<svg class="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clip-rule="evenodd"></path></svg>',
'Inline Code':
'<svg class="w-5 h-5 text-cyan-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.707 3.293a1 1 0 010 1.414L5.414 7l2.293 2.293a1 1 0 11-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0zm4.586 0a1 1 0 011.414 0l3 3a1 1 0 010 1.414l-3 3a1 1 0 11-1.414-1.414L14.586 7l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>',
};
return (
icons[title] ||
'<svg class="w-5 h-5 text-zinc-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path></svg>'
);
}
</script>
<svelte:window bind:innerHeight={height} />
{#if $slashVisible}
<div
class="fixed top-0 w-full h-screen"
onkeydown={() => {}}
onclick={closeSlashMenu}
role="menu"
tabindex="-1">
</div>
<div
transition:fly={{ y: 10, duration: 300 }}
class="overflow-auto absolute pb-2 w-80 max-w-full max-h-80 rounded-xl border shadow-xl backdrop-blur-lg origin-top-left scale-125 dark:bg-zinc-900/70 bg-zinc-100/70 dark:border-zinc-700/20 border-zinc-200"
style="left: {$slashLocation.x}px; top: {$slashLocation.y + $slashLocation.height + 320 > height
? $slashLocation.y - $slashLocation.height - 320
: $slashLocation.y + $slashLocation.height}px;">
<div class="p-2 text-sm text-zinc-500">Basic Blocks</div>
{#each $slashItems as { title, subtitle, command }, i}
<div
class="p-2 flex gap-3 cursor-pointer {i == $selectedIndex &&
'dark:bg-zinc-950/50 bg-zinc-300/50'} dark:hover:bg-zinc-950/30 hover:bg-zinc-300/20 rounded-lg mx-2"
onclick={() => handleItemClick({ command })}
onkeydown={() => {}}
role="menuitem"
tabindex="-1"
bind:this={elements[i]}>
<div class="flex justify-center items-center w-8 h-8 rounded-lg bg-zinc-800">
{@html getCommandIcon(title)}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate dark:text-white">
{title}
</div>
<p class="text-xs truncate text-zinc-400">
{subtitle ? subtitle : ''}
</p>
</div>
</div>
{/each}
</div>
{/if}
@@ -1,26 +0,0 @@
import { Extension } from '@tiptap/core';
import Suggestion from '@tiptap/suggestion';
export default Extension.create({
name: 'slash',
addOptions() {
return {
suggestion: {
char: '/',
command: ({ editor, range, props }: any) => {
props.command({ editor, range });
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});
@@ -1,46 +0,0 @@
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
type SlashItems = SlashItem[];
type SlashItem = {
title: string;
subtitle: string;
command: ({ editor, range }: EditorProps) => void;
};
type Component = {
name: string;
description: string;
code: string;
};
type Components = Component[];
type EditorProps = {
editor: any;
range: number | null;
};
type Location = {
x: number;
y: number;
height: number;
};
// For now we'll keep using stores until we can fully convert to runes in all components
export const slashItems: Writable<SlashItems> = writable([]);
export const slashVisible: Writable<boolean> = writable(false);
export const slashLocation: Writable<Location> = writable({
x: 0,
y: 0,
height: 0,
});
export const slashProps: Writable<EditorProps> = writable({
editor: null,
range: null,
});
export const desktopMenu: Writable<boolean> = writable(true);
export const components: Writable<Components> = writable([]);
export const editorWidth: Writable<number> = writable(0);
export const selectedIndex: Writable<number> = writable(0);
@@ -1,159 +0,0 @@
import { slashVisible, slashItems, slashLocation, slashProps, selectedIndex } from './stores';
export default {
items: ({ query }: any) => {
return [
{
title: 'To Dos',
subtitle: 'Create a to do list with checkboxes',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run();
},
},
{
title: 'Heading 1',
subtitle: 'BIG heading',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setNode('heading', { level: 1 }).run();
},
},
{
title: 'Heading 2',
subtitle: 'Less Big heading',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setNode('heading', { level: 2 }).run();
},
},
{
title: 'Heading 3',
subtitle: 'Medium big heading',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setNode('heading', { level: 3 }).run();
},
},
{
title: 'Bullet List',
subtitle: 'Pew pew pew',
command: ({ editor, range }: any) => {
editor.commands.deleteRange(range);
editor.commands.toggleBulletList();
},
},
{
title: 'Numbered List',
subtitle: '1, 2, 3, 4...',
command: ({ editor, range }: any) => {
editor.commands.deleteRange(range);
editor.commands.toggleOrderedList();
},
},
{
title: 'Text',
subtitle: 'Just plain text paragraph',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setNode('paragraph').run();
},
},
{
title: 'Quote',
subtitle: 'Capture important quotes',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
},
},
{
title: 'Code Block',
subtitle: 'Formatted code snippet',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
},
},
{
title: 'Divider',
subtitle: 'Add a horizontal line',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
},
{
title: 'Bold Text',
subtitle: 'Make text bold',
command: ({ editor, range }: any) => {
editor.commands.deleteRange(range);
editor.commands.toggleBold();
},
},
{
title: 'Italic Text',
subtitle: 'Make text italic',
command: ({ editor, range }: any) => {
editor.commands.deleteRange(range);
editor.commands.toggleItalic();
},
},
{
title: 'Link',
subtitle: 'Add a web link',
command: ({ editor, range }: any) => {
const url = prompt('Enter the URL:');
if (url) {
editor
.chain()
.focus()
.deleteRange(range)
.setLink({ href: url })
.insertContent('Link text')
.run();
}
},
},
{
title: 'Inline Code',
subtitle: 'Inline code snippet',
command: ({ editor, range }: any) => {
editor.commands.deleteRange(range);
editor.commands.toggleCode();
},
},
]
.filter((item) => item.title.toLowerCase().startsWith(query.toLowerCase()))
.slice(0, 10);
},
render: () => {
return {
onStart: (props: any) => {
let editor = props.editor;
let range = props.range;
let location = props.clientRect();
const editorRect = editor.view.dom.getBoundingClientRect();
slashProps.set({ editor, range });
slashVisible.set(true);
slashLocation.set({
x: location.x - editorRect.left,
y: location.y - editorRect.top + location.height / 2 + 4,
height: location.height,
});
slashItems.set(props.items);
selectedIndex.set(0);
},
onUpdate(props: any) {
slashItems.set(props.items);
selectedIndex.set(0);
},
onKeyDown(props: any) {
if (props.event.key === 'Escape') {
slashVisible.set(false);
return true;
}
},
onExit() {
slashVisible.set(false);
selectedIndex.set(0);
},
};
},
};
@@ -1,75 +0,0 @@
.ProseMirror {
position: relative;
}
.ProseMirror {
word-wrap: break-word;
white-space: pre-wrap;
white-space: break-spaces;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */
}
.ProseMirror [contenteditable="false"] {
white-space: normal;
}
.ProseMirror [contenteditable="false"] [contenteditable="true"] {
white-space: pre-wrap;
}
.ProseMirror pre {
white-space: pre-wrap;
}
img.ProseMirror-separator {
display: inline !important;
border: none !important;
margin: 0 !important;
width: 0 !important;
height: 0 !important;
}
.ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
margin: 0;
}
.ProseMirror-gapcursor:after {
content: "";
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid black;
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
}
@keyframes ProseMirror-cursor-blink {
to {
visibility: hidden;
}
}
.ProseMirror-hideselection *::selection {
background: transparent;
}
.ProseMirror-hideselection *::-moz-selection {
background: transparent;
}
.ProseMirror-hideselection * {
caret-color: transparent;
}
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
.tippy-box[data-animation=fade][data-state=hidden] {
opacity: 0
}
@@ -1,238 +0,0 @@
/* SEQTA Applied styles on DMs (applied to ensure consistency) */
.editor-prose {
font-family: 'Roboto', sans-serif;
border: 0;
padding: 0 8px;
margin: 0;
line-height: 1.2;
/* Removed font size because drag and drop text content within the editor insert html span with font sizing */
font-size: 10pt;
/* Macro: Image */
/* Macro: Image gallery (display) */
/* Fake macro element from plugin "seqta-macro" */
img[data-macro],
a[data-macro] {
border: 2px dashed #ccc;
padding: 8px;
border-radius: 4px;
position: relative;
box-sizing: border-box;
}
img[data-macro].selected,
a[data-macro].selected {
border: 2px solid #204a87;
box-shadow: inset 0 0 4px #204a87;
}
img[data-macro='Resource'] {
background-image: repeating-linear-gradient(
-45deg,
rgba(0, 0, 0, 0),
rgba(0, 0, 0, 0) 12px,
rgba(0, 0, 0, 0.05) 12px,
rgba(0, 0, 0, 0.05) 24px
);
}
img[data-macro='Embed'] {
background-image: repeating-linear-gradient(
45deg,
rgba(0, 0, 0, 0),
rgba(0, 0, 0, 0) 12px,
rgba(0, 0, 0, 0.05) 12px,
rgba(0, 0, 0, 0.05) 24px
);
}
img[data-macro='Embed'][data-full] {
width: 100%;
}
img[data-macro='Gallery'] {
display: block;
margin: 0 auto;
max-width: 100%;
padding: 0;
}
/* Direqt message-specific styling */
blockquote.forward {
margin: 0;
background: rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.1);
}
blockquote.forward > .preamble {
background: rgba(255, 255, 255, 0.1);
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
padding: 8px;
}
blockquote.forward > .preamble > .date > .label,
blockquote.forward > .preamble > .sender > .label {
color: rgba(0, 0, 0, 0.7);
}
blockquote.forward > .preamble > .date > .value,
blockquote.forward > .preamble > .sender > .value {
color: rgba(0, 0, 0, 0.9);
}
blockquote.forward > .body {
padding: 8px;
}
/** Assessment display **/
.assessmentWrapper {
position: relative;
display: inline-block;
bottom: -15px;
}
.macro-assessment {
padding: 8px 16px;
margin: 0 8px;
border: 4px solid rgba(0, 0, 0, 0.25);
display: inline-block;
width: 256px;
background-color: #fff;
overflow: hidden;
text-shadow: none;
position: relative;
word-wrap: break-word;
min-height: 30px;
}
.macro-assessment > .title {
font-size: 150%;
max-width: 230px;
}
.macro-assessment > .due > span.weight {
padding-left: 24px;
}
.macro-assessment > .due > span.marked {
padding-left: 24px;
font-weight: bold;
}
.macro-assessment > .hidden,
.macro-assessment > .deleted {
font-style: italic;
max-width: 230px;
}
/** Syllabus display **/
.macro-syllabus {
padding: 8px;
margin: 0 8px;
border: 4px solid #eee;
display: inline-block;
max-width: 200px;
background-color: #fff;
overflow: hidden;
color: #444;
text-shadow: none;
position: relative;
bottom: -15px;
}
.macro-syllabus > .label {
font-weight: bold;
}
.macro-syllabus > .extra {
font-style: italic;
}
.macro-syllabus > .meta {
text-transform: uppercase;
font-size: var(--small-text);
color: #999;
}
/* Drop-down menu for plugins like "seqta-macro" */
.cke_panel_block > h1 {
display: none;
}
.cke_panel_block > .cke_panel_list {
list-style: none;
padding: 0;
margin: 0;
}
.cke_panel_block > .cke_panel_list > li {
color: #888;
margin: 0;
cursor: pointer;
}
.cke_panel_block > .cke_panel_list > li:hover {
color: white;
background: #1b315e;
}
.cke_panel_block > .cke_panel_list > li > a {
display: block;
color: inherit;
text-decoration: inherit;
text-transform: uppercase;
font-size: 90%;
padding: 8px; /* Padding on the <a>, not the <li>, so our click target is full size. */
text-shadow: none;
}
.cke_panel_block > .cke_panel_list > li > a > p,
.cke_panel_block > .cke_panel_list > li > a > h1,
.cke_panel_block > .cke_panel_list > li > a > h2,
.cke_panel_block > .cke_panel_list > li > a > h3,
.cke_panel_block > .cke_panel_list > li > a > pre {
margin: 0;
color: inherit;
padding: 0;
}
.moodleFrame > .userHTML {
width: 100%;
height: 600px;
margin: 16px 0 16px 0;
}
.application.restricted {
display: block;
max-width: 320px;
margin: 32px auto;
border: 1px dashed #ccc;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
padding: 24px;
background: #f8f8f8;
background-image: -webkit-linear-gradient(315deg, #fff, #f8f8f8);
background-image: linear-gradient(135deg, #fff, #f8f8f8);
border-radius: 8px;
box-sizing: border-box;
}
.application.restricted > .title {
margin: 0;
padding: 0;
font-size: 100%;
font-weight: bold;
color: #666;
}
.application.restricted > .message {
margin: 0;
padding: 0;
font-size: var(--small-text);
}
}
@@ -1,236 +0,0 @@
import type { Plugin } from "@/plugins/core/types";
import { BasePlugin } from "@/plugins/core/settings";
import { defineSettings } from "@/plugins/core/settingsHelpers";
import { waitForElm } from "@/seqta/utils/waitForElm";
import renderSvelte from "@/interface/main";
import BetterEditor from "./BetterEditor.svelte";
import { unmount } from "svelte";
const settings = defineSettings({});
class CustomMessageEditorPlugin extends BasePlugin<typeof settings> {}
const settingsInstance = new CustomMessageEditorPlugin();
const customMessageEditorPlugin: Plugin<typeof settings> = {
id: "custom-message-editor",
name: "Custom Message Editor",
description: "Enhanced message editor with better editing capabilities",
version: "1.0.0",
settings: settingsInstance.settings,
defaultEnabled: true,
run: async (api) => {
let currentShadowContainer: HTMLElement | null = null;
let currentSvelteApp: any = null;
let currentEditorId: string | null = null;
let lastCKEditorContent: string = "";
const cleanup = (resetEditorId = true) => {
if (currentSvelteApp) {
unmount(currentSvelteApp);
currentSvelteApp = null;
}
if (currentShadowContainer) {
currentShadowContainer.remove();
currentShadowContainer = null;
}
if (resetEditorId) {
currentEditorId = null;
}
};
const handleEditorChange = (value: string) => {
if (currentEditorId) {
window.postMessage(
{
type: "ckeditorSetData",
editorId: currentEditorId,
content: value,
},
"*",
);
}
};
const getCKEditorContent = () => {
if (currentEditorId) {
window.postMessage(
{
type: "ckeditorGetData",
editorId: currentEditorId,
},
"*",
);
}
};
const messageListener = (event: MessageEvent) => {
if (event.data.type === "ckeditorGetDataResponse") {
lastCKEditorContent = event.data.data;
console.log("Retrieved CKEditor content:", lastCKEditorContent);
}
};
window.addEventListener("message", messageListener);
const injectBetterEditorButton = async (composer: Element) => {
try {
const pillbox = await waitForElm(
".coneqtMessage.composer .footer .pillbox",
true,
100,
50,
);
if (!pillbox) {
console.error("Could not find pillbox element");
return;
}
if (pillbox.querySelector(".better-editor-btn")) {
return;
}
const betterEditorBtn = document.createElement("button");
betterEditorBtn.type = "button";
betterEditorBtn.className = "notLast editorMode better-editor-btn";
betterEditorBtn.textContent = "Better Editor";
betterEditorBtn.setAttribute("data-key", "better");
const htmlEditorBtn = pillbox.querySelector(
'button[data-key="html"]',
) as HTMLButtonElement;
if (!htmlEditorBtn) {
console.error("Could not find HTML editor button");
return;
}
pillbox.insertBefore(betterEditorBtn, htmlEditorBtn);
betterEditorBtn.addEventListener("click", async () => {
const simpleEditorBtn = pillbox.querySelector(
'button[data-key="content"]',
) as HTMLButtonElement;
if (simpleEditorBtn) {
simpleEditorBtn.click();
}
pillbox.querySelectorAll(".editorMode").forEach((btn) => {
btn.classList.remove("depressed");
});
if (simpleEditorBtn) {
simpleEditorBtn.classList.add("depressed");
}
const wrapper = composer.querySelector(
".prime .body .formattedText .wrapper",
);
const ckeElement = wrapper?.querySelector(".cke");
if (!wrapper || !ckeElement) {
console.error("Could not find wrapper or CKE elements");
return;
}
if (ckeElement.id) {
const ckeMatch = ckeElement.id.match(/^cke_(.+)$/);
if (ckeMatch) {
currentEditorId = ckeMatch[1];
console.log("Found CKEditor ID:", currentEditorId);
}
}
let initialContent = "";
if (currentEditorId) {
window.postMessage(
{
type: "ckeditorGetData",
editorId: currentEditorId,
},
"*",
);
initialContent = await new Promise<string>((resolve) => {
const timeout = setTimeout(() => resolve(""), 1000);
const responseListener = (event: MessageEvent) => {
if (event.data.type === "ckeditorGetDataResponse") {
clearTimeout(timeout);
window.removeEventListener("message", responseListener);
resolve(event.data.data || "");
}
};
window.addEventListener("message", responseListener);
});
}
(ckeElement as HTMLElement).style.display = "none";
cleanup(false);
const shadowContainer = document.createElement("div");
shadowContainer.className = "better-editor-container";
shadowContainer.style.cssText =
"width: 100%; height: 100%; min-height: 200px; overflow-y: scroll; background: var(--background-primary); border-radius: 16px; padding: 4px;";
const shadowRoot = shadowContainer.attachShadow({ mode: "open" });
currentSvelteApp = renderSvelte(BetterEditor, shadowRoot, {
initialContent,
onchange: handleEditorChange,
});
wrapper.appendChild(shadowContainer);
currentShadowContainer = shadowContainer;
pillbox.querySelectorAll(".editorMode").forEach((btn) => {
btn.classList.remove("depressed");
});
betterEditorBtn.classList.add("depressed");
});
pillbox
.querySelectorAll(".editorMode:not(.better-editor-btn)")
.forEach((btn) => {
btn.addEventListener("click", () => {
getCKEditorContent();
cleanup(false);
const wrapper = composer.querySelector(
".prime .body .formattedText .wrapper",
);
const ckeElement = wrapper?.querySelector(".cke");
if (ckeElement) {
(ckeElement as HTMLElement).style.display = "";
}
});
});
} catch (error) {
console.error("Error injecting Better Editor button:", error);
}
};
const { unregister } = api.seqta.onMount(".uiSlidePane", (slidePane) => {
console.log("Found slide pane, checking for message composer");
const messageComposer = slidePane.querySelector(
".coneqtMessage.composer",
);
if (messageComposer) {
console.log("Found message composer, injecting Better Editor button");
injectBetterEditorButton(messageComposer);
}
});
return () => {
cleanup();
unregister();
window.removeEventListener("message", messageListener);
};
},
};
export default customMessageEditorPlugin;
@@ -0,0 +1,626 @@
# client-vector-search
A client side vector search library that can embed, search, and cache. Works on the browser and server side.
It outperforms OpenAI's text-embedding-ada-002 and is way faster than Pinecone and other VectorDBs.
I'm the founder of [searchbase.app](https://searchbase.app) and we needed this for our product and customers. We'll be using this library in production. You can be sure it'll be maintained and improved.
- Embed documents using transformers by default: gte-small (~30mb).
- Calculate cosine similarity between embeddings.
- Create an index and search on the client side
- Cache vectors with browser caching support.
Lots of improvements are coming!
## Roadmap
Our goal is to build a super simple, fast vector search that works with couple hundred to thousands vectors. ~1k vectors per user covers 99% of the use cases.
We'll initially keep things super simple and sub 100ms
### TODOs
- [ ] add HNSW index that works on node and browser env, don't rely on hnsw binder libs
- [ ] add a proper testing suite and ci/cd for the lib
- [ ] simple health tests
- [ ] mock the @xenova/transformers for jest, it's not happy with it
- [ ] performance tests, recall, memory usage, cpu usage etc.
## Installation
```bash
npm i client-vector-search
```
## Quickstart
This library provides a plug-and-play solution for embedding and vector search. It's designed to be easy to use, efficient, and versatile. Here's a quick start guide:
```ts
import { getEmbedding, EmbeddingIndex } from "client-vector-search";
// getEmbedding is an async function, so you need to use 'await' or '.then()' to get the result
const embedding = await getEmbedding("Apple"); // Returns embedding as number[]
// Each object should have an 'embedding' property of type number[]
const initialObjects = [
{ id: 1, name: "Apple", embedding: embedding },
{ id: 2, name: "Banana", embedding: await getEmbedding("Banana") },
{ id: 3, name: "Cheddar", embedding: await getEmbedding("Cheddar") },
{ id: 4, name: "Space", embedding: await getEmbedding("Space") },
{ id: 5, name: "database", embedding: await getEmbedding("database") },
];
const index = new EmbeddingIndex(initialObjects); // Creates an index
// The query should be an embedding of type number[]
const queryEmbedding = await getEmbedding("Fruit"); // Query embedding
const results = await index.search(queryEmbedding, { topK: 5 }); // Returns top similar objects
// specify the storage type
await index.saveIndex("indexedDB");
const results = await index.search([1, 2, 3], {
topK: 5,
useStorage: "indexedDB",
// storageOptions: { // use only if you overrode the defaults
// indexedDBName: 'clientVectorDB',
// indexedDBObjectStoreName: 'ClientEmbeddingStore',
// },
});
console.log(results);
await index.deleteIndexedDB(); // if you overrode default, specify db name
```
## Trouble-shooting
### NextJS
To use it inside NextJS projects you'll need to update the `next.config.js` file to include the following:
```js
module.exports = {
// Override the default webpack configuration
webpack: (config) => {
// See https://webpack.js.org/configuration/resolve/#resolvealias
config.resolve.alias = {
...config.resolve.alias,
sharp$: false,
"onnxruntime-node$": false,
};
return config;
},
};
```
#### Model load after page is loaded
You can initialize the model before using it to generate embeddings. This will ensure that the model is loaded before you use it and provide a better UX.
```js
import { initializeModel } from "client-vector-search"
...
useEffect(() => {
try {
initializeModel();
} catch (e) {
console.log(e);
}
}, []);
```
## Usage Guide
This guide provides a step-by-step walkthrough of the library's main features. It covers everything from generating embeddings for a string to performing operations on the index such as adding, updating, and removing objects. It also includes instructions on how to save the index to a database and perform search operations within it.
Until we have a reference documentation, you can find all the methods and their usage in this guide. Each step is accompanied by a code snippet to illustrate the usage of the method in question. Make sure to follow along and try out the examples in your own environment to get a better understanding of how everything works.
Let's get started!
### Step 1: Generate Embeddings for String
Generate embeddings for a given string using the `getEmbedding` method.
```ts
const embedding = await getEmbedding("Apple"); // Returns embedding as number[]
```
> **Note**: `getEmbedding` is asynchronous; make sure to use `await`.
---
### Step 2: Calculate Cosine Similarity
Calculate the cosine similarity between two embeddings.
```ts
const similarity = cosineSimilarity(embedding1, embedding2, 6);
```
> **Note**: Both embeddings should be of the same length.
---
### Step 3: Create an Index
Create an index with an initial array of objects. Each object must have an 'embedding' property.
```ts
const initialObjects = [...];
const index = new EmbeddingIndex(initialObjects);
```
---
### Step 4: Add to Index
Add an object to the index.
```ts
const objectToAdd = {
id: 6,
name: "Cat",
embedding: await getEmbedding("Cat"),
};
index.add(objectToAdd);
```
---
### Step 5: Update Index
Update an existing object in the index.
```ts
const vectorToUpdate = {
id: 6,
name: "Dog",
embedding: await getEmbedding("Dog"),
};
index.update({ id: 6 }, vectorToUpdate);
```
---
### Step 6: Remove from Index
Remove an object from the index.
```ts
index.remove({ id: 6 });
```
---
### Step 7: Retrieve from Index
Retrieve an object from the index.
```ts
const vector = index.get({ id: 1 });
```
---
### Step 8: Search the Index
Search the index with a query embedding.
```ts
const queryEmbedding = await getEmbedding("Fruit");
const results = await index.search(queryEmbedding, { topK: 5 });
```
---
### Step 9: Print the Index
Print the entire index to the console.
```ts
index.printIndex();
```
---
### Step 10: Save Index to IndexedDB (for browser)
Save the index to a persistent IndexedDB database. Note
```ts
await index.saveIndex("indexedDB", {
DBName: "clientVectorDB",
objectStoreName: "ClientEmbeddingStore",
});
```
---
### Important: Search in indexedDB
Perform a search operation in the IndexedDB.
````ts
const results = await index.search(queryEmbedding, {
topK: 5,
useStorage: "indexedDB",
storageOptions: { // only if you want to override the default options, defaults are below
indexedDBName: 'clientVectorDB',
indexedDBObjectStoreName: 'ClientEmbeddingStore'
}
});
---
### Delete Database
To delete an entire database.
```ts
await IndexedDbManager.deleteIndexedDB("clientVectorDB");
````
---
### Delete Object Store
To delete an object store from a database.
```ts
await IndexedDbManager.deleteIndexedDBObjectStore(
"clientVectorDB",
"ClientEmbeddingStore",
);
```
---
### Retrieve All Objects
To retrieve all objects from a specific object store.
```ts
const allObjects = await IndexedDbManager.getAllObjectsFromIndexedDB(
"clientVectorDB",
"ClientEmbeddingStore",
);
```
# THE MAIN INDEX.TS FILE THAT YOU ARE IMPORTING FROM
```index.ts
const DEFAULT_TOP_K = 3;
interface Filter {
[key: string]: any;
}
import Cache from './cache';
import { IndexedDbManager } from './indexedDB';
import { cosineSimilarity } from './utils';
export { ExperimentalHNSWIndex } from './hnsw';
// uncomment if you want to test indexedDB implementation in node env for faster dev cycle
// import { IDBFactory } from 'fake-indexeddb';
// const indexedDB = new IDBFactory();
export interface SearchResult {
similarity: number;
object: any;
}
type StorageOptions = 'indexedDB' | 'localStorage' | 'none';
/**
* Interface for search options in the EmbeddingIndex class.
* topK: The number of top similar items to return.
* filter: An optional filter to apply to the objects before searching.
* useStorage: A flag to indicate whether to use storage options like indexedDB or localStorage.
*/
interface SearchOptions {
topK?: number;
filter?: Filter;
useStorage?: StorageOptions;
storageOptions?: { indexedDBName: string; indexedDBObjectStoreName: string }; // TODO: generalize it to localStorage as well
}
const cacheInstance = Cache.getInstance();
let pipe: any;
let currentModel: string;
export const initializeModel = async (
model: string = 'Xenova/gte-small',
): Promise<void> => {
if (model !== currentModel) {
const transformersModule = await import('@xenova/transformers');
const pipeline = transformersModule.pipeline;
pipe = await pipeline('feature-extraction', model);
currentModel = model;
}
};
export const getEmbedding = async (
text: string,
precision: number = 7,
options = { pooling: 'mean', normalize: false },
model = 'Xenova/gte-small',
): Promise<number[]> => {
const cachedEmbedding = cacheInstance.get(text);
if (cachedEmbedding) {
return Promise.resolve(cachedEmbedding);
}
if (model !== currentModel) {
await initializeModel(model);
}
const output = await pipe(text, options);
const roundedOutput = Array.from(output.data as number[]).map(
(value: number) => parseFloat(value.toFixed(precision)),
);
cacheInstance.set(text, roundedOutput);
return Array.from(roundedOutput);
};
export class EmbeddingIndex {
private objects: Filter[];
private keys: string[];
constructor(initialObjects?: Filter[]) {
// TODO: add support for options while creating index such as {... indexedDB: true, ...}
this.objects = [];
this.keys = [];
if (initialObjects && initialObjects.length > 0) {
initialObjects.forEach((obj) => this.validateAndAdd(obj));
if (initialObjects[0]) {
this.keys = Object.keys(initialObjects[0]);
}
}
}
private findVectorIndex(filter: Filter): number {
return this.objects.findIndex((object) =>
Object.keys(filter).every((key) => object[key] === filter[key]),
);
}
private validateAndAdd(obj: Filter) {
if (!Array.isArray(obj.embedding) || obj.embedding.some(isNaN)) {
throw new Error(
'Object must have an embedding property of type number[]',
);
}
if (this.keys.length === 0) {
this.keys = Object.keys(obj);
} else if (!this.keys.every((key) => key in obj)) {
throw new Error(
'Object must have the same properties as the initial objects',
);
}
this.objects.push(obj);
}
add(obj: Filter) {
this.validateAndAdd(obj);
}
// Method to update an existing vector in the index
update(filter: Filter, vector: Filter) {
const index = this.findVectorIndex(filter);
if (index === -1) {
throw new Error('Vector not found');
}
if (vector.hasOwnProperty('embedding')) {
// Validate and add the new vector
this.validateAndAdd(vector);
}
// Replace the old vector with the new one
this.objects[index] = Object.assign(this.objects[index] as Filter, vector);
}
// Method to remove a vector from the index
remove(filter: Filter) {
const index = this.findVectorIndex(filter);
if (index === -1) {
throw new Error('Vector not found');
}
// Remove the vector from the index
this.objects.splice(index, 1);
}
// Method to remove multiple vectors from the index
removeBatch(filters: Filter[]) {
filters.forEach((filter) => {
const index = this.findVectorIndex(filter);
if (index !== -1) {
// Remove the vector from the index
this.objects.splice(index, 1);
}
});
}
// Method to retrieve a vector from the index
get(filter: Filter) {
const vector = this.objects[this.findVectorIndex(filter)];
return vector || null;
}
size(): number {
// Returns the size of the index
return this.objects.length;
}
clear() {
this.objects = [];
}
async search(
queryEmbedding: number[],
options: SearchOptions = {
topK: 3,
useStorage: 'none',
storageOptions: {
indexedDBName: 'clientVectorDB',
indexedDBObjectStoreName: 'ClientEmbeddingStore',
},
},
): Promise<SearchResult[]> {
const topK = options.topK || DEFAULT_TOP_K;
const filter = options.filter || {};
const useStorage = options.useStorage || 'none';
if (useStorage === 'indexedDB') {
const DBname = options.storageOptions?.indexedDBName || 'clientVectorDB';
const objectStoreName =
options.storageOptions?.indexedDBObjectStoreName ||
'ClientEmbeddingStore';
if (typeof indexedDB === 'undefined') {
console.error('IndexedDB is not supported');
throw new Error('IndexedDB is not supported');
}
const results = await this.loadAndSearchFromIndexedDB(
DBname,
objectStoreName,
queryEmbedding,
topK,
filter,
);
return results;
} else {
// Compute similarities
const similarities = this.objects
.filter((object) =>
Object.keys(filter).every((key) => object[key] === filter[key]),
)
.map((obj) => ({
similarity: cosineSimilarity(queryEmbedding, obj.embedding),
object: obj,
}));
// Sort by similarity and return topK results
return similarities
.sort((a, b) => b.similarity - a.similarity)
.slice(0, topK);
}
}
printIndex() {
console.log('Index Content:');
this.objects.forEach((obj, idx) => {
console.log(`Item ${idx + 1}:`, obj);
});
}
async saveIndex(
storageType: string,
options: { DBName: string; objectStoreName: string } = {
DBName: 'clientVectorDB',
objectStoreName: 'ClientEmbeddingStore',
},
) {
if (storageType === 'indexedDB') {
await this.saveToIndexedDB(options.DBName, options.objectStoreName);
} else {
throw new Error(
`Unsupported storage type: ${storageType} \n Supported storage types: "indexedDB"`,
);
}
}
async saveToIndexedDB(
DBname: string = 'clientVectorDB',
objectStoreName: string = 'ClientEmbeddingStore',
): Promise<void> {
if (typeof indexedDB === 'undefined') {
console.error('IndexedDB is not defined');
throw new Error('IndexedDB is not supported');
}
if (!this.objects || this.objects.length === 0) {
throw new Error('Index is empty. Nothing to save');
}
try {
const db = await IndexedDbManager.create(DBname, objectStoreName);
await db.addToIndexedDB(this.objects);
console.log(
`Index saved to database '${DBname}' object store '${objectStoreName}'`,
);
} catch (error) {
console.error('Error saving index to database:', error);
throw new Error('Error saving index to database');
}
}
async loadAndSearchFromIndexedDB(
DBname: string = 'clientVectorDB',
objectStoreName: string = 'ClientEmbeddingStore',
queryEmbedding: number[],
topK: number,
filter: { [key: string]: any },
): Promise<SearchResult[]> {
const db = await IndexedDbManager.create(DBname, objectStoreName);
const generator = db.dbGenerator();
const results: { similarity: number; object: any }[] = [];
for await (const record of generator) {
if (Object.keys(filter).every((key) => record[key] === filter[key])) {
const similarity = cosineSimilarity(queryEmbedding, record.embedding);
results.push({ similarity, object: record });
}
}
results.sort((a, b) => b.similarity - a.similarity);
return results.slice(0, topK);
}
async deleteIndexedDB(DBname: string = 'clientVectorDB'): Promise<void> {
if (typeof indexedDB === 'undefined') {
console.error('IndexedDB is not defined');
throw new Error('IndexedDB is not supported');
}
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(DBname);
request.onsuccess = () => {
console.log(`Database '${DBname}' deleted`);
resolve();
};
request.onerror = (event) => {
console.error('Failed to delete database', event);
reject(new Error('Failed to delete database'));
};
});
}
async deleteIndexedDBObjectStore(
DBname: string = 'clientVectorDB',
objectStoreName: string = 'ClientEmbeddingStore',
): Promise<void> {
const db = await IndexedDbManager.create(DBname, objectStoreName);
try {
await db.deleteIndexedDBObjectStoreFromDB(DBname, objectStoreName);
console.log(
`Object store '${objectStoreName}' deleted from database '${DBname}'`,
);
} catch (error) {
console.error('Error deleting object store:', error);
throw new Error('Error deleting object store');
}
}
async getAllObjectsFromIndexedDB(
DBname: string = 'clientVectorDB',
objectStoreName: string = 'ClientEmbeddingStore',
): Promise<any[]> {
const db = await IndexedDbManager.create(DBname, objectStoreName);
const objects: any[] = [];
for await (const record of db.dbGenerator()) {
objects.push(record);
}
return objects;
}
}
```
@@ -0,0 +1,50 @@
<script lang="ts">
import { highlightMatch, highlightSnippet, stripHtmlButKeepHighlights } from '../utils/highlight';
import type { DynamicContentItem } from '../utils/dynamicItems';
import type { FuseResultMatch } from '../core/types';
const { item, isSelected, searchTerm, matches } = $props<{
item: DynamicContentItem;
isSelected: boolean;
searchTerm: string;
matches?: readonly FuseResultMatch[];
}>();
</script>
<button
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
>
<div class="flex items-center w-full">
<div class="flex-none w-8 h-8 text-xl font-IconFamily flex items-center justify-center {isSelected ? 'text-zinc-900 dark:text-white' : 'text-zinc-600 dark:text-zinc-400'}">{item.metadata?.icon || '\ue924'}</div>
<span class="ml-4 text-lg truncate">
{@html stripHtmlButKeepHighlights(highlightMatch(item.text, searchTerm, matches))}
</span>
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
{item.category}
</span>
</div>
{#if item.content}
<div class="mt-1 ml-12 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2 text-start">
{@html stripHtmlButKeepHighlights(highlightSnippet(item.content, searchTerm, matches))}
</div>
{/if}
</button>
<style>
:global(.highlight) {
background-color: rgba(255, 213, 0, 0.3);
font-weight: 500;
border-radius: 2px;
padding: 0 1px;
margin: 0 -1px;
}
.dark :global(.highlight) {
background-color: rgba(255, 230, 100, 0.4);
}
.due-badge {
font-size: 0.65rem;
}
</style>

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