mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 11:44:40 +00:00
Compare commits
212 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e11ab821b | |||
| cca59ebf06 | |||
| 14648b70f5 | |||
| 0184e90088 | |||
| aa977a259d | |||
| 1507e9cdfe | |||
| c816174aed | |||
| 85dee0e2c1 | |||
| bebe0ac9b2 | |||
| f4e8c68ef3 | |||
| 8d1168d6c4 | |||
| 5dc3526711 | |||
| 0bb4d89570 | |||
| e5c05c0dca | |||
| 172021d0d0 | |||
| f9f7b54adc | |||
| 44126b6ee6 | |||
| 3ca9cf4415 | |||
| be1336a9ec | |||
| 34185e61ff | |||
| 426e0749ef | |||
| bb6de3a1a2 | |||
| 9de6e8feaf | |||
| fe82365c24 | |||
| 60e7dad261 | |||
| f8caf9cb35 | |||
| 1a17f91f10 | |||
| 043c466d9e | |||
| 3d458a185e | |||
| 58d3172c5d | |||
| ab3d4b212c | |||
| 68c94f80d6 | |||
| 2627204112 | |||
| 54e7b58794 | |||
| 3d276e3b22 | |||
| 0671a7370b | |||
| 4a7cd9b7a1 | |||
| f2b299cc9c | |||
| c1e1741b71 | |||
| bfb253341e | |||
| d91d67cf15 | |||
| 8b672f3e67 | |||
| bf1f7bfb3b | |||
| 34c88b06e8 | |||
| 528b8b49a1 | |||
| 1ad1a758fa | |||
| cdfa6723ef | |||
| 052c06a04d | |||
| 75f5f698da | |||
| 87b374571e | |||
| 1fe4c71656 | |||
| b45b5cb22f | |||
| e55e4c8a06 | |||
| a5098d62ed | |||
| f5462495c1 | |||
| b70a3e9268 | |||
| cc6091c899 | |||
| 6153861f54 | |||
| a8987b5a7b | |||
| e1544b1ee5 | |||
| b404828f0e | |||
| 83b0e8de3f | |||
| 922a3d5837 | |||
| 386445c7ee | |||
| 7555b0ff14 | |||
| 6597ff9075 | |||
| 84eaecdadd | |||
| 818ff48a0d | |||
| 33e34a0552 | |||
| d4a1fe199a | |||
| 2eefeb30b6 | |||
| e55fb35bf9 | |||
| 008666d81d | |||
| 8b26947fcf | |||
| d7a38a273c | |||
| a140bf02e0 | |||
| d624e9df32 | |||
| 881339d016 | |||
| 096c53b359 | |||
| 3440e86e2e | |||
| 43ff5d1037 | |||
| 0106124a60 | |||
| caa92e1f67 | |||
| 278a085286 | |||
| 2d325f820d | |||
| 53cfb27899 | |||
| b6e91b2254 | |||
| 8b0de97bc6 | |||
| 49d4f39584 | |||
| 1360736dd7 | |||
| e49849c18a | |||
| 6267a77a71 | |||
| 548dcbf34e | |||
| 92c0076e2d | |||
| 91ec33c0f9 | |||
| 384663912d | |||
| a0082dc895 | |||
| 03ffe22fbb | |||
| 8f5013d2ff | |||
| 831853798e | |||
| 90a4c8f048 | |||
| c9550d0d37 | |||
| 2a9e901b2b | |||
| f65dc92490 | |||
| fea486aa52 | |||
| 31e833f791 | |||
| d0a7749006 | |||
| 94684a9481 | |||
| 580cd9b3d9 | |||
| ae10f334f1 | |||
| 3b62a82d91 | |||
| 92fd20c380 | |||
| 0b9240a390 | |||
| 6885ae2d08 | |||
| f9c3fbcc87 | |||
| b8c99baf0c | |||
| 31cd9d0e48 | |||
| f5119ac9ca | |||
| 22d1f50372 | |||
| 3819fb39c8 | |||
| 3388281744 | |||
| 1272c60a4d | |||
| 3e851b335b | |||
| 8e7782b6a1 | |||
| 263a7f3cda | |||
| 17228e0444 | |||
| 3cd2f4ede8 | |||
| e55fd65590 | |||
| 1ff14b8f3e | |||
| db120c00ce | |||
| 1aa63274e7 | |||
| 08c07db6cc | |||
| 8d05692a9b | |||
| 538b46dac4 | |||
| c727bc668b | |||
| fec555d220 | |||
| 02a1e8e32d | |||
| 4187a9cade | |||
| 8d08354f6c | |||
| 15c6283bcf | |||
| de1b0c3194 | |||
| 6da4c791a6 | |||
| d1466da58b | |||
| 01fc068bcd | |||
| f08b851846 | |||
| 10e8bc29df | |||
| 40650f902d | |||
| 99c342da85 | |||
| 5f6e0fa122 | |||
| ad8eb2a273 | |||
| df23c4f888 | |||
| ed6757edb1 | |||
| f1ba486dbf | |||
| 531c350f06 | |||
| 2a91e7056e | |||
| a04d8211ed | |||
| 9f8816f322 | |||
| b55e8b47a0 | |||
| e8c0f075ec | |||
| b4ca961392 | |||
| 7fbdf8bf32 | |||
| 8ccbdd49e1 | |||
| 64ffc462d0 | |||
| f985f9445f | |||
| 966b58b932 | |||
| 2348b90023 | |||
| d68ba1521a | |||
| f6a58cda0f | |||
| 7822d210b2 | |||
| 2d23669aa3 | |||
| 7951358cd0 | |||
| 7ca4682adb | |||
| d04965db6a | |||
| ae1b676fc3 | |||
| 6b20c13705 | |||
| 38ddcbf5ca | |||
| bff4a2abf4 | |||
| 54d7eae95b | |||
| fdeea2f626 | |||
| 272deb2b8c | |||
| c3cb2937c9 | |||
| f0bdbbb14f | |||
| 52c8809cf2 | |||
| f95b845b92 | |||
| a82cd79954 | |||
| cd430f2027 | |||
| 573ac401be | |||
| ac73056128 | |||
| 9363de5fb4 | |||
| 7f93aef9cc | |||
| 220d15ebbc | |||
| 428ad7569e | |||
| 4a8ed32d3e | |||
| e001078808 | |||
| cf379c61aa | |||
| e3ec0d83ab | |||
| c2da4c1ed5 | |||
| d567c625c4 | |||
| 52bbe4d7e4 | |||
| 89f2743475 | |||
| 19102f9bcd | |||
| c008b32efa | |||
| c376183082 | |||
| e4ba89073c | |||
| 2f08d6ee08 | |||
| 99a3166fa4 | |||
| 59a8084e98 | |||
| 0d0e526a25 | |||
| 125ebfbaea | |||
| f996e4bf19 | |||
| 30c5a823d8 | |||
| 10977247cc |
@@ -0,0 +1,16 @@
|
||||
# Copy this file to .env.submit and fill in the values as you wish to publish
|
||||
CHROME_EXTENSION_ID=
|
||||
CHROME_CLIENT_ID=
|
||||
CHROME_CLIENT_SECRET=
|
||||
CHROME_REFRESH_TOKEN=
|
||||
CHROME_PUBLISH_TARGET=
|
||||
CHROME_SKIP_SUBMIT_REVIEW=
|
||||
FIREFOX_EXTENSION_ID=
|
||||
FIREFOX_JWT_ISSUER=
|
||||
FIREFOX_JWT_SECRET=
|
||||
FIREFOX_CHANNEL=
|
||||
EDGE_PRODUCT_ID=
|
||||
EDGE_CLIENT_ID=
|
||||
EDGE_CLIENT_SECRET=
|
||||
EDGE_ACCESS_TOKEN_URL=
|
||||
EDGE_SKIP_SUBMIT_REVIEW= # true or false
|
||||
+2
-2
@@ -3,12 +3,12 @@
|
||||
"browser": true,
|
||||
"commonjs": true,
|
||||
"es2021": true,
|
||||
"node": true // add this line to allow Node.js-specific globals
|
||||
"node": true
|
||||
},
|
||||
"extends": "eslint:recommended",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module" // add this line to allow 'import' and 'export' statements
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
// allow importing ts extensions
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Report A bug.
|
||||
about: Create a report of a present bug.
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Please indicate how did you make this happen.
|
||||
|
||||
**Expected behaviuor**
|
||||
Please add a clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- If using Windows, the build number. Find this by using ```winver``` and copying down the build id.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[FR] "
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: Vulnerability
|
||||
about: Report a vulnerability in this extension.
|
||||
title: "[VUL] "
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**What is the vulnerability?**
|
||||
Describe the vulnerability in concise language.
|
||||
|
||||
**Where is the vulnerability found?**
|
||||
Describe where the vulnerability is found.
|
||||
|
||||
**What does this affect?**
|
||||
Explain what it affects. E.G: It opens up my school email to the world. etc.
|
||||
@@ -0,0 +1,73 @@
|
||||
name: MVP - make, version & publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch: # This line adds manual triggering from the GitHub UI
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
make_version_publish:
|
||||
name: Make, Version & Publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node 20.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
- name: Install bun & Deps
|
||||
run: |
|
||||
npm install bun -g
|
||||
bun install
|
||||
|
||||
- name: 'Build - all browsers'
|
||||
id: buildProject
|
||||
run: MODE=chrome vite build && MODE=firefox vite build
|
||||
|
||||
- name: '[ V E R S I O N ] : Create or Update Release Pull Request - Version Changes'
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: bun run version
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: 'Get current version info from package.json'
|
||||
if: steps.changesets.outputs.hasChangesets == 'false'
|
||||
id: package
|
||||
run: |
|
||||
echo "::set-output name=PACKAGE_NAME::$(jq -r .name package.json)"
|
||||
echo "::set-output name=PACKAGE_VERSION::$(jq -r .version package.json)"
|
||||
working-directory: ${{ github.workspace }}
|
||||
|
||||
- name: 'Check if a git release already exists for current version'
|
||||
if: steps.changesets.outputs.hasChangesets == 'false'
|
||||
id: checkRelease
|
||||
run: |
|
||||
TAG_NAME=${{ steps.package.outputs.PACKAGE_NAME }}@${{ steps.package.outputs.PACKAGE_VERSION }}
|
||||
if gh release view $TAG_NAME &>/dev/null; then
|
||||
echo "Release $TAG_NAME already exists."
|
||||
echo "RELEASE_EXISTS=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "RELEASE_EXISTS=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: 'Create Release Archive(s) - zip 🫰 it 🫰 up 🫰 !'
|
||||
id: zip
|
||||
if: steps.changesets.outputs.hasChangesets == 'false'
|
||||
run: bun run zip
|
||||
|
||||
- name: 'Create a git release w/ notes & release archive(s)'
|
||||
id: gitRelease
|
||||
if: steps.changesets.outputs.hasChangesets == 'false' && env.RELEASE_EXISTS != 'true'
|
||||
run: bun run release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PACKAGE_NAME: ${{ steps.package.outputs.PACKAGE_NAME }}
|
||||
PACKAGE_VERSION: ${{ steps.package.outputs.PACKAGE_VERSION }}
|
||||
+2
-11
@@ -7,24 +7,15 @@ yarn.lock
|
||||
|
||||
.parcel-cache
|
||||
.env
|
||||
.env.submit
|
||||
|
||||
# Build
|
||||
extension.zip
|
||||
build/
|
||||
dist/
|
||||
betterseqtaplus-safari/
|
||||
|
||||
.million/
|
||||
.vscode/
|
||||
|
||||
**/.DS_Store
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
|
||||
# Sentry Config File
|
||||
.sentryclirc
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
|
||||
#
|
||||
|
||||
<a href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel">
|
||||
<img src="https://socialify.git.ci/betterseqta/betterseqta-plus/image?description=1&font=Inter&forks=1&issues=1&logo=data%3Aimage%2Fsvg%2Bxml%2C%253Csvg%20height%3D%27656pt%27%20fill%3D%27white%27%20preserveAspectRatio%3D%27xMidYMid%20meet%27%20viewBox%3D%270%200%20658%20656%27%20width%3D%27658pt%27%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%253E%253Cg%20transform%3D%27matrix(.1%200%200%20-.1%200%20656)%27%253E%253Cpath%20d%3D%27m2960%206499c-918-100-1726-561-2278-1299-196-262-374-609-475-925-171-533-203-1109-91-1655%20228-1115%201030-2032%202104-2408%20356-124%20680-177%201080-176%20269%201%20403%2014%20650%2064%20790%20159%201503%20624%201980%201290%20714%20998%20799%202342%20217%203420-488%20902-1361%201515-2382%201671-113%2017-196%2022-430%2024-159%202-328-1-375-6zm566-1443c476-99%20885-385%201134-791%20190-309%20282-696%20250-1045-22-240-73-420-180-635-78-156-159-275-274-401l-77-84h445%20446v-235-236l-1162%204-1163%203-100%2023c-449%20101-812%20337-1071%20697-77%20107-193%20335-233%20459-115%20358-116%20726-1%201078%20209%20644%20766%201101%201446%201187%20128%2016%20405%204%20540-24z%27%2F%253E%253Cpath%20d%3D%27m3065%204604c-250-36-396-89-576-209-280-187-470-478-535-821-25-135-16-395%2019-525%2095-351%20331-644%20651-806%2098-49%20225-93%20331-114%2092-18%20368-18%20460%200%20481%2095%20853%20444%20982%20921%2035%20129%2044%20389%2019%20524-36%20191-121%20387-228%20531-186%20249-476%20428-783%20485-65%2012-291%2021-340%2014z%27%2F%253E%253C%2Fg%253E%253C%2Fsvg%253E&name=1&owner=1&pattern=Signal&stargazers=1&theme=Dark" />
|
||||
</a>
|
||||
@@ -48,18 +51,13 @@
|
||||
- Brave Supported
|
||||
- Opera Supported
|
||||
- Vivaldi Supported
|
||||
- Chromium-based browsers are supported
|
||||
- Firefox (Experimental - available [here](https://addons.mozilla.org/en-US/firefox/addon/betterseqta-plus/)
|
||||
- Safari (Experimental - only available via compilation)
|
||||
|
||||
## 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/SethBurkart123/BetterSEQTA-theme-generator).
|
||||
|
||||
Don't worry- if you get stuck feel free to ask around in the discord. We're open and happy to help out! Happy creating :)
|
||||
|
||||
## 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/SethBurkart123/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. We're open and happy to help out! Happy creating :)
|
||||
|
||||
@@ -111,7 +109,7 @@ npm run build
|
||||
3. Package it up (optional)
|
||||
|
||||
```
|
||||
npm run package # This requires 7-Zip to be installed in order to work
|
||||
npm run zip # This requires 7-Zip to be installed in order to work
|
||||
```
|
||||
|
||||
## Folder Structure
|
||||
@@ -120,7 +118,7 @@ The folder structure is as follows:
|
||||
|
||||
- The `src` folder contains source files that are compiled to the build directory.
|
||||
|
||||
- The `src/interface` folder contains source React files that are required for the Settings page.
|
||||
- The `src/interface` folder contains source React & Svelte files that are required for the Settings page.
|
||||
|
||||
- The `dist` folder is where the compiled code ends up, this is the folder what you need to load into chrome as an unpacked extension for development.
|
||||
|
||||
@@ -130,7 +128,7 @@ The folder structure is as follows:
|
||||
<img src="https://contrib.rocks/image?repo=betterseqta/betterseqta-plus" />
|
||||
</a>
|
||||
|
||||
Want to contribute? [Click Here!](https://github.com/BetterSEQTA/BetterSEQTA-Plus/blob/main/contribute.md)
|
||||
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 by [SethBurkart123](https://github.com/SethBurkart123) and [Crazypersonalph](https://github.com/Crazypersonalph)
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Below here is the supported versions of BetterSEQTA+. Anything older than this is not supported and contains bugs.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 3.4.0 | :white_check_mark: |
|
||||
| <= 3.3 | :x: |
|
||||
|
||||
`*` May not work on other devices.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you find vulnerabilities, REPORT IT IMMEDIATELY. Make an issue and use the template provided for vulnerabilities.
|
||||
@@ -0,0 +1,16 @@
|
||||
import fs from "fs";
|
||||
import mime from "mime-types";
|
||||
|
||||
export const base64Loader = {
|
||||
name: "base64-loader",
|
||||
transform(_: any, id: string) {
|
||||
const [filePath, query] = id.split("?");
|
||||
if (query !== "base64") return null;
|
||||
|
||||
const data = fs.readFileSync(filePath, { encoding: 'base64' });
|
||||
const mimeType = mime.lookup(filePath);
|
||||
const dataURL = `data:${mimeType};base64,${data}`;
|
||||
|
||||
return `export default '${dataURL}';`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Browser, BuildTarget, Manifest } from './types'
|
||||
import type { AnyCase } from './utils'
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @export
|
||||
* @param {Manifest} manifest
|
||||
* @param {AnyCase<Browser>} browser
|
||||
* @return {*} {@link BuildTarget}
|
||||
*/
|
||||
export function createManifest(
|
||||
manifest: Manifest,
|
||||
browser: AnyCase<Browser>,
|
||||
): BuildTarget {
|
||||
return {
|
||||
manifest,
|
||||
browser,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* create a base Manifest to inherit from
|
||||
* type Manifest = chrome.runtime.ManifestV3
|
||||
*
|
||||
* use as shared base to extend inBrowser manifests
|
||||
*
|
||||
* @export
|
||||
* @param {Manifest} manifest
|
||||
* @return {*} {@link Manifest}
|
||||
*/
|
||||
export function createManifestBase(manifest: Manifest): Manifest {
|
||||
return manifest
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
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 **');
|
||||
}
|
||||
|
||||
fs.watchFile(manifestPath, () => {
|
||||
console.log('** watchFile ** ');
|
||||
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 **');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
const glob = require('glob');
|
||||
const semver = require('semver');
|
||||
const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
function getLatestVersion(files) {
|
||||
console.log('Files passed to getLatestVersion:', files);
|
||||
const versions = files.map(file => {
|
||||
const match = file.match(/@(\d+\.\d+\.\d+)-/);
|
||||
console.log('Matching file:', file, 'Version found:', match ? match[1] : 'None');
|
||||
return match ? match[1] : null;
|
||||
}).filter(Boolean);
|
||||
|
||||
console.log('Extracted versions:', versions);
|
||||
const latestVersion = semver.maxSatisfying(versions, '*');
|
||||
console.log('Latest version:', latestVersion);
|
||||
return latestVersion;
|
||||
}
|
||||
|
||||
function getLatestFiles(browser) {
|
||||
const pattern = `dist/betterseqtaplus@*-*${browser}.zip`;
|
||||
console.log('Glob pattern:', pattern);
|
||||
const files = glob.sync(pattern);
|
||||
console.log('Files found for browser', browser, ':', files);
|
||||
const latestVersion = getLatestVersion(files);
|
||||
|
||||
const latestFile = files.find(file => file.includes(latestVersion));
|
||||
console.log('Latest file for browser', browser, ':', latestFile);
|
||||
return latestFile;
|
||||
}
|
||||
|
||||
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}`).join(' ');
|
||||
|
||||
const zipCommand = `7z a ${zipFileName} . ${excludePatterns}`;
|
||||
|
||||
console.log('Zipping project sources with command:', zipCommand);
|
||||
execSync(zipCommand, { stdio: 'inherit' });
|
||||
|
||||
return zipFileName;
|
||||
}
|
||||
|
||||
function runPublishCommand(browsers) {
|
||||
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);
|
||||
|
||||
if (browsers.length === 0) {
|
||||
console.log('No browsers specified. Exiting.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
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';
|
||||
if (chromeZip) {
|
||||
command += ` --chrome-zip ${chromeZip}`;
|
||||
}
|
||||
if (firefoxZip && firefoxSourcesZip) {
|
||||
command += ` --firefox-zip ${firefoxZip} --firefox-sources-zip ${firefoxSourcesZip}`;
|
||||
}
|
||||
|
||||
console.log('Running command:', command);
|
||||
execSync(command, { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
// Parse command-line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const browserIndex = args.indexOf('--b');
|
||||
const browsers = browserIndex !== -1 ? args.slice(browserIndex + 1) : [];
|
||||
|
||||
runPublishCommand(browsers);
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import type { ManifestV3Export } from '@crxjs/vite-plugin'
|
||||
import { type AnyCase, createEnum } from './utils'
|
||||
|
||||
export const FrameworkEnum = {
|
||||
React: 'React',
|
||||
Vanilla: 'Vanilla',
|
||||
Preact: 'Preact',
|
||||
Lit: 'Lit',
|
||||
Svelte: 'Svelte',
|
||||
Vue: 'Vue',
|
||||
} as const
|
||||
|
||||
export const BrowserEnum = {
|
||||
Chrome: 'Chrome',
|
||||
Brave: 'Brave',
|
||||
Opera: 'Opera',
|
||||
Edge: 'Edge',
|
||||
Firefox: 'Firefox',
|
||||
Safari: 'Safari',
|
||||
} as const
|
||||
|
||||
const LanguageEnum = {
|
||||
TypeScript: 'TypeScript',
|
||||
JavaScript: 'JavaScript',
|
||||
} as const
|
||||
|
||||
export const StyleEnum = {
|
||||
Tailwind: 'Tailwind',
|
||||
} as const
|
||||
|
||||
export const PackageManagerEnum = {
|
||||
Bun: 'Bun',
|
||||
PnPm: 'PnPm',
|
||||
Npm: 'Npm',
|
||||
Yarn: 'Yarn',
|
||||
} as const
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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']
|
||||
export type ManifestWebAccessibleResources =
|
||||
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']
|
||||
|
||||
export type BrowserName<T extends string> = Capitalize<T> | Lowercase<T>
|
||||
export type BrowserEnumType<T extends string> = {
|
||||
[browser in BrowserName<T>]: BrowserName<T>
|
||||
}
|
||||
|
||||
export type BuildMode = AnyCase<Browser>
|
||||
export type BuildTarget = {
|
||||
manifest: Manifest
|
||||
browser: AnyCase<Browser>
|
||||
}
|
||||
export type BuildConfig = {
|
||||
command?: 'build' | 'serve'
|
||||
mode?: AnyCase<Browser> | string | undefined
|
||||
}
|
||||
|
||||
export interface Repository {
|
||||
type: string
|
||||
url?: string
|
||||
bugs?: Bugs
|
||||
}
|
||||
|
||||
export interface Bugs {
|
||||
url?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
export type Browser = (typeof BrowserEnum)[keyof typeof BrowserEnum]
|
||||
export const Browser: AnyCase<Browser> = createEnum(BrowserEnum)
|
||||
|
||||
export type PackageManager =
|
||||
(typeof PackageManagerEnum)[keyof typeof PackageManagerEnum]
|
||||
export const PackageManager: AnyCase<PackageManager> =
|
||||
createEnum(PackageManagerEnum)
|
||||
|
||||
export type Framework = (typeof FrameworkEnum)[keyof typeof FrameworkEnum]
|
||||
export const Framework: AnyCase<Framework> = createEnum(FrameworkEnum)
|
||||
|
||||
export type Style = (typeof StyleEnum)[keyof typeof StyleEnum]
|
||||
export const Style: AnyCase<Style> = createEnum(StyleEnum)
|
||||
|
||||
export type Language = (typeof LanguageEnum)[keyof typeof LanguageEnum]
|
||||
export const Language: AnyCase<Language> = createEnum(LanguageEnum)
|
||||
@@ -0,0 +1,21 @@
|
||||
export type ObjectValues<T> = T[keyof T]
|
||||
|
||||
export function createEnum<T extends Record<string, string>>(enumObj: T) {
|
||||
return Object.values(enumObj) as unknown as ObjectValues<T>
|
||||
}
|
||||
|
||||
export type AnyCase<T extends string> =
|
||||
| Uppercase<T>
|
||||
| Lowercase<T>
|
||||
| Capitalize<T>
|
||||
| Uncapitalize<T>
|
||||
|
||||
export type AnyCaseLanguage<T extends string, K extends string> =
|
||||
| Uppercase<T | K>
|
||||
| Lowercase<T | K>
|
||||
| Capitalize<T | K>
|
||||
| Uncapitalize<T | K>
|
||||
|
||||
export type OptionalKeys<T> = {
|
||||
[K in keyof T as undefined extends T[K] ? K : never]: T[K]
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "BetterSEQTA+",
|
||||
"version": "3.2.6",
|
||||
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development, and incorporate a plethora of new features!",
|
||||
"icons": {
|
||||
"32": "src/resources/icons/icon-32.png",
|
||||
"48": "src/resources/icons/icon-48.png",
|
||||
"64": "src/resources/icons/icon-64.png"
|
||||
},
|
||||
"action": {
|
||||
"browser_style": true,
|
||||
"default_popup": "src/interface/index.html#settings",
|
||||
"default_icon": {
|
||||
"32": "src/resources/icons/icon-32.png",
|
||||
"48": "src/resources/icons/icon-48.png",
|
||||
"64": "src/resources/icons/icon-64.png"
|
||||
}
|
||||
},
|
||||
"permissions": ["tabs", "notifications", "storage", "activeTab"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"scripts": ["src/background.ts"]
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["*://*/*"],
|
||||
"js": ["src/SEQTA.ts"],
|
||||
"run_at": "document_start"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "BetterSEQTA+",
|
||||
"version": "3.3.0",
|
||||
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development, and incorporate a plethora of new features!",
|
||||
"icons": {
|
||||
"32": "src/resources/icons/icon-32.png",
|
||||
"48": "src/resources/icons/icon-48.png",
|
||||
"64": "src/resources/icons/icon-64.png"
|
||||
},
|
||||
"action": {
|
||||
"browser_style": true,
|
||||
"default_popup": "src/interface/index.html#settings",
|
||||
"default_icon": {
|
||||
"32": "src/resources/icons/icon-32.png",
|
||||
"48": "src/resources/icons/icon-48.png",
|
||||
"64": "src/resources/icons/icon-64.png"
|
||||
}
|
||||
},
|
||||
"permissions": ["tabs", "notifications", "storage"],
|
||||
"host_permissions": ["https://newsapi.org/", "*://*/*"],
|
||||
"background": {
|
||||
"service_worker": "src/background.ts"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["*://*/*"],
|
||||
"js": ["src/SEQTA.ts"],
|
||||
"run_at": "document_start"
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["src/interface/index.html"],
|
||||
"matches": ["*://*/*"]
|
||||
},
|
||||
{
|
||||
"resources": ["src/seqta/ui/background/background.html"],
|
||||
"matches": ["*://*/*"]
|
||||
},
|
||||
{
|
||||
"resources": ["*://*/*"],
|
||||
"matches": ["*://*/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
+61
-47
@@ -1,15 +1,20 @@
|
||||
{
|
||||
"name": "betterseqtaplus",
|
||||
"version": "3.3.0",
|
||||
"version": "3.4.0",
|
||||
"type": "module",
|
||||
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development, and incorporate a plethora of new features!",
|
||||
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development, while incorporating a plethora of new and improved features!",
|
||||
"browserslist": "> 0.5%, last 2 versions, not dead",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev:firefox": "VITE_TARGET=firefox vite build --watch",
|
||||
"build": "vite build",
|
||||
"build:firefox": "VITE_TARGET=firefox vite build",
|
||||
"package": "rimraf ./dist/*.map && 7z a -tzip extension.zip ./dist/*"
|
||||
"dev": "cross-env MODE=chrome vite dev",
|
||||
"dev:firefox": "cross-env MODE=firefox vite build --watch",
|
||||
"build": "cross-env MODE=chrome vite build && cross-env MODE=firefox vite build",
|
||||
"build:chrome": "cross-env MODE=chrome vite build",
|
||||
"build:firefox": "cross-env MODE=firefox vite build",
|
||||
"build:safari": "cross-env MODE=safari vite build",
|
||||
"convert:safari": "xcrun safari-web-extension-converter dist/safari --project-location . --app-name $npm_package_name-safari",
|
||||
"release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes",
|
||||
"publish": "bun lib/publish.js --b",
|
||||
"zip": "bedframe zip"
|
||||
},
|
||||
"targets": {
|
||||
"prod": {
|
||||
@@ -19,64 +24,73 @@
|
||||
}
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"author": {
|
||||
"name": "SethBurkart123",
|
||||
"email": "betterseqta@betterseqta.com",
|
||||
"url": "https://github.com/BetterSEQTA/BetterSEQTA-plus"
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@crxjs/vite-plugin": "^2.0.0-beta.23",
|
||||
"@crxjs/vite-plugin": "2.0.0-beta.25",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"eslint": "^8.56.0",
|
||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
"glob": "^11.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"parcel": "^2.11.0",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier": "^3.3.3",
|
||||
"process": "^0.11.10",
|
||||
"sass": "^1.70.0",
|
||||
"sass": "^1.78.0",
|
||||
"sass-loader": "^13.3.3",
|
||||
"url": "^0.11.3"
|
||||
"semver": "^7.6.3",
|
||||
"url": "^0.11.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blocknote/core": "^0.14.1",
|
||||
"@blocknote/mantine": "^0.14.1",
|
||||
"@blocknote/react": "^0.14.1",
|
||||
"@bedframe/cli": "^0.0.85",
|
||||
"@codemirror/lang-css": "^6.3.0",
|
||||
"@codemirror/lang-less": "^6.0.2",
|
||||
"@heroicons/react": "^2.1.3",
|
||||
"@million/lint": "latest",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tsconfig/svelte": "^5.0.4",
|
||||
"@types/chrome": "^0.0.270",
|
||||
"@types/color": "^3.0.6",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/lodash": "^4.17.4",
|
||||
"@types/node": "^20.11.30",
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@types/sortablejs": "^1.15.7",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/node": "^20.16.5",
|
||||
"@types/react": "17",
|
||||
"@types/react-dom": "17",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/webextension-polyfill": "^0.10.7",
|
||||
"@uiw/codemirror-extensions-color": "^4.21.25",
|
||||
"@uiw/codemirror-theme-github": "^4.21.25",
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"@uiw/codemirror-extensions-color": "^4.23.3",
|
||||
"@uiw/codemirror-theme-github": "^4.23.3",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"classnames": "^2.5.1",
|
||||
"codemirror": "^6.0.1",
|
||||
"color": "^4.2.3",
|
||||
"dompurify": "^3.0.8",
|
||||
"framer-motion": "^11.0.25",
|
||||
"dompurify": "^3.1.6",
|
||||
"embla-carousel-autoplay": "^8.3.1",
|
||||
"embla-carousel-svelte": "^8.3.1",
|
||||
"fuse.js": "^7.0.0",
|
||||
"idb": "^8.0.0",
|
||||
"kolorist": "^1.8.0",
|
||||
"localforage": "^1.10.0",
|
||||
"lodash": "^4.17.21",
|
||||
"million": "latest",
|
||||
"motion": "^10.17.0",
|
||||
"react": "^18.2.0",
|
||||
"react-best-gradient-color-picker": "3.0.5",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"react-toastify": "^10.0.5",
|
||||
"rimraf": "^5.0.5",
|
||||
"sortablejs": "^1.15.2",
|
||||
"swiper": "latest",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-loader": "^9.5.1",
|
||||
"typescript": "^5.3.3",
|
||||
"million": "^3.1.11",
|
||||
"motion": "^10.18.0",
|
||||
"postcss": "^8.4.45",
|
||||
"publish-browser-extension": "^2.2.1",
|
||||
"react": "17",
|
||||
"react-best-gradient-color-picker": "^3.0.10",
|
||||
"react-dom": "17",
|
||||
"sortablejs": "^1.15.3",
|
||||
"svelte": "^5.1.9",
|
||||
"tailwindcss": "^3.4.11",
|
||||
"typescript": "^5.6.2",
|
||||
"uuid": "^9.0.1",
|
||||
"vite": "^5.2.2",
|
||||
"vite": "^5.4.4",
|
||||
"webextension-polyfill": "^0.10.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
+219
-137
@@ -5,42 +5,40 @@ import browser from 'webextension-polyfill'
|
||||
import { animate, spring, stagger } from 'motion'
|
||||
|
||||
// Internal utilities and functions
|
||||
import { delay } from './seqta/utils/delay'
|
||||
import stringToHTML from './seqta/utils/stringToHTML'
|
||||
import { MessageHandler } from './seqta/utils/listeners/MessageListener'
|
||||
import { initializeSettingsState, settingsState } from './seqta/utils/listeners/SettingsState'
|
||||
import { StorageChangeHandler } from './seqta/utils/listeners/StorageChanges'
|
||||
import { eventManager } from './seqta/utils/listeners/EventManager'
|
||||
import { delay } from '@/seqta/utils/delay'
|
||||
import stringToHTML from '@/seqta/utils/stringToHTML'
|
||||
import { MessageHandler } from '@/seqta/utils/listeners/MessageListener'
|
||||
import { initializeSettingsState, settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||
import { StorageChangeHandler } from '@/seqta/utils/listeners/StorageChanges'
|
||||
import { eventManager } from '@/seqta/utils/listeners/EventManager'
|
||||
|
||||
// UI and theme management
|
||||
import loading, { AppendLoadingSymbol } from './seqta/ui/Loading'
|
||||
import { enableCurrentTheme } from './seqta/ui/themes/enableCurrent'
|
||||
import { updateAllColors } from './seqta/ui/colors/Manager'
|
||||
import { SettingsResizer } from './seqta/ui/SettingsResizer'
|
||||
import { AddBetterSEQTAElements } from './seqta/ui/AddBetterSEQTAElements'
|
||||
import loading, { AppendLoadingSymbol } from '@/seqta/ui/Loading'
|
||||
import { enableCurrentTheme } from '@/seqta/ui/themes/enableCurrent'
|
||||
import { updateAllColors } from '@/seqta/ui/colors/Manager'
|
||||
import { SettingsResizer } from '@/seqta/ui/SettingsResizer'
|
||||
import { AddBetterSEQTAElements } from '@/seqta/ui/AddBetterSEQTAElements'
|
||||
|
||||
// JSON content
|
||||
import MenuitemSVGKey from './seqta/content/MenuItemSVGKey.json'
|
||||
import ShortcutLinks from './seqta/content/links.json'
|
||||
import MenuitemSVGKey from '@/seqta/content/MenuItemSVGKey.json'
|
||||
import ShortcutLinks from '@/seqta/content/links.json'
|
||||
|
||||
// Icons and fonts
|
||||
import IconFamily from './resources/fonts/IconFamily.woff'
|
||||
import LogoLight from './resources/icons/betterseqta-light-icon.png'
|
||||
import LogoLightOutline from './resources/icons/betterseqta-light-outline.png'
|
||||
import icon48 from './resources/icons/icon-48.png?base64'
|
||||
import assessmentsicon from './seqta/icons/assessmentsIcon'
|
||||
import coursesicon from './seqta/icons/coursesIcon'
|
||||
import IconFamily from '@/resources/fonts/IconFamily.woff'
|
||||
import LogoLight from '@/resources/icons/betterseqta-light-icon.png'
|
||||
import LogoLightOutline from '@/resources/icons/betterseqta-light-outline.png'
|
||||
import icon48 from '@/resources/icons/icon-48.png?base64'
|
||||
import assessmentsicon from '@/seqta/icons/assessmentsIcon'
|
||||
import coursesicon from '@/seqta/icons/coursesIcon'
|
||||
|
||||
// Stylesheets
|
||||
import iframeCSS from './css/iframe.scss?raw'
|
||||
import injectedCSS from './css/injected.scss?inline'
|
||||
import documentLoadCSS from './css/documentload.scss?inline'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
chrome?: any
|
||||
}
|
||||
}
|
||||
import iframeCSS from '@/css/iframe.scss?raw'
|
||||
import injectedCSS from '@/css/injected.scss?inline'
|
||||
import documentLoadCSS from '@/css/documentload.scss?inline'
|
||||
import renderSvelte from '@/interface/main'
|
||||
import Settings from '@/interface/pages/settings.svelte'
|
||||
import { settingsPopup } from './interface/hooks/SettingsPopup'
|
||||
import { migrateBackgrounds } from './seqta/utils/migrateBackgrounds'
|
||||
|
||||
let SettingsClicked = false
|
||||
export let MenuOptionsOpen = false
|
||||
@@ -48,10 +46,13 @@ let currentSelectedDate = new Date()
|
||||
let LessonInterval: any
|
||||
|
||||
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)
|
||||
const hasSEQTAText = document.childNodes[1].textContent?.includes('Copyright (c) SEQTA Software')
|
||||
init()
|
||||
if (document.childNodes[1]) {
|
||||
hasSEQTAText = document.childNodes[1].textContent?.includes('Copyright (c) SEQTA Software') ?? false
|
||||
init()
|
||||
}
|
||||
|
||||
async function init() {
|
||||
CheckForMenuList()
|
||||
@@ -59,7 +60,8 @@ async function init() {
|
||||
|
||||
if (hasSEQTAText && hasSEQTATitle && !IsSEQTAPage) {
|
||||
IsSEQTAPage = true
|
||||
console.log('[BetterSEQTA+] Verified SEQTA Page')
|
||||
console.info('[BetterSEQTA+] Verified SEQTA Page')
|
||||
|
||||
const documentLoadStyle = document.createElement('style')
|
||||
documentLoadStyle.textContent = documentLoadCSS
|
||||
document.head.appendChild(documentLoadStyle)
|
||||
@@ -83,7 +85,7 @@ async function init() {
|
||||
document.head.appendChild(injectedStyle)
|
||||
}
|
||||
}
|
||||
|
||||
console.info('[BetterSEQTA+] Successfully initalised BetterSEQTA+, starting to load assets.')
|
||||
main()
|
||||
} catch (error: any) {
|
||||
console.error(error)
|
||||
@@ -110,7 +112,7 @@ async function HideMenuItems(): Promise<void> {
|
||||
for (const [menuItem, { toggle }] of Object.entries(settingsState.menuitems)) {
|
||||
if (!toggle) {
|
||||
stylesheetInnerText += SetDisplayNone(menuItem)
|
||||
console.log(`[BetterSEQTA+] Hiding ${menuItem} menu item`)
|
||||
console.info(`[BetterSEQTA+] Hiding ${menuItem} menu item`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +120,7 @@ async function HideMenuItems(): Promise<void> {
|
||||
menuItemStyle.innerText = stylesheetInnerText
|
||||
document.head.appendChild(menuItemStyle)
|
||||
} catch (error) {
|
||||
console.error("An error occurred:", error)
|
||||
console.error("[BetterSEQTA+] An error occurred:", error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +146,7 @@ export function OpenWhatsNewPopup() {
|
||||
let video = document.createElement('video')
|
||||
let source = document.createElement('source')
|
||||
// Perhaps we host this on a server and then grab it instead of having it locally?
|
||||
source.setAttribute('src', 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/update-video.webm')
|
||||
source.setAttribute('src', 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/update-video.mp4')
|
||||
video.autoplay = true
|
||||
video.muted = true
|
||||
video.loop = true
|
||||
@@ -158,6 +160,17 @@ export function OpenWhatsNewPopup() {
|
||||
let text = stringToHTML(
|
||||
/* html */ `
|
||||
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: scroll;">
|
||||
<h1>3.4.0 - Major Performance Update</h1>
|
||||
<li>Completely rebuilt the extension popup using Svelte for dramatically improved performance</li>
|
||||
<li>Added a brand new background store with search functionality and downloadable backgrounds</li>
|
||||
<li>Significant code cleanup and optimization across the extension</li>
|
||||
<li>Improved overall responsiveness and load times</li>
|
||||
<li>Smoother animations and improved scrolling</li>
|
||||
<li>Fixed Firefox compatibility issues</li>
|
||||
<li>Other minor bug fixes and under the hood improvements</li>
|
||||
|
||||
<h1>3.3.1 - Hot Fix</h1>
|
||||
<li>Fixed assessments not loading when no notices are available</li>
|
||||
|
||||
<h1>3.3.0 - Overhauled Theming System</h1>
|
||||
<li>Added a theme store!</li>
|
||||
@@ -345,6 +358,102 @@ export function OpenWhatsNewPopup() {
|
||||
})
|
||||
}
|
||||
|
||||
export function OpenAboutPage() {
|
||||
const background = document.createElement('div')
|
||||
background.id = 'whatsnewbk'
|
||||
background.classList.add('whatsnewBackground')
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.classList.add('whatsnewContainer')
|
||||
|
||||
var header: any = stringToHTML(
|
||||
/* html */
|
||||
`<div class="whatsnewHeader">
|
||||
<h1>About</h1>
|
||||
<p>BetterSEQTA+ V${browser.runtime.getManifest().version}</p>
|
||||
</div>`
|
||||
).firstChild
|
||||
|
||||
let text = stringToHTML(
|
||||
/* html */ `
|
||||
<div class="whatsnewTextContainer" style="overflow-y: scroll;">
|
||||
<img src="${settingsState.DarkMode ? 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/branding/dark.jpg' : 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/branding/light.jpg'}" class="aboutImg" />
|
||||
|
||||
<p>BetterSEQTA+ is a fork of BetterSEQTA which was originally developed by Nulkem, which was discontinued. BetterSEQTA+ continued development of BetterSEQTA, while incorporating a plethora of features. </p>
|
||||
<p>We are currently working on fixing bugs and adding good features. If you want to make a feature request or report a bug, you can do so on GitHub (find icon below).</p>
|
||||
<h1>Credits</h1>
|
||||
<p>Nulkem created the original extension, was ported to Manifest V3 by MEGA-Dawg68, and is under active development by Crazypersonalph and SethBurkart123.</p>
|
||||
</div>
|
||||
`,
|
||||
).firstChild
|
||||
|
||||
let footer = stringToHTML(
|
||||
/* html */ `
|
||||
<div class="whatsnewFooter">
|
||||
<div>
|
||||
Report bugs and feedback:
|
||||
<a class="socials" href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" style="background: none !important; margin: 0 5px; padding:0;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25px" height="25px" viewBox="0 0 256 250" version="1.1" preserveAspectRatio="xMidYMid">
|
||||
<g><path d="M128.00106,0 C57.3172926,0 0,57.3066942 0,128.00106 C0,184.555281 36.6761997,232.535542 87.534937,249.460899 C93.9320223,250.645779 96.280588,246.684165 96.280588,243.303333 C96.280588,240.251045 96.1618878,230.167899 96.106777,219.472176 C60.4967585,227.215235 52.9826207,204.369712 52.9826207,204.369712 C47.1599584,189.574598 38.770408,185.640538 38.770408,185.640538 C27.1568785,177.696113 39.6458206,177.859325 39.6458206,177.859325 C52.4993419,178.762293 59.267365,191.04987 59.267365,191.04987 C70.6837675,210.618423 89.2115753,204.961093 96.5158685,201.690482 C97.6647155,193.417512 100.981959,187.77078 104.642583,184.574357 C76.211799,181.33766 46.324819,170.362144 46.324819,121.315702 C46.324819,107.340889 51.3250588,95.9223682 59.5132437,86.9583937 C58.1842268,83.7344152 53.8029229,70.715562 60.7532354,53.0843636 C60.7532354,53.0843636 71.5019501,49.6441813 95.9626412,66.2049595 C106.172967,63.368876 117.123047,61.9465949 128.00106,61.8978432 C138.879073,61.9465949 149.837632,63.368876 160.067033,66.2049595 C184.49805,49.6441813 195.231926,53.0843636 195.231926,53.0843636 C202.199197,70.715562 197.815773,83.7344152 196.486756,86.9583937 C204.694018,95.9223682 209.660343,107.340889 209.660343,121.315702 C209.660343,170.478725 179.716133,181.303747 151.213281,184.472614 C155.80443,188.444828 159.895342,196.234518 159.895342,208.176593 C159.895342,225.303317 159.746968,239.087361 159.746968,243.303333 C159.746968,246.709601 162.05102,250.70089 168.53925,249.443941 C219.370432,232.499507 256,184.536204 256,128.00106 C256,57.3066942 198.691187,0 128.00106,0 Z M47.9405593,182.340212 C47.6586465,182.976105 46.6581745,183.166873 45.7467277,182.730227 C44.8183235,182.312656 44.2968914,181.445722 44.5978808,180.80771 C44.8734344,180.152739 45.876026,179.97045 46.8023103,180.409216 C47.7328342,180.826786 48.2627451,181.702199 47.9405593,182.340212 Z M54.2367892,187.958254 C53.6263318,188.524199 52.4329723,188.261363 51.6232682,187.366874 C50.7860088,186.474504 50.6291553,185.281144 51.2480912,184.70672 C51.8776254,184.140775 53.0349512,184.405731 53.8743302,185.298101 C54.7115892,186.201069 54.8748019,187.38595 54.2367892,187.958254 Z M58.5562413,195.146347 C57.7719732,195.691096 56.4895886,195.180261 55.6968417,194.042013 C54.9125733,192.903764 54.9125733,191.538713 55.713799,190.991845 C56.5086651,190.444977 57.7719732,190.936735 58.5753181,192.066505 C59.3574669,193.22383 59.3574669,194.58888 58.5562413,195.146347 Z M65.8613592,203.471174 C65.1597571,204.244846 63.6654083,204.03712 62.5716717,202.981538 C61.4524999,201.94927 61.1409122,200.484596 61.8446341,199.710926 C62.5547146,198.935137 64.0575422,199.15346 65.1597571,200.200564 C66.2704506,201.230712 66.6095936,202.705984 65.8613592,203.471174 Z M75.3025151,206.281542 C74.9930474,207.284134 73.553809,207.739857 72.1039724,207.313809 C70.6562556,206.875043 69.7087748,205.700761 70.0012857,204.687571 C70.302275,203.678621 71.7478721,203.20382 73.2083069,203.659543 C74.6539041,204.09619 75.6035048,205.261994 75.3025151,206.281542 Z M86.046947,207.473627 C86.0829806,208.529209 84.8535871,209.404622 83.3316829,209.4237 C81.8013,209.457614 80.563428,208.603398 80.5464708,207.564772 C80.5464708,206.498591 81.7483088,205.631657 83.2786917,205.606221 C84.8005962,205.576546 86.046947,206.424403 86.046947,207.473627 Z M96.6021471,207.069023 C96.7844366,208.099171 95.7267341,209.156872 94.215428,209.438785 C92.7295577,209.710099 91.3539086,209.074206 91.1652603,208.052538 C90.9808515,206.996955 92.0576306,205.939253 93.5413813,205.66582 C95.054807,205.402984 96.4092596,206.021919 96.6021471,207.069023 Z" fill="currentColor" /></g>
|
||||
</svg>
|
||||
</a>
|
||||
<a class="socials" href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel" style="background: none !important; margin: 0 5px; padding:0;">
|
||||
<svg style="width:25px;height:25px" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,20L15.46,14H15.45C15.79,13.4 16,12.73 16,12C16,10.8 15.46,9.73 14.62,9H19.41C19.79,9.93 20,10.94 20,12A8,8 0 0,1 12,20M4,12C4,10.54 4.39,9.18 5.07,8L8.54,14H8.55C9.24,15.19 10.5,16 12,16C12.45,16 12.88,15.91 13.29,15.77L10.89,19.91C7,19.37 4,16.04 4,12M15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12M12,4C14.96,4 17.54,5.61 18.92,8H12C10.06,8 8.45,9.38 8.08,11.21L5.7,7.08C7.16,5.21 9.44,4 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`).firstChild
|
||||
|
||||
let exitbutton = document.createElement('div')
|
||||
exitbutton.id = 'whatsnewclosebutton'
|
||||
|
||||
container.append(header)
|
||||
container.append(text as ChildNode)
|
||||
container.append(footer as ChildNode)
|
||||
container.append(exitbutton)
|
||||
|
||||
background.append(container)
|
||||
|
||||
document.getElementById('container')!.append(background)
|
||||
|
||||
let bkelement = document.getElementById('whatsnewbk')
|
||||
let popup = document.getElementsByClassName('whatsnewContainer')[0]
|
||||
|
||||
if (settingsState.animations) {
|
||||
animate(
|
||||
[popup, bkelement as HTMLElement],
|
||||
{ scale: [0, 1], opacity: [0, 1] },
|
||||
{ easing: spring({ stiffness: 220, damping: 18 }) }
|
||||
)
|
||||
|
||||
animate(
|
||||
'.whatsnewTextContainer *',
|
||||
{ opacity: [0, 1], y: [10, 0] },
|
||||
{
|
||||
delay: stagger(0.05, { start: 0.1 }),
|
||||
duration: 0.5,
|
||||
easing: [.22, .03, .26, 1]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
delete settingsState.justupdated
|
||||
|
||||
bkelement!.addEventListener('click', function (event) {
|
||||
// Check if the click event originated from the element itself and not any of its children
|
||||
if (event.target === bkelement) {
|
||||
DeleteWhatsNew()
|
||||
}
|
||||
});
|
||||
|
||||
var closeelement = document.getElementById('whatsnewclosebutton')
|
||||
closeelement!.addEventListener('click', function () {
|
||||
DeleteWhatsNew()
|
||||
})
|
||||
}
|
||||
|
||||
export async function finishLoad() {
|
||||
try {
|
||||
document.querySelector('.legacy-root')?.classList.remove('hidden');
|
||||
@@ -359,7 +468,10 @@ export async function finishLoad() {
|
||||
|
||||
if (settingsState.justupdated && !document.getElementById('whatsnewbk')) {
|
||||
OpenWhatsNewPopup();
|
||||
|
||||
/* Background Migration script */
|
||||
}
|
||||
migrateBackgrounds();
|
||||
}
|
||||
|
||||
async function DeleteWhatsNew() {
|
||||
@@ -525,12 +637,9 @@ function applyDarkModeToIframe(iframe: HTMLIFrameElement, cssLink: HTMLStyleElem
|
||||
const iframeDocument = iframe.contentDocument;
|
||||
if (!iframeDocument) return;
|
||||
|
||||
if (iframeDocument.readyState !== 'complete') {
|
||||
iframe.onload = () => {
|
||||
applyDarkModeToIframe(iframe, cssLink);
|
||||
};
|
||||
return;
|
||||
}
|
||||
iframe.onload = () => {
|
||||
applyDarkModeToIframe(iframe, cssLink);
|
||||
};
|
||||
|
||||
if (settingsState.DarkMode) {
|
||||
iframeDocument.documentElement.classList.add('dark')
|
||||
@@ -608,7 +717,7 @@ async function handleSublink(sublink: string | undefined): Promise<void> {
|
||||
break;
|
||||
case 'home':
|
||||
window.location.replace(`${location.origin}/#?page=/home`);
|
||||
console.log('[BetterSEQTA+] Started Init')
|
||||
console.info('[BetterSEQTA+] Started Init')
|
||||
if (settingsState.onoff) loadHomePage()
|
||||
finishLoad();
|
||||
break;
|
||||
@@ -652,7 +761,7 @@ async function handleTimetable(): Promise<void> {
|
||||
}
|
||||
|
||||
async function handleNewsPage(): Promise<void> {
|
||||
console.log('[BetterSEQTA+] Started Init');
|
||||
console.info('[BetterSEQTA+] Started Init');
|
||||
if (settingsState.onoff) {
|
||||
SendNewsPage();
|
||||
if (settingsState.notificationcollector) {
|
||||
@@ -871,7 +980,7 @@ function main() {
|
||||
}
|
||||
|
||||
if (settingsState.onoff) {
|
||||
console.log('[BetterSEQTA+] Enabled')
|
||||
console.info('[BetterSEQTA+] Enabled')
|
||||
if (settingsState.DarkMode) document.documentElement.classList.add('dark')
|
||||
|
||||
document.querySelector('.legacy-root')?.classList.add('hidden')
|
||||
@@ -891,7 +1000,7 @@ function main() {
|
||||
}
|
||||
|
||||
function InjectCustomIcons() {
|
||||
console.log('[BetterSEQTA+] Injecting Icons')
|
||||
console.info('[BetterSEQTA+] Injecting Icons')
|
||||
|
||||
const style = document.createElement('style')
|
||||
style.setAttribute('type', 'text/css')
|
||||
@@ -906,7 +1015,7 @@ function InjectCustomIcons() {
|
||||
}
|
||||
|
||||
export function AppendElementsToDisabledPage() {
|
||||
console.log("[BetterSEQTA+] Appending elements to disabled page")
|
||||
console.info("[BetterSEQTA+] Appending elements to disabled page")
|
||||
AddBetterSEQTAElements()
|
||||
|
||||
let settingsStyle = document.createElement('style')
|
||||
@@ -938,30 +1047,22 @@ export function AppendElementsToDisabledPage() {
|
||||
document.head.append(settingsStyle)
|
||||
}
|
||||
|
||||
export function closeSettings() {
|
||||
const ExtensionSettings = document.getElementById('ExtensionPopup')!
|
||||
const ExtensionIframe = document.getElementById('ExtensionIframe') as HTMLIFrameElement
|
||||
export const closeExtensionPopup = (extensionPopup?: HTMLElement) => {
|
||||
if (!extensionPopup) extensionPopup = document.getElementById('ExtensionPopup')!
|
||||
|
||||
if (SettingsClicked == true) {
|
||||
ExtensionSettings!.classList.add('hide')
|
||||
if (settingsState.animations) {
|
||||
animate(
|
||||
'#ExtensionPopup',
|
||||
{ opacity: [1, 0], scale: [1, 0] },
|
||||
{ easing: spring({ stiffness: 220, damping: 18 }) }
|
||||
)
|
||||
} else {
|
||||
ExtensionSettings.style.opacity = '0'
|
||||
ExtensionSettings.style.transform = 'scale(0)'
|
||||
}
|
||||
SettingsClicked = false
|
||||
|
||||
if (ExtensionIframe.contentWindow) {
|
||||
ExtensionIframe.contentWindow.postMessage('popupClosed', '*')
|
||||
}
|
||||
extensionPopup.classList.add('hide')
|
||||
if (settingsState.animations) {
|
||||
animate((progress) => {
|
||||
extensionPopup.style.opacity = Math.max(0, 1 - progress).toString()
|
||||
extensionPopup.style.transform = `scale(${Math.max(0, 1 - progress)})`
|
||||
}, { easing: spring({ stiffness: 520, damping: 20 }) })
|
||||
} else {
|
||||
extensionPopup.style.opacity = '0'
|
||||
extensionPopup.style.transform = 'scale(0)'
|
||||
}
|
||||
|
||||
ExtensionSettings!.classList.add('hide')
|
||||
settingsPopup.triggerClose()
|
||||
SettingsClicked = false
|
||||
}
|
||||
|
||||
export function addExtensionSettings() {
|
||||
@@ -972,42 +1073,23 @@ export function addExtensionSettings() {
|
||||
const extensionContainer = document.querySelector('#container') as HTMLDivElement
|
||||
if (extensionContainer) extensionContainer.appendChild(extensionPopup)
|
||||
|
||||
const extensionIframe: HTMLIFrameElement = document.createElement('iframe')
|
||||
extensionIframe.src = `${browser.runtime.getURL('src/interface/index.html')}#settings/embedded`
|
||||
extensionIframe.id = 'ExtensionIframe'
|
||||
extensionIframe.setAttribute('allowTransparency', 'true')
|
||||
extensionIframe.setAttribute('excludeDarkCheck', 'true')
|
||||
extensionIframe.style.width = '384px'
|
||||
extensionIframe.style.height = '100%'
|
||||
extensionIframe.style.border = 'none'
|
||||
extensionPopup.appendChild(extensionIframe)
|
||||
// create shadow dom and render svelte app
|
||||
try {
|
||||
const shadow = extensionPopup.attachShadow({ mode: 'open' });
|
||||
requestIdleCallback(() => renderSvelte(Settings, shadow));
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
|
||||
const container = document.getElementById('container')
|
||||
|
||||
new SettingsResizer();
|
||||
|
||||
const closeExtensionPopup = () => {
|
||||
const ExtensionIframe = document.getElementById('ExtensionIframe') as HTMLIFrameElement
|
||||
|
||||
extensionPopup.classList.add('hide')
|
||||
if (settingsState.animations) {
|
||||
animate(
|
||||
'#ExtensionPopup',
|
||||
{ opacity: [1, 0], scale: [1, 0] },
|
||||
{ easing: spring({ stiffness: 220, damping: 18 }) }
|
||||
)
|
||||
} else {
|
||||
extensionPopup.style.opacity = '0'
|
||||
extensionPopup.style.transform = 'scale(0)'
|
||||
}
|
||||
if (ExtensionIframe.contentWindow) {
|
||||
ExtensionIframe.contentWindow.postMessage('popupClosed', '*')
|
||||
}
|
||||
SettingsClicked = false
|
||||
}
|
||||
|
||||
container!.onclick = (event) => {
|
||||
if ((event.target as HTMLElement).closest('#AddedSettings') == null && SettingsClicked) {
|
||||
if (!SettingsClicked) return;
|
||||
|
||||
if (!(event.target as HTMLElement).closest('#AddedSettings')) {
|
||||
if (event.target == extensionPopup) return;
|
||||
closeExtensionPopup()
|
||||
}
|
||||
}
|
||||
@@ -1242,25 +1324,22 @@ export function setupSettingsButton() {
|
||||
var AddedSettings = document.getElementById('AddedSettings');
|
||||
var extensionPopup = document.getElementById('ExtensionPopup');
|
||||
|
||||
AddedSettings!.addEventListener('click', function () {
|
||||
AddedSettings!.addEventListener('click', async () => {
|
||||
if (SettingsClicked) {
|
||||
extensionPopup!.classList.add('hide');
|
||||
if (settingsState.animations) {
|
||||
animate('#ExtensionPopup', { opacity: [1, 0], scale: [1, 0] }, { easing: spring({ stiffness: 220, damping: 18 }) });
|
||||
} else {
|
||||
extensionPopup!.style.opacity = '0'
|
||||
extensionPopup!.style.transform = 'scale(0)'
|
||||
}
|
||||
(document.getElementById('ExtensionIframe')! as HTMLIFrameElement).contentWindow!.postMessage('popupClosed', '*');
|
||||
SettingsClicked = false;
|
||||
closeExtensionPopup(extensionPopup as HTMLElement);
|
||||
} else {
|
||||
extensionPopup!.classList.remove('hide');
|
||||
if (settingsState.animations) {
|
||||
animate('#ExtensionPopup', { opacity: [0, 1], scale: [0, 1] }, { easing: spring({ stiffness: 260, damping: 24 }) });
|
||||
animate((progress) => {
|
||||
extensionPopup!.style.opacity = progress.toString()
|
||||
extensionPopup!.style.transform = `scale(${progress})`
|
||||
}, { easing: spring({ stiffness: 280, damping: 20 }) });
|
||||
|
||||
} else {
|
||||
extensionPopup!.style.opacity = '1'
|
||||
extensionPopup!.style.transform = 'scale(1)'
|
||||
extensionPopup!.style.transition = 'opacity 0s linear, transform 0s linear'
|
||||
}
|
||||
extensionPopup!.classList.remove('hide');
|
||||
SettingsClicked = true;
|
||||
}
|
||||
});
|
||||
@@ -2031,7 +2110,7 @@ async function AddCustomShortcutsToPage() {
|
||||
|
||||
export async function loadHomePage() {
|
||||
// Sends the html data for the home page
|
||||
console.log('[BetterSEQTA+] Started Loading Home Page')
|
||||
console.info('[BetterSEQTA+] Started Loading Home Page')
|
||||
|
||||
document.title = 'Home ― SEQTA Learn'
|
||||
const element = document.querySelector('[data-key=home]')
|
||||
@@ -2195,7 +2274,7 @@ export async function loadHomePage() {
|
||||
|
||||
var Notices = stringToHTML(NoticesStr)
|
||||
// Appends the shortcut container into the home container
|
||||
document.getElementById('home-container')!.append(Notices.firstChild!)
|
||||
document.getElementById('home-container')!.append(Notices.firstChild!) // HERE!!!
|
||||
|
||||
if (settingsState.animations) {
|
||||
animate(
|
||||
@@ -2219,38 +2298,41 @@ export async function loadHomePage() {
|
||||
|
||||
const response = await GetPrefs.json()
|
||||
|
||||
const labelArray = response.payload.filter((item: any) => item.name === 'notices.filters').map((item: any) => item.value)[0].split(' ')
|
||||
const labelArray = response.payload.filter((item: any) => item.name === 'notices.filters').map((item: any) => item.value)
|
||||
|
||||
const xhr2 = new XMLHttpRequest()
|
||||
xhr2.open(
|
||||
'POST',
|
||||
`${location.origin}/seqta/student/load/notices?`,
|
||||
true
|
||||
)
|
||||
xhr2.setRequestHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
|
||||
xhr2.onreadystatechange = function () {
|
||||
if (xhr2.readyState === 4) {
|
||||
processNotices(xhr2.response, labelArray);
|
||||
}
|
||||
};
|
||||
|
||||
const dateControl = document.querySelector('input[type="date"]') as HTMLInputElement;
|
||||
xhr2.send(JSON.stringify({ date: dateControl.value }));
|
||||
|
||||
function onInputChange(e: any) {
|
||||
xhr2.open('POST', `${location.origin}/seqta/student/load/notices?`, true);
|
||||
xhr2.setRequestHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
xhr2.send(JSON.stringify({ date: e.target.value }));
|
||||
if (labelArray.length !== 0) {
|
||||
const labelArray = response.payload.filter((item: any) => item.name === 'notices.filters').map((item: any) => item.value)[0].split(' ')
|
||||
const xhr2 = new XMLHttpRequest()
|
||||
xhr2.open(
|
||||
'POST',
|
||||
`${location.origin}/seqta/student/load/notices?`,
|
||||
true
|
||||
)
|
||||
xhr2.setRequestHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
|
||||
xhr2.onreadystatechange = function () {
|
||||
if (xhr2.readyState === 4) {
|
||||
processNotices(xhr2.response, labelArray);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
dateControl.addEventListener('input', onInputChange);
|
||||
const dateControl = document.querySelector('input[type="date"]') as HTMLInputElement;
|
||||
xhr2.send(JSON.stringify({ date: dateControl.value }));
|
||||
|
||||
function onInputChange(e: any) {
|
||||
xhr2.open('POST', `${location.origin}/seqta/student/load/notices?`, true);
|
||||
xhr2.setRequestHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
xhr2.send(JSON.stringify({ date: e.target.value }));
|
||||
|
||||
xhr2.onreadystatechange = function () {
|
||||
if (xhr2.readyState === 4) {
|
||||
processNotices(xhr2.response, labelArray);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
dateControl.addEventListener('input', onInputChange);
|
||||
}
|
||||
|
||||
if (settingsState.notificationcollector) {
|
||||
enableNotificationCollector()
|
||||
@@ -2374,7 +2456,7 @@ export function enableNotificationCollector() {
|
||||
'notifications__bubble___1EkSQ'
|
||||
)[0]
|
||||
if (typeof alertdiv == 'undefined') {
|
||||
console.log('[BetterSEQTA+] No notifications currently')
|
||||
console.info('[BetterSEQTA+] No notifications currently')
|
||||
} else {
|
||||
alertdiv.textContent = Notifications.payload.notifications.length
|
||||
}
|
||||
@@ -2424,7 +2506,7 @@ function createNewShortcut(link: any, icon: any, viewBox: any, title: any) {
|
||||
export function SendNewsPage() {
|
||||
setTimeout(function () {
|
||||
// Sends the html data for the home page
|
||||
console.log('[BetterSEQTA+] Started Loading News Page')
|
||||
console.info('[BetterSEQTA+] Started Loading News Page')
|
||||
document.title = 'News ― SEQTA Learn'
|
||||
var element = document.querySelector('[data-key=news]')
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import browser from 'webextension-polyfill'
|
||||
import { SettingsState } from "./types/storage";
|
||||
import type { SettingsState } from "@/types/storage";
|
||||
|
||||
export const openDB = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
+151
-52
@@ -1,9 +1,9 @@
|
||||
@charset "UTF-8";
|
||||
@import url('https://fonts.googleapis.com/css?family=Rubik:300,400,500,600');
|
||||
@import url("https://fonts.googleapis.com/css?family=Rubik:300,400,500,600");
|
||||
|
||||
@import './injected/sidebar-animation.scss';
|
||||
@import './injected/theme.scss';
|
||||
@import './injected/transparency.scss';
|
||||
@import "./injected/sidebar-animation.scss";
|
||||
@import "./injected/theme.scss";
|
||||
@import "./injected/transparency.scss";
|
||||
|
||||
:root {
|
||||
background: var(--better-main) !important;
|
||||
@@ -16,7 +16,12 @@
|
||||
}
|
||||
|
||||
body,
|
||||
.legacy-root input, .legacy-root textarea, .legacy-root button, .legacy-root select, .legacy-root option, .legacy-root .input,
|
||||
.legacy-root input,
|
||||
.legacy-root textarea,
|
||||
.legacy-root button,
|
||||
.legacy-root select,
|
||||
.legacy-root option,
|
||||
.legacy-root .input,
|
||||
html {
|
||||
font-family: Rubik, sans-serif !important;
|
||||
}
|
||||
@@ -26,10 +31,6 @@ html {
|
||||
}
|
||||
* {
|
||||
--theme-fg-parts: white;
|
||||
|
||||
transition:
|
||||
background-color 200ms ease-in-out,
|
||||
backdrop-filter 200ms ease-in-out;
|
||||
}
|
||||
.extension-editor {
|
||||
background: var(--background-primary);
|
||||
@@ -39,7 +40,8 @@ html {
|
||||
height: 100%;
|
||||
visibility: visible !important;
|
||||
}
|
||||
#themeCreatorIframe {
|
||||
|
||||
#themeCreator {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
@@ -62,6 +64,40 @@ html {
|
||||
}
|
||||
}
|
||||
|
||||
#store {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 9998;
|
||||
animation: fadeIn 500ms forwards;
|
||||
animation-delay: 200ms;
|
||||
opacity: 0;
|
||||
|
||||
&.hide {
|
||||
animation: fadeOut 500ms forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .resizeBar {
|
||||
background-color: rgb(28 28 30);
|
||||
}
|
||||
@@ -82,6 +118,10 @@ html {
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.modaliser-container {
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
.connectedNotificationsWrapper > div > button > svg > g {
|
||||
fill: var(--theme-primary) !important;
|
||||
}
|
||||
@@ -165,7 +205,8 @@ html {
|
||||
background: unset;
|
||||
}
|
||||
|
||||
.legacy-root button:active, .legacy-root a:active:not(.cke_combo_button) {
|
||||
.legacy-root button:active,
|
||||
.legacy-root a:active:not(.cke_combo_button) {
|
||||
background-image: unset !important;
|
||||
}
|
||||
|
||||
@@ -175,7 +216,8 @@ html {
|
||||
}
|
||||
|
||||
.dark .dashboard section {
|
||||
input, select {
|
||||
input,
|
||||
select {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -197,7 +239,7 @@ html {
|
||||
input,
|
||||
select {
|
||||
border: transparent;
|
||||
background: rgba(0, 0, 0, .1);
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -239,7 +281,7 @@ html {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
ul.magicDelete > li:hover {
|
||||
background: rgba(0, 0, 0, .1);
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.dashlet-notes > .editor {
|
||||
background: unset;
|
||||
@@ -257,7 +299,17 @@ ul.magicDelete > li.deleting {
|
||||
background: transparent !important;
|
||||
color: var(--text-color) !important;
|
||||
}
|
||||
#media-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#media-container video,
|
||||
#media-container img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
#menu li,
|
||||
#menu section {
|
||||
margin: 8px auto !important;
|
||||
@@ -288,7 +340,6 @@ ul.magicDelete > li.deleting {
|
||||
#menu {
|
||||
width: 270px;
|
||||
z-index: 19;
|
||||
transition-duration: 0.4s;
|
||||
background: var(--better-main) !important;
|
||||
color: var(--text-color);
|
||||
border-right: none;
|
||||
@@ -334,14 +385,16 @@ ul.magicDelete > li.deleting {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--item-colour, transparent);
|
||||
transition: width 100ms, transform 0.3s ease;
|
||||
transition:
|
||||
width 100ms,
|
||||
transform 0.3s ease;
|
||||
border-radius: 8px 0 0 8px;
|
||||
}
|
||||
|
||||
@@ -443,7 +496,7 @@ ol:has(.MessageList__avatar___2wxyb svg) {
|
||||
.quickbar .actions [title="Choose a colour"] > svg {
|
||||
scale: 0.9;
|
||||
}
|
||||
.quickbar[data-yiq='light'] .actions {
|
||||
.quickbar[data-yiq="light"] .actions {
|
||||
color: white !important;
|
||||
}
|
||||
.singleSelect > li {
|
||||
@@ -470,7 +523,7 @@ ol:has(.MessageList__avatar___2wxyb svg) {
|
||||
}
|
||||
#main .timetablepage .quickbar {
|
||||
border: none;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.5);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5);
|
||||
border-radius: 16px;
|
||||
}
|
||||
.quickbar .actions {
|
||||
@@ -501,7 +554,7 @@ ol:has(.MessageList__avatar___2wxyb svg) {
|
||||
margin: 0 0 0 -12px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
clip-path: polygon(50% 40%, 0 0, 100% 0);
|
||||
border: 12px solid rgba(255,255,255,0);
|
||||
border: 12px solid rgba(255, 255, 255, 0);
|
||||
border-top-color: transparent;
|
||||
}
|
||||
#main > .timetablepage > .quickbar.above::before {
|
||||
@@ -1290,7 +1343,9 @@ div > ol:has(.uiFileHandlerWrapper) {
|
||||
height: 25px;
|
||||
width: 24px;
|
||||
}
|
||||
.notifications__notifications___3mmLY > button > .notifications__bubble___1EkSQ {
|
||||
.notifications__notifications___3mmLY
|
||||
> button
|
||||
> .notifications__bubble___1EkSQ {
|
||||
background: var(--better-alert-highlight);
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
@@ -1496,7 +1551,9 @@ iframe.userHTML {
|
||||
.Collapsible__Collapsible___3O8P3 > .Collapsible__header___-Afvq {
|
||||
background: none;
|
||||
}
|
||||
.AssessmentList__AssessmentList___1GdCl > .AssessmentList__searchFilter___3N70o + .AssessmentList__items___3LcmQ {
|
||||
.AssessmentList__AssessmentList___1GdCl
|
||||
> .AssessmentList__searchFilter___3N70o
|
||||
+ .AssessmentList__items___3LcmQ {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.Thermoscore__Thermoscore___2tWMi {
|
||||
@@ -1546,19 +1603,10 @@ iframe.userHTML {
|
||||
#main > .course > .content > .resources > h2 {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
transition: 1s;
|
||||
border-radius: 16px;
|
||||
//width: 0px !important;
|
||||
//background: none;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-corner {
|
||||
background: none;
|
||||
|
||||
/* set button(top and bottom of the scrollbar) */
|
||||
body::-webkit-scrollbar-button {
|
||||
display: none !important;
|
||||
}
|
||||
:root,
|
||||
html,
|
||||
@@ -1567,8 +1615,19 @@ div,
|
||||
ol,
|
||||
ul {
|
||||
scrollbar-width: thin !important;
|
||||
scrollbar-color: var(--better-light) var(--better-sub);
|
||||
scrollbar-color: #babac0 #fff !important;
|
||||
}
|
||||
|
||||
.dark {
|
||||
body,
|
||||
div,
|
||||
ol,
|
||||
ul {
|
||||
scrollbar-width: thin !important;
|
||||
scrollbar-color: #333 #111 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.connectedNotificationsWrapper > div > button {
|
||||
color: var(--text-primary) !important;
|
||||
height: 45px;
|
||||
@@ -1592,10 +1651,13 @@ ul {
|
||||
> .SelectedAssessment__clearBtn___21D85 {
|
||||
background: var(--better-main);
|
||||
}
|
||||
.SelectedAssessment__SelectedAssessment___3Bu5D > .SelectedAssessment__meta___1gq_y {
|
||||
.SelectedAssessment__SelectedAssessment___3Bu5D
|
||||
> .SelectedAssessment__meta___1gq_y {
|
||||
border-bottom: 1px solid var(--better-main);
|
||||
}
|
||||
.TabSet__TabSet___Vo-SZ > ol.TabSet__tabs___1RRZk > li.TabSet__selected___1psfF {
|
||||
.TabSet__TabSet___Vo-SZ
|
||||
> ol.TabSet__tabs___1RRZk
|
||||
> li.TabSet__selected___1psfF {
|
||||
border-bottom-color: var(--better-main);
|
||||
}
|
||||
.TabSet__TabSet___Vo-SZ > ol.TabSet__tabs___1RRZk {
|
||||
@@ -1720,7 +1782,15 @@ div.entry.class[style*="left: 46.5%"] {
|
||||
div.entry.class[style*="width: 46.5%"] {
|
||||
width: 50% !important;
|
||||
}
|
||||
.timetablepage .dailycal > .content > .wrapper > .days > tbody > tr > td > .entriesWrapper {
|
||||
.timetablepage
|
||||
.dailycal
|
||||
> .content
|
||||
> .wrapper
|
||||
> .days
|
||||
> tbody
|
||||
> tr
|
||||
> td
|
||||
> .entriesWrapper {
|
||||
min-width: 0;
|
||||
width: auto !important;
|
||||
}
|
||||
@@ -1914,7 +1984,6 @@ div.bar.flat {
|
||||
.cke_toolbox > .cke_toolbar .cke_button_on {
|
||||
background-color: #3d3d3e !important;
|
||||
}
|
||||
|
||||
}
|
||||
.legacy-root input.singleSelect:focus {
|
||||
background: var(--auto-background);
|
||||
@@ -1958,7 +2027,15 @@ body {
|
||||
.forumView .assessment {
|
||||
background: var(--better-main);
|
||||
}
|
||||
.dailycal > .content > .wrapper > .days > tbody > tr > td > .entriesWrapper > .entry {
|
||||
.dailycal
|
||||
> .content
|
||||
> .wrapper
|
||||
> .days
|
||||
> tbody
|
||||
> tr
|
||||
> td
|
||||
> .entriesWrapper
|
||||
> .entry {
|
||||
padding: 3px;
|
||||
}
|
||||
.Viewer__Viewer___32BH- {
|
||||
@@ -1997,7 +2074,9 @@ li.MessageList__unread___3imtO {
|
||||
border-radius: 1600px;
|
||||
}
|
||||
|
||||
.MessageList__MessageList___3DxoC > ol > li.MessageList__selected___1SJNz.MessageList__unread___3imtO {
|
||||
.MessageList__MessageList___3DxoC
|
||||
> ol
|
||||
> li.MessageList__selected___1SJNz.MessageList__unread___3imtO {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -2017,7 +2096,9 @@ li.MessageList__unread___3imtO {
|
||||
transition: width 0.1s;
|
||||
}
|
||||
|
||||
.MessageList__MessageList___3DxoC > ol > li.MessageList__unread___3imtO::before {
|
||||
.MessageList__MessageList___3DxoC
|
||||
> ol
|
||||
> li.MessageList__unread___3imtO::before {
|
||||
width: 3px;
|
||||
}
|
||||
.connectedNotificationsWrapper > div > button {
|
||||
@@ -2091,7 +2172,10 @@ li.MessageList__unread___3imtO {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dark .MessageList__MessageList___3DxoC > ol > li.MessageList__selected___1SJNz {
|
||||
.dark
|
||||
.MessageList__MessageList___3DxoC
|
||||
> ol
|
||||
> li.MessageList__selected___1SJNz {
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
@@ -2509,20 +2593,21 @@ li.MessageList__unread___3imtO {
|
||||
border-radius: 5px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
/* On mouse-over, add a grey background color */
|
||||
|
||||
.upcoming-checkbox-container:hover input ~ .upcoming-checkmark {
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
/* When the checkbox is checked, add a blue background */
|
||||
|
||||
.upcoming-checkbox-container input:checked ~ .upcoming-checkmark {
|
||||
background: var(--item-colour);
|
||||
}
|
||||
/* Create the checkmark/indicator (hidden when not checked) */
|
||||
|
||||
.upcoming-checkmark:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Show the checkmark when checked */
|
||||
.upcoming-checkbox-container input:checked ~ .upcoming-checkmark:after {
|
||||
display: block;
|
||||
@@ -2581,7 +2666,9 @@ li.MessageList__unread___3imtO {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: inset 0px 6px 0 var(--item-colour, transparent), inset 0px 40px 50px -40px rgba(179, 179, 179, 0.9);
|
||||
box-shadow:
|
||||
inset 0px 6px 0 var(--item-colour, transparent),
|
||||
inset 0px 40px 50px -40px rgba(179, 179, 179, 0.9);
|
||||
transition: 200ms;
|
||||
position: relative;
|
||||
height: 15em;
|
||||
@@ -2590,7 +2677,9 @@ li.MessageList__unread___3imtO {
|
||||
font-family: Rubik, sans-serif;
|
||||
}
|
||||
.dark .day {
|
||||
box-shadow: inset 0px 6px 0 var(--item-colour, transparent), inset 0px 40px 50px -40px rgba(0,0,0,0.9);
|
||||
box-shadow:
|
||||
inset 0px 6px 0 var(--item-colour, transparent),
|
||||
inset 0px 40px 50px -40px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
@@ -2627,7 +2716,7 @@ li.MessageList__unread___3imtO {
|
||||
border-radius: 16px;
|
||||
}
|
||||
.dark .upcoming-items {
|
||||
box-shadow: inset 0px 40px 80px -40px rgba(0,0,0,0.6);
|
||||
box-shadow: inset 0px 40px 80px -40px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.upcoming-assessment-title {
|
||||
color: var(--text-primary);
|
||||
@@ -2907,7 +2996,6 @@ li.MessageList__unread___3imtO {
|
||||
width: 90%;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.3);
|
||||
|
||||
}
|
||||
.whatsnewTextContainer {
|
||||
display: flex;
|
||||
@@ -2977,9 +3065,20 @@ li.MessageList__unread___3imtO {
|
||||
}
|
||||
.whatsnewTextContainer h1:not(.whatsnewTextHeader) {
|
||||
position: sticky;
|
||||
font-size: 1.2em;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
background: var(--background-primary) !important;
|
||||
z-index: 1;
|
||||
padding: 10px;
|
||||
padding: 12px;
|
||||
padding-left: 0px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.whatsnewTextContainer img {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
aspect-ratio: 16/9;
|
||||
object-fit: cover;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@@ -36,4 +36,5 @@
|
||||
transform-origin: 70% 0;
|
||||
will-change: opacity, transform;
|
||||
transform: translateZ(0); // promotes GPU rendering
|
||||
transition: opacity 0.05s, transform 0.05s;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import React, { createContext, ReactNode, useContext, useState } from 'react';
|
||||
import { SettingsState } from './types/AppProps';
|
||||
import useSettingsState from './hooks/settingsState';
|
||||
|
||||
// Create a context with an initial state
|
||||
const SettingsContext = createContext<{
|
||||
settingsState: SettingsState;
|
||||
setSettingsState: React.Dispatch<React.SetStateAction<SettingsState>>;
|
||||
showPicker: boolean;
|
||||
setShowPicker: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
standalone: boolean;
|
||||
setStandalone: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
} | undefined>(undefined);
|
||||
|
||||
export const SettingsContextProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [settingsState, setSettingsState] = useState<SettingsState>({
|
||||
notificationCollector: false,
|
||||
lessonAlerts: false,
|
||||
animatedBackground: false,
|
||||
animatedBackgroundSpeed: "0",
|
||||
customThemeColor: "rgba(219, 105, 105, 1)",
|
||||
betterSEQTAPlus: true,
|
||||
shortcuts: [],
|
||||
customshortcuts: [],
|
||||
transparencyEffects: false,
|
||||
selectedTheme: '',
|
||||
animations: true,
|
||||
defaultPage: 'home',
|
||||
devMode: false
|
||||
});
|
||||
|
||||
const [showPicker, setShowPicker] = useState<boolean>(false);
|
||||
const [standalone, setStandalone] = useState<boolean>(false);
|
||||
|
||||
useSettingsState({ settingsState, setSettingsState });
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={{ settingsState, setSettingsState, showPicker, setShowPicker, standalone, setStandalone }}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line
|
||||
export const useSettingsContext = () => {
|
||||
const context = useContext(SettingsContext);
|
||||
if (!context) {
|
||||
throw new Error('useSettingsContext must be used within a SettingsContextProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
const presetBackgrounds = [
|
||||
// Images
|
||||
{
|
||||
id: 'image-preset-1',
|
||||
type: 'image',
|
||||
url: 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/backgrounds/images/background-1.jpg',
|
||||
previewUrl: 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/backgrounds/images/background-1-thumb.jpg',
|
||||
isPreset: true
|
||||
},
|
||||
{
|
||||
id: 'image-preset-2',
|
||||
type: 'image',
|
||||
url: 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/backgrounds/images/background-2.jpg',
|
||||
previewUrl: 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/backgrounds/images/background-2-thumb.jpg',
|
||||
isPreset: true
|
||||
},
|
||||
{
|
||||
id: 'image-preset-3',
|
||||
type: 'image',
|
||||
url: 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/backgrounds/images/background-3.jpg',
|
||||
previewUrl: 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/backgrounds/images/background-3-thumb.jpg',
|
||||
isPreset: true
|
||||
},
|
||||
{
|
||||
id: 'image-preset-4',
|
||||
type: 'image',
|
||||
url: 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/backgrounds/images/background-4.jpg',
|
||||
previewUrl: 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/backgrounds/images/background-4-thumb.jpg',
|
||||
isPreset: true
|
||||
},
|
||||
{
|
||||
id: 'image-preset-5',
|
||||
type: 'image',
|
||||
url: 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/backgrounds/images/background-5.jpg',
|
||||
previewUrl: 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/backgrounds/images/background-5-thumb.jpg',
|
||||
isPreset: true
|
||||
},
|
||||
{
|
||||
id: 'image-preset-6',
|
||||
type: 'image',
|
||||
url: 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/backgrounds/images/background-6.jpg',
|
||||
previewUrl: 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/backgrounds/images/background-6-thumb.jpg',
|
||||
isPreset: true
|
||||
},
|
||||
{
|
||||
id: 'image-preset-7',
|
||||
type: 'image',
|
||||
url: 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/backgrounds/images/background-7.jpg',
|
||||
previewUrl: 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/backgrounds/images/background-7-thumb.jpg',
|
||||
isPreset: true
|
||||
},
|
||||
|
||||
// Videos
|
||||
{
|
||||
id: 'video-preset-1',
|
||||
type: 'video',
|
||||
url: 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/backgrounds/videos/animated-1.mp4',
|
||||
previewUrl: 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/backgrounds/videos/animation-1-thumb.mp4',
|
||||
isPreset: true
|
||||
},
|
||||
{
|
||||
id: 'video-preset-2',
|
||||
type: 'video',
|
||||
url: 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/backgrounds/videos/animation-2.mp4',
|
||||
previewUrl: 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/backgrounds/videos/animation-2-thumb.mp4',
|
||||
isPreset: true
|
||||
}
|
||||
];
|
||||
|
||||
export default presetBackgrounds;
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1,32 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
const Accordion = ({ children, title, defaultOpened }: { children: React.ReactNode, title: string, defaultOpened?: boolean }) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [shown, setShown] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const show = async () => {
|
||||
if (defaultOpened) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
setShown(true);
|
||||
}
|
||||
};
|
||||
|
||||
show();
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setShown(!shown)} className='flex items-center justify-between text-[15px] w-full'>
|
||||
{ title }
|
||||
<ChevronDownIcon className={`transition-transform duration-300 ${shown ? 'rotate-180' : ''}`} height='24' aria-hidden />
|
||||
</button>
|
||||
<div ref={ref} className='overflow-y-hidden transition-all duration-300 ease-in-out' style={{ height: `${shown ? ref.current?.scrollHeight : '0'}px` }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Accordion;
|
||||
@@ -1,21 +0,0 @@
|
||||
@keyframes shake {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(-1deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(1deg);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(-1deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shake {
|
||||
animation: shake 0.5s linear infinite;
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
import { ChangeEvent, memo, useEffect, useState } from "react";
|
||||
import { downloadPresetBackground, openDB, readAllData, writeData } from "../hooks/BackgroundDataLoader";
|
||||
import presetBackgrounds from "../assets/presetBackgrounds";
|
||||
import "./BackgroundSelector.css";
|
||||
|
||||
export interface Background {
|
||||
id: string;
|
||||
type: string;
|
||||
blob: Blob;
|
||||
url?: string;
|
||||
previewUrl?: string;
|
||||
isPreset?: boolean;
|
||||
isDownloaded?: boolean;
|
||||
}
|
||||
|
||||
interface BackgroundSelectorProps {
|
||||
isEditMode: boolean;
|
||||
disableTheme: () => void;
|
||||
}
|
||||
|
||||
async function GetTheme() {
|
||||
return localStorage.getItem('selectedBackground');
|
||||
}
|
||||
|
||||
async function SetTheme(theme: string) {
|
||||
localStorage.setItem('selectedBackground', theme);
|
||||
//await browser.storage.local.set({ theme });
|
||||
}
|
||||
|
||||
function BackgroundSelector({ isEditMode, disableTheme }: BackgroundSelectorProps) {
|
||||
const [backgrounds, setBackgrounds] = useState<Background[]>([]);
|
||||
const [selectedBackground, setSelectedBackground] = useState<string | null>();
|
||||
const [downloadedPresetIds, setDownloadedPresetIds] = useState<string[]>([]);
|
||||
const [downloadProgress, setDownloadProgress] = useState<Record<string, number>>({});
|
||||
|
||||
const [BackgroundsBlocked, setBackgroundsBlocked] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
GetTheme().then((theme) => {
|
||||
setSelectedBackground(theme);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const fileId = `${Date.now()}-${file.name}`;
|
||||
const fileType = file.type.split('/')[0];
|
||||
const blob = new Blob([file], { type: file.type });
|
||||
|
||||
await writeData(fileId, fileType, blob);
|
||||
setBackgrounds(prev => [...prev, { id: fileId, type: fileType, blob, url: URL.createObjectURL(blob) }]);
|
||||
};
|
||||
|
||||
const loadBackgrounds = async (): Promise<void> => {
|
||||
const data = await readAllData();
|
||||
const dataWithUrls = data.map(bg => ({ ...bg, url: URL.createObjectURL(bg.blob) }));
|
||||
|
||||
// Update downloaded preset IDs
|
||||
setDownloadedPresetIds(data.map(bg => bg.id));
|
||||
|
||||
setBackgrounds(dataWithUrls);
|
||||
};
|
||||
|
||||
const handlePresetClick = async (bg: Background): Promise<void> => {
|
||||
if (bg.isPreset) {
|
||||
// Check if indexed DB is accessible or whether cross site cookies blocks it
|
||||
try {
|
||||
await openDB();
|
||||
} catch (error) {
|
||||
// @ts-expect-error - Brave is not in the navigator type (unless you are actually using brave browser)
|
||||
if (navigator.brave && await navigator.brave.isBrave() || false) {
|
||||
console.error('[BetterSEQTA+] Brave browser is blocking access to IndexedDB. Please disable the "Cross-site cookies blocked" setting in the Shields panel. (or you can just disable brave shields for SEQTA)');
|
||||
setBackgroundsBlocked(true);
|
||||
return;
|
||||
}
|
||||
alert("[BetterSEQTA+] IndexedDB is not accessible. Please check your browser settings (It's probably cross-site cookies that are blocked).");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already exists in IndexedDB or is currently being downloaded
|
||||
const existingBackgrounds = await readAllData();
|
||||
const alreadyExists = existingBackgrounds.some(ebg => ebg.id === bg.id) || downloadProgress[bg.id] !== undefined;
|
||||
|
||||
if (!alreadyExists) {
|
||||
setDownloadProgress(prev => ({ ...prev, [bg.id]: 0 }));
|
||||
const downloadedBg = await downloadPresetBackground(bg, progress => {
|
||||
setDownloadProgress(prev => ({ ...prev, [bg.id]: progress }));
|
||||
});
|
||||
setDownloadProgress(prev => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { [bg.id]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
await writeData(downloadedBg.id, downloadedBg.type, downloadedBg.blob);
|
||||
setBackgrounds(prev => [...prev, downloadedBg]);
|
||||
setDownloadedPresetIds(prev => [...prev, downloadedBg.id]);
|
||||
}
|
||||
selectBackground(bg.id);
|
||||
}
|
||||
};
|
||||
|
||||
const selectBackground = (fileId: string): void => {
|
||||
if (selectedBackground == fileId) {
|
||||
selectNoBackground();
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedBackground(fileId);
|
||||
SetTheme(fileId);
|
||||
};
|
||||
|
||||
const deleteBackground = async (fileId: string): Promise<void> => {
|
||||
const db = await openDB();
|
||||
const tx = db.transaction('backgrounds', 'readwrite');
|
||||
const store = tx.objectStore('backgrounds');
|
||||
store.delete(fileId);
|
||||
setBackgrounds(prev => prev.filter(bg => bg.id !== fileId));
|
||||
|
||||
// Check if the background being deleted is currently selected
|
||||
if (fileId === selectedBackground) {
|
||||
selectNoBackground(); // Disable the current background
|
||||
}
|
||||
};
|
||||
|
||||
const selectNoBackground = (): void => {
|
||||
setSelectedBackground(null);
|
||||
SetTheme('');
|
||||
};
|
||||
|
||||
const calcCircumference = (radius: number) => 2 * Math.PI * radius;
|
||||
|
||||
useEffect(() => {
|
||||
loadBackgrounds();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
disabled={selectedBackground == null ? true : false}
|
||||
className={`w-full px-4 py-2 mb-4 dark:text-white transition ${selectedBackground == null ? 'dark:bg-zinc-900 bg-zinc-100' : 'bg-blue-500 text-white'} rounded`}
|
||||
onClick={() => { disableTheme(), selectNoBackground() }}>
|
||||
{selectedBackground == null ? 'No Theme' : 'Remove Theme'}
|
||||
</button>
|
||||
|
||||
{BackgroundsBlocked && (
|
||||
<div className="p-4 mb-4 text-red-600 bg-red-100 rounded-md dark:text-red-300 dark:bg-red-500 dark:bg-opacity-20">
|
||||
<h2 className="mb-2 text-lg font-bold">File Storage Blocked</h2>
|
||||
<p>Brave browser is blocking access to IndexedDB. Please disable the "Cross-site cookies blocked" setting in the Shields panel. (or you can just disable brave shields for SEQTA)</p>
|
||||
<img src="https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/brave.jpg" alt="Brave browser logo" className="w-1/2 mt-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative px-1">
|
||||
<h2 className="pb-2 text-lg font-bold">Background Images</h2>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{ isEditMode ? <></> :
|
||||
<div className="relative w-16 h-16 overflow-hidden transition rounded-xl bg-zinc-100 dark:bg-zinc-900">
|
||||
<div className="flex items-center justify-center w-full h-full text-3xl font-bold text-gray-400 transition font-IconFamily hover:text-gray-500">
|
||||
{/* Plus icon */}
|
||||
|
||||
</div>
|
||||
<input type="file" accept='image/*, video/*' onChange={handleFileChange} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
|
||||
</div>}
|
||||
{backgrounds.filter(bg => bg.type === 'image').map(bg => (
|
||||
<div key={bg.id}
|
||||
onClick={() => selectBackground(bg.id)}
|
||||
className={`relative w-16 h-16 cursor-pointer rounded-xl transition ring dark:ring-white ring-zinc-300 ${isEditMode ? 'animate-shake' : ''} ${selectedBackground === bg.id ? 'dark:ring-2 ring-4' : 'ring-0'}`}>
|
||||
{isEditMode && (
|
||||
<div className="absolute top-0 right-0 z-10 flex w-6 h-6 p-2 text-white translate-x-1/2 -translate-y-1/2 bg-red-600 rounded-full place-items-center"
|
||||
onClick={() => deleteBackground(bg.id)}>
|
||||
<div className="w-4 h-0.5 bg-white"></div>
|
||||
</div>
|
||||
)}
|
||||
<img className="object-cover w-full h-full rounded-xl" src={bg.url} alt="swatch" />
|
||||
</div>
|
||||
))}
|
||||
{backgrounds.concat(presetBackgrounds as Background[]).filter(bg => bg.type === 'image' && bg.isPreset && !bg.isDownloaded && !downloadedPresetIds.includes(bg.id)).map(bg => (
|
||||
<button key={bg.id}
|
||||
onClick={() => handlePresetClick(bg)}
|
||||
className={`relative w-16 h-16 transition cursor-pointer rounded-xl duration-300 ${ isEditMode ? 'opacity-0 pointer-events-none hidden' : 'opacity-100'}`}>
|
||||
{bg.isPreset && downloadProgress[bg.id] !== undefined && (
|
||||
<div className="absolute top-0 left-0 z-20 flex items-center justify-center w-full h-full">
|
||||
<svg className="w-full h-full text-zinc-100 dark:text-zinc-700" viewBox="0 0 36 36">
|
||||
<circle stroke="currentColor" fill="none" strokeWidth="4" strokeLinecap="round" cx="18" cy="18" r="10" strokeDasharray={`${calcCircumference(14)} ${calcCircumference(14)}`} strokeDashoffset="0" transform="rotate(-90 18 18)"></circle>
|
||||
<circle stroke="#3B82F6" fill="none" strokeWidth="4" strokeLinecap="round" cx="18" cy="18" r="10" strokeDasharray={`${calcCircumference(14)} ${calcCircumference(14)}`} strokeDashoffset={`${calcCircumference(14) * (1 - (downloadProgress[bg.id] / 100))}`} transform="rotate(-90 18 18)"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className={`relative transition top-0 z-10 flex justify-center w-full h-full text-white rounded-xl group place-items-center ${downloadProgress[bg.id] === undefined ? 'hover:bg-black/20' : ''}`}>
|
||||
<span className="absolute z-10 text-3xl transition opacity-0 font-IconFamily group-hover:opacity-100">
|
||||
{downloadProgress[bg.id] === undefined ? '' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<img
|
||||
className="absolute top-0 object-cover w-full h-full rounded-xl"
|
||||
src={bg.isPreset ? bg.previewUrl : bg.url}
|
||||
alt="swatch" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="py-2 text-lg font-bold">Background Videos</h2>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{ isEditMode ? <></> :
|
||||
<div className="relative w-16 h-16 overflow-hidden transition rounded-xl bg-zinc-100 dark:bg-zinc-900">
|
||||
<div className="flex items-center justify-center w-full h-full text-3xl font-bold text-gray-400 transition font-IconFamily hover:text-gray-500">
|
||||
{/* Plus icon */}
|
||||
|
||||
</div>
|
||||
<input type="file" accept='image/*, video/*' onChange={handleFileChange} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
|
||||
</div>
|
||||
}
|
||||
{backgrounds.filter(bg => bg.type === 'video').map(bg => (
|
||||
<div key={bg.id} onClick={() => selectBackground(bg.id)} className={`relative w-16 h-16 cursor-pointer rounded-xl transition ring dark:ring-white ring-zinc-300 ${isEditMode ? 'animate-shake' : ''} ${selectedBackground === bg.id ? 'dark:ring-2 ring-4' : 'ring-0'}`}>
|
||||
{isEditMode && (
|
||||
<div className="absolute top-0 right-0 z-10 flex w-6 h-6 p-2 text-white translate-x-1/2 -translate-y-1/2 bg-red-600 rounded-full place-items-center"
|
||||
onClick={() => deleteBackground(bg.id)}>
|
||||
<div className="w-4 h-0.5 bg-white"></div>
|
||||
</div>
|
||||
)}
|
||||
<video muted loop autoPlay src={bg.url} className="object-cover w-full h-full rounded-xl" />
|
||||
</div>
|
||||
))}
|
||||
{backgrounds.concat(presetBackgrounds as Background[]).filter(bg => bg.type === 'video' && bg.isPreset && !bg.isDownloaded && !downloadedPresetIds.includes(bg.id)).map(bg => (
|
||||
<div key={bg.id}
|
||||
onClick={() => handlePresetClick(bg)}
|
||||
className={`relative w-16 h-16 transition cursor-pointer rounded-xl duration-300 ${ isEditMode ? 'opacity-0 pointer-events-none hidden' : 'opacity-100'}`}>
|
||||
{bg.isPreset && downloadProgress[bg.id] !== undefined && (
|
||||
<div className="absolute top-0 left-0 z-20 flex items-center justify-center w-full h-full">
|
||||
<svg className="w-full h-full text-zinc-100 dark:text-zinc-700" viewBox="0 0 36 36">
|
||||
<circle stroke="currentColor" fill="none" strokeWidth="4" strokeLinecap="round" cx="18" cy="18" r="10" strokeDasharray={`${calcCircumference(14)} ${calcCircumference(14)}`} strokeDashoffset="0" transform="rotate(-90 18 18)"></circle>
|
||||
<circle stroke="#3B82F6" fill="none" strokeWidth="4" strokeLinecap="round" cx="18" cy="18" r="10" strokeDasharray={`${calcCircumference(14)} ${calcCircumference(14)}`} strokeDashoffset={`${calcCircumference(14) * (1 - (downloadProgress[bg.id] / 100))}`} transform="rotate(-90 18 18)"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className={`relative transition top-0 z-10 flex justify-center w-full h-full text-white rounded-xl group place-items-center ${downloadProgress[bg.id] === undefined ? 'hover:bg-black/20' : ''}`}>
|
||||
<span className="absolute z-10 text-3xl transition opacity-0 font-IconFamily group-hover:opacity-100">
|
||||
{downloadProgress[bg.id] === undefined ? '' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<video muted loop autoPlay src={bg.isPreset ? bg.previewUrl : bg.url} className="absolute top-0 object-cover w-full h-full rounded-xl" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(BackgroundSelector);
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
let { onClick, text } = $props<{ onClick: () => void, text: string, [key: string]: any }>();
|
||||
</script>
|
||||
|
||||
<button onclick={onClick} class='px-4 py-1 text-[0.75rem] dark:bg-[#38373D] bg-[#DDDDDD] dark:text-white rounded-md'>
|
||||
{text}
|
||||
</button>
|
||||
@@ -1,45 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
type CheckboxProps = {
|
||||
value: boolean;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
const Checkbox: React.FC<CheckboxProps> = ({ value, onChange }) => {
|
||||
return (
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="absolute opacity-0"
|
||||
checked={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<div
|
||||
className={`w-5 h-5 rounded-md bg-gradient-to-tr transition-colors duration-200 ${
|
||||
value
|
||||
? 'from-blue-500 to-blue-600'
|
||||
: 'from-gray-300 to-gray-400 dark:from-zinc-700 dark:to-zinc-700/50'
|
||||
}`}
|
||||
/>
|
||||
{value && (
|
||||
<svg
|
||||
className="absolute inset-0 m-auto text-white"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
export default Checkbox;
|
||||
@@ -1,11 +0,0 @@
|
||||
.cm-editor {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
body:not(.dark) .cm-editor {
|
||||
@apply bg-zinc-200;
|
||||
}
|
||||
|
||||
.cm-editor.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { highlightSelectionMatches } from '@codemirror/search';
|
||||
import { indentWithTab, history, defaultKeymap, historyKeymap } from '@codemirror/commands';
|
||||
import { indentOnInput, indentUnit, bracketMatching, foldKeymap, syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language';
|
||||
import { closeBrackets, autocompletion, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
|
||||
import { highlightSpecialChars, drawSelection, rectangularSelection, crosshairCursor, highlightActiveLine, keymap, EditorView, dropCursor } from '@codemirror/view';
|
||||
import { color } from '@uiw/codemirror-extensions-color'
|
||||
import { Compartment } from '@codemirror/state';
|
||||
|
||||
// Theme
|
||||
import { githubLight, githubDark } from '@uiw/codemirror-theme-github';
|
||||
|
||||
// Language
|
||||
import { css } from "@codemirror/lang-css";
|
||||
|
||||
let editor = $state<HTMLDivElement | null>(null)
|
||||
let view: EditorView | null = null;
|
||||
let editorTheme = new Compartment();
|
||||
let { value, onChange } = $props<{value: string, onChange: (value: string) => void}>()
|
||||
|
||||
function createEditorState(initialContents: string) {
|
||||
let extensions = [
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
drawSelection(),
|
||||
indentUnit.of(" "),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
indentOnInput(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
autocompletion(),
|
||||
rectangularSelection(),
|
||||
crosshairCursor(),
|
||||
dropCursor(),
|
||||
highlightActiveLine(),
|
||||
highlightSelectionMatches(),
|
||||
editorTheme.of(githubLight),
|
||||
keymap.of([
|
||||
indentWithTab,
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...foldKeymap,
|
||||
...completionKeymap,
|
||||
]),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onChange(update.state.doc.toString())
|
||||
}
|
||||
}),
|
||||
css(),
|
||||
color,
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
];
|
||||
|
||||
return EditorState.create({
|
||||
doc: initialContents,
|
||||
extensions
|
||||
});
|
||||
}
|
||||
|
||||
function createEditorView(state: EditorState, parent: HTMLElement) {
|
||||
return new EditorView({ state, parent });
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (editor) {
|
||||
const state = createEditorState(value);
|
||||
view = createEditorView(state, editor as HTMLElement);
|
||||
}
|
||||
|
||||
settingsState.subscribe((settings) => {
|
||||
if (view) {
|
||||
view.dispatch({
|
||||
effects: editorTheme.reconfigure(
|
||||
settings.DarkMode ? githubDark : githubLight
|
||||
)
|
||||
})
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (view) {
|
||||
view.destroy();
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg text-[13px] overflow-clip w-full bg-white dark:bg-zinc-900" bind:this={editor}></div>
|
||||
@@ -1,48 +0,0 @@
|
||||
import CodeMirror, { ViewUpdate } from '@uiw/react-codemirror'
|
||||
import { githubDark, githubLight } from '@uiw/codemirror-theme-github'
|
||||
import { color } from '@uiw/codemirror-extensions-color';
|
||||
import { less } from '@codemirror/lang-less'
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import './CodeEditor.css'
|
||||
|
||||
export default function CodeEditor({
|
||||
className = '',
|
||||
height = '100%',
|
||||
value,
|
||||
setValue
|
||||
}: {
|
||||
className?: string;
|
||||
height?: string;
|
||||
value: string;
|
||||
setValue: (value: string) => void;
|
||||
}) {
|
||||
const [darkMode, setDarkMode] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
setDarkMode(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onChange = useCallback((value: string, _: ViewUpdate) => {
|
||||
setValue(value)
|
||||
}, [])
|
||||
|
||||
return(
|
||||
<CodeMirror
|
||||
basicSetup={{
|
||||
allowMultipleSelections: true,
|
||||
lineNumbers: false,
|
||||
foldGutter: false,
|
||||
dropCursor: true,
|
||||
tabSize: 2,
|
||||
}}
|
||||
theme={ darkMode ? githubDark : githubLight }
|
||||
placeholder={"Happy coding!"}
|
||||
className={`rounded-lg text-[13px] ${className}`}
|
||||
value={value}
|
||||
height={height}
|
||||
extensions={[less(), color]}
|
||||
onChange={onChange} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
div:has(> #rbgcp-wrapper) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.dark {
|
||||
#rbgcp-wrapper {
|
||||
div[style="padding-top: 11px; position: relative;"] div {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
div:has(> #rbgcp-solid-btn),
|
||||
div:has(> #rbgcp-advanced-btn),
|
||||
#rbgcp-color-model-btn > div,
|
||||
#rbgcp-gradient-controls-wrap {
|
||||
background-color: #37373b !important;
|
||||
color: white !important;
|
||||
|
||||
svg {
|
||||
circle {
|
||||
fill: white !important;
|
||||
}
|
||||
|
||||
polyline,
|
||||
line,
|
||||
g,
|
||||
path {
|
||||
stroke: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
#rbgcp-radial-btn,
|
||||
#rbgcp-linear-btn {
|
||||
&[style*="background: white;"] {
|
||||
background-color: #28282b !important;
|
||||
}
|
||||
|
||||
svg {
|
||||
path,
|
||||
g,
|
||||
polyline,
|
||||
circle {
|
||||
stroke: white !important;
|
||||
fill: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div:has(> #rbgcp-stop-input) svg {
|
||||
path {
|
||||
stroke: unset !important;
|
||||
fill: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
#rbgcp-comparibles-btn svg path {
|
||||
fill: white !important;
|
||||
}
|
||||
|
||||
> div {
|
||||
color: white !important;
|
||||
|
||||
&[style*="background: white;"] {
|
||||
background: #28282b !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div:has(> #rbgcp-degree-input) {
|
||||
width: 70px !important;
|
||||
}
|
||||
|
||||
#rbgcp-degree-input {
|
||||
width: 50px !important;
|
||||
}
|
||||
|
||||
#rbgcp-degree-input,
|
||||
#rbgcp-stop-input {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
#rbgcp-gradient-controls-wrap > div,
|
||||
#rbgcp-gradient-controls-wrap > div > div:not([role="button"]) {
|
||||
background-color: #37373b !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import ColourPicker from './ColourPicker.tsx';
|
||||
import ReactAdapter from './utils/ReactAdapter.svelte';
|
||||
import { animate, spring } from 'motion';
|
||||
import { delay } from '@/seqta/utils/delay.ts'
|
||||
|
||||
const { hidePicker, standalone = false, savePresets = true, customOnChange = null, customState = null } = $props<{
|
||||
hidePicker?: () => void,
|
||||
standalone?: boolean,
|
||||
savePresets?: boolean,
|
||||
customOnChange?: (color: string) => void,
|
||||
customState?: string
|
||||
}>();
|
||||
|
||||
let background = $state<HTMLDivElement | null>(null);
|
||||
let content = $state<HTMLDivElement | null>(null);
|
||||
|
||||
const closePicker = async () => {
|
||||
if (standalone) return;
|
||||
if (!background || !content) return;
|
||||
|
||||
animate(
|
||||
content,
|
||||
{ scale: [1, 0.4], opacity: [1, 0] },
|
||||
{ easing: spring({ stiffness: 400, damping: 30 }) }
|
||||
);
|
||||
|
||||
animate(
|
||||
background,
|
||||
{ opacity: [1, 0] },
|
||||
{ easing: [0.4, 0, 0.2, 1] }
|
||||
);
|
||||
|
||||
await delay(400);
|
||||
hidePicker();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (standalone) return;
|
||||
if (!background || !content) return;
|
||||
|
||||
animate(
|
||||
background,
|
||||
{ opacity: [0, 1] },
|
||||
{ duration: 0.3, easing: [0.4, 0, 0.2, 1] }
|
||||
);
|
||||
|
||||
animate(
|
||||
content,
|
||||
{ scale: [0.4, 1], opacity: [0, 1] },
|
||||
{ easing: spring({ stiffness: 400, damping: 30 }) }
|
||||
);
|
||||
|
||||
const handleEscapeKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
closePicker();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscapeKey);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscapeKey);
|
||||
};
|
||||
});
|
||||
|
||||
function handleBackgroundClick(event: MouseEvent) {
|
||||
if (event.target === background) {
|
||||
closePicker();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if standalone}
|
||||
<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="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="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>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,108 @@
|
||||
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%)",
|
||||
"linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)",
|
||||
"linear-gradient(40deg, rgba(0, 141, 201, 0.76) 0%, rgba(8, 5, 170, 0.66) 100%)",
|
||||
"linear-gradient(40deg, rgba(0, 201, 20, 0.76) 0%, rgba(4, 160, 105, 0.66) 100%)",
|
||||
"linear-gradient(40deg, rgba(199, 20, 55, 0.76) 0%, rgba(95, 11, 160, 0.66) 100%)",
|
||||
"linear-gradient(40deg, rgba(24, 20, 199, 0.76) 0%, rgba(23, 173, 65, 0.66) 100%)",
|
||||
"radial-gradient(circle, rgba(20, 199, 178, 0.76) 32%, rgba(3, 120, 57, 0.66) 100%)",
|
||||
"radial-gradient(circle, rgba(13, 15, 145, 0.76) 12%, rgba(103, 3, 120, 0.66) 100%)",
|
||||
"linear-gradient(20deg, rgb(230, 21, 21) 0%, rgb(230, 109, 21) 12%, rgb(230, 34, 21) 26%, rgb(230, 21, 21) 39%, rgb(230, 84, 21) 48%, rgb(230, 34, 21) 58%, rgb(230, 96, 21) 69%, rgb(230, 34, 21) 80%, rgb(230, 71, 21) 89%, rgb(230, 21, 21) 100%)",
|
||||
"rgba(114, 1, 170, 0.89)",
|
||||
"rgba(93, 135, 63, 0.89)",
|
||||
"rgba(4, 4, 138, 0.77)",
|
||||
"rgba(21, 20, 20, 0.89)",
|
||||
"linear-gradient(340deg, rgb(205, 74, 82) 18%, rgba(132, 8, 8, 0.89) 46%, rgb(204, 78, 85) 72%)",
|
||||
"radial-gradient(circle, rgb(74, 205, 158) 0%, rgba(8, 72, 132, 0.89) 99%)",
|
||||
"rgba(17, 94, 89, 1)",
|
||||
"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
|
||||
}
|
||||
|
||||
export default function Picker({
|
||||
customOnChange,
|
||||
customState,
|
||||
savePresets = true,
|
||||
}: PickerProps) {
|
||||
const [customThemeColor, setCustomThemeColor] = useState<string | null>()
|
||||
const [presets, setPresets] = useState<string[]>()
|
||||
|
||||
const latestValuesRef = useRef({ customThemeColor, customOnChange, savePresets, presets });
|
||||
|
||||
useEffect(() => {
|
||||
if (customState !== undefined && customState !== null) {
|
||||
setCustomThemeColor(customState)
|
||||
} else {
|
||||
setCustomThemeColor(settingsState.selectedColor ?? null)
|
||||
}
|
||||
|
||||
if (presets === undefined) {
|
||||
const savedPresets = localStorage.getItem("colorPickerPresets")
|
||||
setPresets(savedPresets ? JSON.parse(savedPresets) : defaultPresets)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
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;
|
||||
|
||||
// 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;
|
||||
} else if (existingIndex > -1) {
|
||||
updatedPresets = [
|
||||
customThemeColor,
|
||||
...presets.slice(0, existingIndex),
|
||||
...presets.slice(existingIndex + 1),
|
||||
];
|
||||
} else {
|
||||
updatedPresets = [customThemeColor, ...presets].slice(0, 18);
|
||||
}
|
||||
|
||||
localStorage.setItem("colorPickerPresets", JSON.stringify(updatedPresets));
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (customThemeColor && !customOnChange) {
|
||||
settingsState.selectedColor = customThemeColor
|
||||
}
|
||||
}, [customThemeColor, customOnChange])
|
||||
|
||||
return (
|
||||
<ColorPicker
|
||||
disableDarkMode={true}
|
||||
presets={presets}
|
||||
hideInputs={true}
|
||||
value={customThemeColor ?? ""}
|
||||
onChange={(color: string) => {
|
||||
if (customOnChange) {
|
||||
customOnChange(color)
|
||||
setCustomThemeColor(color)
|
||||
} else {
|
||||
setCustomThemeColor(color)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
const SpinnerIcon = ({ className }: { className: string }) => (
|
||||
<svg className={className} width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>{`.spinner_7mtw{transform-origin:center;animation:spinner_jgYN .6s linear infinite}@keyframes spinner_jgYN{100%{transform:rotate(360deg)}}`}</style>
|
||||
<path stroke="currentColor" fill="currentColor" className="spinner_7mtw" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default SpinnerIcon;
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
|
||||
import { animate as motionAnimate, spring } from 'motion';
|
||||
|
||||
let { initial, animate, exit, transition, children, class: className } = $props<{
|
||||
initial?: any,
|
||||
animate?: any,
|
||||
exit?: any,
|
||||
transition?: any,
|
||||
children?: any,
|
||||
class?: string
|
||||
}>();
|
||||
|
||||
let divElement: HTMLElement;
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const playAnimation = (keyframe: any) => {
|
||||
if (divElement && keyframe) {
|
||||
let animationOptions = transition;
|
||||
let finalKeyframe = { ...keyframe };
|
||||
|
||||
if (finalKeyframe.height === 'auto') {
|
||||
const prevHeight = divElement.style.height;
|
||||
const prevVisibility = divElement.style.visibility;
|
||||
|
||||
divElement.style.height = 'auto';
|
||||
divElement.style.visibility = 'hidden';
|
||||
divElement.style.position = 'absolute';
|
||||
|
||||
const autoHeight = divElement.offsetHeight;
|
||||
|
||||
divElement.style.height = prevHeight;
|
||||
divElement.style.visibility = prevVisibility;
|
||||
divElement.style.position = '';
|
||||
|
||||
finalKeyframe.height = `${autoHeight}px`;
|
||||
}
|
||||
|
||||
if (!transition || transition.type === 'spring') {
|
||||
const springConfig = transition?.config || { stiffness: 250, damping: 25 };
|
||||
animationOptions = {
|
||||
...transition,
|
||||
easing: spring(springConfig)
|
||||
};
|
||||
}
|
||||
|
||||
const animation = motionAnimate(divElement, finalKeyframe, animationOptions);
|
||||
return animation.finished;
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
if (initial) {
|
||||
Object.assign(divElement.style, initial);
|
||||
await playAnimation(animate || {});
|
||||
} else if (animate) {
|
||||
await playAnimation(animate);
|
||||
}
|
||||
|
||||
dispatch('animationend');
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (animate) {
|
||||
playAnimation(animate);
|
||||
}
|
||||
|
||||
dispatch('animationend');
|
||||
});
|
||||
|
||||
onDestroy(async () => {
|
||||
if (exit) {
|
||||
await playAnimation(exit);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={className} bind:this={divElement} style="will-change: transform, opacity;">
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,32 +0,0 @@
|
||||
.dark [class*="rbgcpColorModelDropdown"],
|
||||
.dark [class*="rbgcpControlBtnWrapper"],
|
||||
.dark #rbgcp-gradient-controls-wrap {
|
||||
background-color: #37373b !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.dark [class*="rbgcpControlBtn"][class*="rbgcpControlBtnSelected"] {
|
||||
color: #568cf5 !important;
|
||||
}
|
||||
|
||||
.dark [class*="rbgcpControlBtn"] {
|
||||
color: #CDCEC9 !important;
|
||||
}
|
||||
|
||||
.dark [class*="rbgcpControlBtnSelected"] svg {
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
.dark [class*="rbgcpControlBtnSelected"] {
|
||||
background-color: #28282b !important;
|
||||
}
|
||||
|
||||
.dark [class*="rbgcpComparibleLabel"] {
|
||||
color: #CDCEC9 !important;
|
||||
}
|
||||
|
||||
.dark #rbgcp-stop-input,
|
||||
.dark #rbgcp-degree-input,
|
||||
.dark [class*="rbgcpControlBtnWrapper"] svg {
|
||||
filter: invert();
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import ColorPicker from 'react-best-gradient-color-picker';
|
||||
import { useSettingsContext } from '../SettingsContext';
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import "./Picker.css";
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
|
||||
function Picker() {
|
||||
const { settingsState, setSettingsState, showPicker, setShowPicker } = useSettingsContext();
|
||||
|
||||
const defaultPresets = [
|
||||
'linear-gradient(30deg, rgba(229,209,218,1) 0%, RGBA(235,169,202,1) 46%, rgba(214,155,162,1) 100%)',
|
||||
'linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)',
|
||||
'linear-gradient(40deg, rgba(0, 141, 201, 0.76) 0%, rgba(8, 5, 170, 0.66) 100%)',
|
||||
'linear-gradient(40deg, rgba(0, 201, 20, 0.76) 0%, rgba(4, 160, 105, 0.66) 100%)',
|
||||
'linear-gradient(40deg, rgba(199, 20, 55, 0.76) 0%, rgba(95, 11, 160, 0.66) 100%)',
|
||||
'linear-gradient(40deg, rgba(24, 20, 199, 0.76) 0%, rgba(23, 173, 65, 0.66) 100%)',
|
||||
'radial-gradient(circle, rgba(20, 199, 178, 0.76) 32%, rgba(3, 120, 57, 0.66) 100%)',
|
||||
'radial-gradient(circle, rgba(13, 15, 145, 0.76) 12%, rgba(103, 3, 120, 0.66) 100%)',
|
||||
'linear-gradient(20deg, rgb(230, 21, 21) 0%, rgb(230, 109, 21) 12%, rgb(230, 34, 21) 26%, rgb(230, 21, 21) 39%, rgb(230, 84, 21) 48%, rgb(230, 34, 21) 58%, rgb(230, 96, 21) 69%, rgb(230, 34, 21) 80%, rgb(230, 71, 21) 89%, rgb(230, 21, 21) 100%)',
|
||||
'rgba(114, 1, 170, 0.89)',
|
||||
'rgba(93, 135, 63, 0.89)',
|
||||
'rgba(4, 4, 138, 0.77)',
|
||||
'rgba(21, 20, 20, 0.89)',
|
||||
'linear-gradient(340deg, rgb(205, 74, 82) 18%, rgba(132, 8, 8, 0.89) 46%, rgb(204, 78, 85) 72%)',
|
||||
'radial-gradient(circle, rgb(74, 205, 158) 0%, rgba(8, 72, 132, 0.89) 99%)',
|
||||
'rgba(17, 94, 89, 1)',
|
||||
'rgba(30, 64, 175, 0.89)',
|
||||
'rgba(134, 25, 143, 1)',
|
||||
'rgba(14, 165, 233, 0.9)'
|
||||
];
|
||||
const [presets, setPresets] = useState(() => {
|
||||
const savedPresets = localStorage.getItem('colorPickerPresets');
|
||||
return savedPresets ? JSON.parse(savedPresets) : defaultPresets;
|
||||
});
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data === "popupClosed") {
|
||||
setShowPicker(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Add event listener for 'message' event
|
||||
window.addEventListener("message", handleMessage);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Watch for changes in showPicker and update the presets
|
||||
if (!showPicker) {
|
||||
// Check if the selected color is already in the presets
|
||||
const existingIndex = presets.indexOf(settingsState.customThemeColor);
|
||||
|
||||
let updatedPresets;
|
||||
if (existingIndex > -1) {
|
||||
// If the color exists, move it to the front
|
||||
updatedPresets = [
|
||||
settingsState.customThemeColor,
|
||||
...presets.slice(0, existingIndex),
|
||||
...presets.slice(existingIndex + 1)
|
||||
];
|
||||
} else {
|
||||
// If the color is new, add it to the front and slice the array
|
||||
updatedPresets = [settingsState.customThemeColor, ...presets].slice(0, 18);
|
||||
}
|
||||
|
||||
setPresets(updatedPresets);
|
||||
localStorage.setItem('colorPickerPresets', JSON.stringify(updatedPresets));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showPicker]);
|
||||
|
||||
const colorChange = (color: string) => {
|
||||
setSettingsState({
|
||||
...settingsState,
|
||||
customThemeColor: color,
|
||||
});
|
||||
};
|
||||
|
||||
// Define animation variants
|
||||
const backgroundVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1 },
|
||||
exit: { opacity: 0 }
|
||||
};
|
||||
|
||||
const scaleVariants = {
|
||||
hidden: { scale: 0.3 },
|
||||
visible: { scale: 1 },
|
||||
exit: { scale: 0.4 } // Adding exit animation
|
||||
};
|
||||
|
||||
return (
|
||||
// Apply fade-in animation to background
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate={showPicker ? "visible" : "exit"}
|
||||
exit="exit"
|
||||
variants={backgroundVariants}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={() => setShowPicker(false)}
|
||||
className={`absolute top-0 left-0 z-50 flex justify-center w-full h-full pt-4 bg-black/20 ${!showPicker ? 'pointer-events-none' : ''}`}
|
||||
>
|
||||
<div>
|
||||
{/* Apply springy scale animation */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate={showPicker ? "visible" : "exit"}
|
||||
exit="exit"
|
||||
variants={scaleVariants}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 40 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-auto p-4 bg-white border rounded-lg shadow-lg dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
|
||||
>
|
||||
<ColorPicker disableDarkMode={true} presets={presets} hideInputs={true} value={settingsState.customThemeColor} onChange={colorChange} />
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Picker);
|
||||
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||
|
||||
let { onClick } = $props<{ onClick: () => void }>();
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={onClick}
|
||||
style="background: {$settingsState.selectedColor}"
|
||||
class="w-16 h-8 rounded-md"
|
||||
></button>
|
||||
@@ -1,20 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import { useSettingsContext } from '../SettingsContext';
|
||||
|
||||
const PickerSwatch = () => {
|
||||
const { setShowPicker, settingsState } = useSettingsContext();
|
||||
|
||||
const enablePicker = () => {
|
||||
setShowPicker(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={enablePicker}
|
||||
style={{ background: settingsState.customThemeColor }}
|
||||
className="w-16 h-8 rounded-md"
|
||||
></button>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(PickerSwatch);
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
let { state, onChange, options } = $props<{
|
||||
state: string,
|
||||
onChange: (newState: string) => void,
|
||||
options: Array<{ value: string, label: string }>
|
||||
}>();
|
||||
|
||||
let select: HTMLSelectElement;
|
||||
</script>
|
||||
|
||||
<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>
|
||||
@@ -1,7 +0,0 @@
|
||||
export default function Select({ state, onChange, options }: { state: string, onChange: (value: string) => void, options: { value: string, label: string }[] }) {
|
||||
return (
|
||||
<select className='px-4 py-1.5 text-[0.75rem] dark:bg-[#38373D] bg-[#DDDDDD] dark:text-white focus:border-none rounded-md mt-2 block w-full border-0 pl-3 pr-10 text-gray-900 focus:outline-none sm:text-sm sm:leading-6' value={state} onChange={(e) => onChange(e.target.value)}>
|
||||
{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
let { width, height} = $props<{width?: string, height?: string}>()
|
||||
</script>
|
||||
|
||||
<div style="width: {width ? width : '100%'}; height: {height ? height : '100%'}; background: #e0e0e0;" class="animate-pulse"></div>
|
||||
@@ -1,19 +0,0 @@
|
||||
/* Slider Thumb */
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.3);
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.3);
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
let { state, onChange } = $props<{ state: number, onChange: (value: number) => void }>();
|
||||
let percentage = $derived((state / 100) * 100);
|
||||
</script>
|
||||
|
||||
<div class="relative w-full max-w-lg mx-auto">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
bind:value={state}
|
||||
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 dark:bg-[#38373D] bg-[#DDDDDD] slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.3);
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.3);
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,25 +0,0 @@
|
||||
import { memo } from "react";
|
||||
import "./Slider.css";
|
||||
|
||||
interface SliderProps {
|
||||
state: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
const Slider: React.FC<SliderProps> = ({ state, onChange }) => {
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-lg py-8 mx-auto">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={state}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-full h-1 rounded-full appearance-none cursor-pointer slider dark:bg-[#38373D] bg-[#DDDDDD]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Slider);
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
let { size = 'md', color = 'currentColor' } = $props();
|
||||
|
||||
const sizeMap = {
|
||||
sm: '1rem',
|
||||
md: '2rem',
|
||||
lg: '3rem',
|
||||
};
|
||||
|
||||
let dimensions = $derived(sizeMap[size as keyof typeof sizeMap] || size);
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class="animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
width={dimensions}
|
||||
height={dimensions}
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke={color}
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill={color}
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { animate, spring } from 'motion';
|
||||
import { standalone } from '../utils/standalone.svelte'
|
||||
|
||||
let { state, onChange } = $props<{ state: boolean, onChange: (newState: boolean) => void }>();
|
||||
let handle: HTMLElement | null = null;
|
||||
|
||||
const springParams = {
|
||||
stiffness: 600,
|
||||
damping: 30,
|
||||
};
|
||||
|
||||
const animateSwitch = (enabled: boolean) => {
|
||||
if (!handle) return;
|
||||
animate(
|
||||
handle,
|
||||
{
|
||||
x: enabled ? (standalone.standalone ? 24 : 20) : 0,
|
||||
},
|
||||
{
|
||||
easing: spring(springParams),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Trigger animation whenever state changes
|
||||
$effect(() => animateSwitch(state));
|
||||
</script>
|
||||
|
||||
<div
|
||||
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"
|
||||
aria-checked={state}
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
bind:this={handle}
|
||||
class="w-6 h-6 bg-white dark:bg-[#FEFEFE] rounded-full drop-shadow-md"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.switch[data-ison="true"] {
|
||||
background-color: #30D259;
|
||||
}
|
||||
</style>
|
||||
@@ -1,35 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import "./Switch.css";
|
||||
import type { SwitchProps } from "../types/SwitchProps";
|
||||
import { memo } from "react";
|
||||
|
||||
function Switch(props: SwitchProps) {
|
||||
const toggleSwitch = () => {
|
||||
const newIsOn = !props.state;
|
||||
props.onChange(newIsOn);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-14 p-1 cursor-pointer rounded-full dark:bg-[#38373D] bg-[#DDDDDD] switch"
|
||||
data-ison={props.state}
|
||||
onClick={toggleSwitch}
|
||||
>
|
||||
<motion.div
|
||||
|
||||
className="w-6 h-6 bg-white dark:bg-[#FEFEFE] rounded-full drop-shadow-md"
|
||||
initial={{ x: props.state ? 0 : 0 }}
|
||||
animate={{ x: props.state ? 24 : 0 }}
|
||||
transition={spring}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const spring = {
|
||||
type: "spring",
|
||||
stiffness: 700,
|
||||
damping: 30
|
||||
};
|
||||
|
||||
export default memo(Switch);
|
||||
@@ -0,0 +1,3 @@
|
||||
.tab-width {
|
||||
width: var(--tab-width);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import MotionDiv from './MotionDiv.svelte';
|
||||
import './TabbedContainer.css';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { tabs } = $props<{ tabs: { title: string, Content: any, props?: any }[] }>();
|
||||
let activeTab = $state(0);
|
||||
let hoveredTab = $state<number | null>(null);
|
||||
let containerRef: HTMLElement | null = null;
|
||||
let tabWidth = $state(0);
|
||||
|
||||
const springTransition = { type: 'spring', stiffness: 250, damping: 25 };
|
||||
|
||||
const updateTabWidth = () => {
|
||||
tabWidth = tabs.length > 0 ? 100 / tabs.length : 0;
|
||||
if (!containerRef) return;
|
||||
containerRef.style.setProperty('--tab-width', `${tabWidth}%`);
|
||||
};
|
||||
|
||||
const calcXPos = (index: number | null) => {
|
||||
if (containerRef) {
|
||||
return tabWidth * (index !== null ? index : activeTab) * containerRef.getBoundingClientRect().width / 100;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
calcXPos(hoveredTab);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
updateTabWidth();
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data === "popupClosed") {
|
||||
activeTab = 0;
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", handleMessage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<div bind:this={containerRef} class="top-0 z-10 text-[0.875rem] pb-0.5 mx-4 tab-width-container">
|
||||
<div class="relative flex">
|
||||
<MotionDiv
|
||||
class="absolute top-0 left-0 z-0 h-full bg-[#DDDDDD] dark:bg-[#38373D] rounded-full opacity-40 tab-width"
|
||||
animate={{ x: calcXPos(hoveredTab) }}
|
||||
transition={springTransition}
|
||||
/>
|
||||
{#each tabs as { title }, index}
|
||||
<button
|
||||
class="relative z-10 flex-1 px-4 py-2 focus-visible:outline-none"
|
||||
onclick={() => activeTab = index}
|
||||
onmouseenter={() => hoveredTab = index}
|
||||
onmouseleave={() => hoveredTab = null}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-full px-4 overflow-hidden">
|
||||
<MotionDiv
|
||||
class="h-full"
|
||||
animate={{ x: `${-activeTab * 100}%` }}
|
||||
transition={springTransition}
|
||||
>
|
||||
<div class="flex">
|
||||
{#each tabs as { Content, props }, index}
|
||||
<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}
|
||||
</div>
|
||||
</MotionDiv>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,99 +0,0 @@
|
||||
import React, { memo, useEffect, useRef, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { TabbedContainerProps } from '../types/TabbedContainerProps';
|
||||
import { useSettingsContext } from '../SettingsContext';
|
||||
|
||||
const TabbedContainer: React.FC<TabbedContainerProps> = ({ tabs }) => {
|
||||
const { settingsState } = useSettingsContext();
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [hoveredTab, setHoveredTab] = useState<number | null>(null);
|
||||
const [tabWidth, setTabWidth] = useState(0);
|
||||
const [position, setPosition] = useState(0);
|
||||
const positionRef = useRef(position);
|
||||
|
||||
// Function to handle message
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data === "popupClosed") {
|
||||
setActiveTab(0);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Add event listener for 'message' event
|
||||
window.addEventListener("message", handleMessage);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const newPosition = -activeTab * 100;
|
||||
setPosition(newPosition);
|
||||
positionRef.current = newPosition;
|
||||
}, [activeTab]);
|
||||
|
||||
const containerRef = useRef(null);
|
||||
|
||||
const springTransition = settingsState.animations ? { type: 'spring', stiffness: 250, damping: 25 } : { duration: 0 };
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
// @ts-expect-error for some reason its giving an error in TS but it works...
|
||||
const width = containerRef.current.getBoundingClientRect().width;
|
||||
setTabWidth(width / tabs.length);
|
||||
}
|
||||
}, [tabs.length]);
|
||||
|
||||
const calcXPos = (index: number | null) => {
|
||||
if (index !== null) {
|
||||
return tabWidth * index;
|
||||
}
|
||||
return tabWidth * activeTab;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} className="top-0 z-10 text-[0.875rem] pb-0.5 mx-4">
|
||||
<div className="relative flex">
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 z-0 h-full bg-[#DDDDDD] dark:bg-[#38373D] rounded-full opacity-40"
|
||||
style={{ width: `${tabWidth}px` }}
|
||||
initial={false}
|
||||
animate={{ x: calcXPos(hoveredTab) }}
|
||||
transition={springTransition}
|
||||
/>
|
||||
{tabs.map((tab, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="relative z-10 flex-1 px-4 py-2"
|
||||
onClick={() => setActiveTab(index)}
|
||||
onMouseEnter={() => setHoveredTab(index)}
|
||||
onMouseLeave={() => setHoveredTab(null)}
|
||||
>
|
||||
{tab.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full px-4 overflow-x-clip">
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ x: `${position}%` }}
|
||||
transition={springTransition}
|
||||
className='flex'
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<div key={index} className={`absolute h-[100vh] focus-visible:outline-none overflow-y-scroll w-full pb-40 ${ settingsState.animations ? 'transition-opacity duration-300' : ''} ${activeTab === index ? 'opacity-100' : 'opacity-0'}`}
|
||||
style={{left: `${index * 100}%`}}>
|
||||
{tab.content}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(TabbedContainer);
|
||||
@@ -1,104 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { CustomTheme, DownloadedTheme } from '../types/CustomThemes';
|
||||
import browser from 'webextension-polyfill';
|
||||
import { ArrowUpOnSquareIcon, PencilIcon } from '@heroicons/react/24/outline';
|
||||
import { sendThemeUpdate, setTheme } from '../hooks/ThemeManagment';
|
||||
import { DeleteDownloadedTheme } from '../pages/Store';
|
||||
|
||||
type ThemeCoverProps = {
|
||||
theme: Omit<CustomTheme, 'CustomImages'> | DownloadedTheme;
|
||||
isSelected: boolean;
|
||||
isEditMode: boolean;
|
||||
downloaded?: boolean;
|
||||
onThemeSelect: (themeId: string) => void;
|
||||
onThemeDelete: (themeId: string) => void;
|
||||
};
|
||||
|
||||
export const ThemeCover: React.FC<ThemeCoverProps> = React.memo(({
|
||||
theme,
|
||||
downloaded,
|
||||
isSelected,
|
||||
isEditMode,
|
||||
onThemeSelect,
|
||||
onThemeDelete,
|
||||
}) => {
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
const handleThemeClick = async () => {
|
||||
if (isEditMode) return;
|
||||
if (downloaded) {
|
||||
await sendThemeUpdate(theme as DownloadedTheme, true)
|
||||
DeleteDownloadedTheme(theme.id);
|
||||
setTheme(theme.id);
|
||||
} else {
|
||||
onThemeSelect(theme.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onThemeDelete(theme.id);
|
||||
};
|
||||
|
||||
const handleShareClick = (event: React.MouseEvent) => {
|
||||
event?.preventDefault();
|
||||
setUploading(true);
|
||||
browser.runtime.sendMessage({ type: 'currentTab', info: 'ShareTheme', body: { themeID: theme.id } }).then(() => {
|
||||
setUploading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`relative group w-full aspect-theme flex justify-center items-center rounded-xl transition ring dark:ring-white ring-zinc-300 ${
|
||||
isSelected ? 'dark:ring-2 ring-4' : 'ring-0'
|
||||
}`}
|
||||
onClick={handleThemeClick}
|
||||
>
|
||||
{isEditMode && (
|
||||
<div
|
||||
className="absolute z-20 flex w-6 h-6 p-2 text-white transition-all rounded-full opacity-0 top-1 right-2 dark:bg-red-600 place-items-center group-hover:opacity-100 group-hover:top-2"
|
||||
onClick={handleDeleteClick}
|
||||
>
|
||||
<div className="w-4 h-0.5 bg-white"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ ( !isEditMode ) && !downloaded /* && !theme.webURL */ ? (
|
||||
<>
|
||||
<div
|
||||
className="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full delay-[20ms] opacity-0 top-1 right-2 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-[1.25rem]"
|
||||
onClick={(event) => { event?.preventDefault(), browser.runtime.sendMessage({ type: 'currentTab', info: 'OpenThemeCreator', body: { themeID: theme.id } }) }}
|
||||
>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full opacity-0 top-1 right-12 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-[1.25rem]"
|
||||
onClick={handleShareClick}
|
||||
>
|
||||
{uploading ? <LoadingSpinner size={16} /> : <ArrowUpOnSquareIcon className="w-4 h-4" />}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="relative top-0 z-10 flex justify-center w-full h-full overflow-hidden transition dark:text-white rounded-xl group place-items-center bg-zinc-100 dark:bg-zinc-900">
|
||||
{theme.coverImage &&
|
||||
<img
|
||||
src={(typeof theme.coverImage) == 'string' ? theme.coverImage as string : URL.createObjectURL(theme.coverImage as Blob)}
|
||||
alt={theme.name}
|
||||
className="absolute inset-0 z-0 object-cover w-full h-full pointer-events-none"
|
||||
/>
|
||||
}
|
||||
{
|
||||
theme.hideThemeName ? <></> :
|
||||
<div className={`z-10 ${theme.coverImage && 'text-white'}`}>{theme.name}</div>
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
const LoadingSpinner = ({ size }: { size: number }) => {
|
||||
return <div style={{ width: `${size}px`, height: `${size}px` }} className={`animate-spin rounded-full border-2 border-white border-t-2 border-t-transparent`}></div>;
|
||||
};
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
import React, { forwardRef, ForwardRefExoticComponent, RefAttributes, useCallback, useEffect, useImperativeHandle, useState } from 'react';
|
||||
import { deleteTheme, disableTheme, getDownloadedThemes, listThemes, sendThemeUpdate, setTheme } from '../hooks/ThemeManagment';
|
||||
import { DeleteDownloadedTheme } from '../pages/Store';
|
||||
import { ThemeCover } from './ThemeCover';
|
||||
import browser from 'webextension-polyfill';
|
||||
import { CustomTheme, DownloadedTheme } from '../types/CustomThemes';
|
||||
import { useSettingsContext } from '../SettingsContext';
|
||||
import { SettingsState } from '../types/AppProps';
|
||||
import { InstallTheme } from '../../seqta/ui/themes/downloadTheme';
|
||||
import SpinnerIcon from './LoadingSpinner';
|
||||
import { toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import useVisibility from './useVisibility';
|
||||
import { debounce } from 'lodash';
|
||||
import { Mutex } from '../../seqta/utils/mutex';
|
||||
|
||||
interface ThemeSelectorProps {
|
||||
isEditMode: boolean;
|
||||
ref: React.Ref<any>;
|
||||
}
|
||||
|
||||
const ThemeSelector: ForwardRefExoticComponent<Omit<ThemeSelectorProps, "ref"> & RefAttributes<any>> = forwardRef(({ isEditMode = false }, ref) => {
|
||||
const [themes, setThemes] = useState<Omit<CustomTheme, 'CustomImages'>[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
const [tempTheme, setTempTheme] = useState<any>(null);
|
||||
const { settingsState, setSettingsState } = useSettingsContext();
|
||||
const [elementRef, isVisible] = useVisibility({
|
||||
root: null, // Use the viewport as the root
|
||||
rootMargin: '0px',
|
||||
threshold: 0.1, // 10% of the element needs to be visible
|
||||
});
|
||||
|
||||
const mutex = new Mutex();
|
||||
|
||||
const setSelectedTheme = (themeId: string) => {
|
||||
setSettingsState((prevState: SettingsState) => ({
|
||||
...prevState,
|
||||
selectedTheme: themeId,
|
||||
}));
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
disableTheme: async () => {
|
||||
await disableTheme();
|
||||
setSelectedTheme('');
|
||||
}
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
const handleThemeChange = async () => {
|
||||
//await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
fetchThemes();
|
||||
};
|
||||
|
||||
window.addEventListener('message', (message) => {
|
||||
if (message.data.type === 'themeChanged') {
|
||||
handleThemeChange();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', (message) => {
|
||||
if (message.data.type === 'themeChanged') {
|
||||
handleThemeChange();
|
||||
}
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let intervalId: any;
|
||||
if (isVisible) {
|
||||
intervalId = setInterval(fetchThemes, 2000);
|
||||
} else {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [isVisible]);
|
||||
|
||||
const fetchThemes = async () => {
|
||||
try {
|
||||
const { themes, selectedTheme } = await listThemes();
|
||||
let tempDownloadedThemes = await getDownloadedThemes();
|
||||
|
||||
setThemes(themes);
|
||||
setSelectedTheme(selectedTheme ? selectedTheme : '');
|
||||
|
||||
const matchingThemes = themes.filter(theme =>
|
||||
tempDownloadedThemes.some(downloadedTheme => downloadedTheme.id === theme.id)
|
||||
);
|
||||
|
||||
if (matchingThemes.length > 0) {
|
||||
matchingThemes.forEach((theme) => {
|
||||
DeleteDownloadedTheme(theme.id);
|
||||
tempDownloadedThemes = tempDownloadedThemes.filter(downloadedTheme => downloadedTheme.id !== theme.id);
|
||||
})
|
||||
}
|
||||
|
||||
tempDownloadedThemes.forEach(async (theme) => {
|
||||
await sendThemeUpdate(theme as DownloadedTheme, true, false)
|
||||
DeleteDownloadedTheme(theme.id);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching themes:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchThemes();
|
||||
}, []);
|
||||
|
||||
const handleThemeSelect = useCallback(
|
||||
async (themeId: string) => {
|
||||
const unlock = await mutex.lock();
|
||||
try {
|
||||
if (themeId === settingsState.selectedTheme) {
|
||||
await disableTheme();
|
||||
setSelectedTheme('');
|
||||
} else {
|
||||
const selectedTheme = themes.find((theme) => theme.id === themeId);
|
||||
if (selectedTheme) {
|
||||
await setTheme(selectedTheme.id);
|
||||
setSelectedTheme(themeId);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
},
|
||||
[settingsState.selectedTheme, themes]
|
||||
);
|
||||
|
||||
const handleThemeSelectDebounced = useCallback(
|
||||
debounce(handleThemeSelect, 100),
|
||||
[handleThemeSelect]
|
||||
);
|
||||
|
||||
const handleThemeDelete = useCallback(
|
||||
async (themeId: string) => {
|
||||
try {
|
||||
await deleteTheme(themeId);
|
||||
setThemes((prevThemes) => prevThemes.filter((theme) => theme.id !== themeId));
|
||||
if (themeId === settingsState.selectedTheme) {
|
||||
setSelectedTheme('')
|
||||
disableTheme();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting theme:', error);
|
||||
}
|
||||
},
|
||||
[settingsState.selectedTheme]
|
||||
);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file: File = e.dataTransfer.files[0];
|
||||
const reader: FileReader = new FileReader();
|
||||
|
||||
reader.onload = async (event: ProgressEvent<FileReader>) => {
|
||||
try {
|
||||
const result: any = JSON.parse(event.target!.result as string);
|
||||
try {
|
||||
setTempTheme(result);
|
||||
await InstallTheme(result);
|
||||
await fetchThemes();
|
||||
setTempTheme(null);
|
||||
} catch(error) {
|
||||
toast.error('Invalid file type. Please upload a valid theme file.');
|
||||
setTempTheme(null);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Error parsing file. Please upload a valid JSON theme file.');
|
||||
setTempTheme(null);
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className='text-center'>Loading themes...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={elementRef}
|
||||
className={`my-3 w-full`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className={`${isDragging ? 'opacity-100' : 'opacity-0'} transition pointer-events-none absolute w-full p-2 z-50`}>
|
||||
<div className='sticky w-full h-64 bg-white shadow-xl dark:bg-zinc-900 top-5 dark:text-white rounded-xl outline-dashed outline-4 outline-zinc-200 dark:outline-zinc-700'>
|
||||
<div className='flex items-center justify-center h-full'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<svg height="48" width="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="currentColor">
|
||||
<path d="M44,31a1,1,0,0,0-1,1v8a3,3,0,0,1-3,3H8a3,3,0,0,1-3-3V32a1,1,0,0,0-2,0v8a5.006,5.006,0,0,0,5,5H40a5.006,5.006,0,0,0,5-5V32A1,1,0,0,0,44,31Z" fill="currentColor"/>
|
||||
<path d="M23.2,33.6a1,1,0,0,0,1.6,0l9-12A1,1,0,0,0,33,20H26V5a2,2,0,0,0-4,0V20H15a1,1,0,0,0-.8,1.6Z" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
<span className='text-lg'>Import Theme</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="pb-2 text-lg font-bold">Themes</h2>
|
||||
<div className="flex flex-col gap-2 px-1">
|
||||
|
||||
{themes.map((theme) => (
|
||||
<ThemeCover
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={theme.id === settingsState.selectedTheme}
|
||||
isEditMode={isEditMode}
|
||||
onThemeSelect={handleThemeSelectDebounced}
|
||||
onThemeDelete={handleThemeDelete}
|
||||
/>
|
||||
))}
|
||||
|
||||
{tempTheme && (
|
||||
<div className="flex justify-center w-full bg-gray-200 rounded-xl dark:bg-zinc-700/50 place-items-center aspect-theme animate-pulse">
|
||||
<SpinnerIcon className='opacity-50' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ themes.length > 0 && <div
|
||||
id="divider"
|
||||
className="w-full h-[1px] my-2 bg-zinc-100 dark:bg-zinc-600"
|
||||
></div>}
|
||||
|
||||
<button
|
||||
onClick={() => browser.tabs.create({ url: browser.runtime.getURL('src/interface/index.html#store')})}
|
||||
className="flex items-center justify-center w-full transition aspect-theme rounded-xl bg-zinc-100 dark:bg-zinc-900 dark:text-white"
|
||||
>
|
||||
<span className="text-xl font-IconFamily">{'\uecc5'}</span>
|
||||
<span className="ml-2">Theme Store</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => browser.runtime.sendMessage({ type: 'currentTab', info: 'OpenThemeCreator' })}
|
||||
className="flex items-center justify-center w-full transition aspect-theme rounded-xl bg-zinc-100 dark:bg-zinc-900 dark:text-white"
|
||||
>
|
||||
<span className="text-xl font-IconFamily">{'\uec60'}</span>
|
||||
<span className="ml-2">Create your own</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ThemeSelector;
|
||||
@@ -0,0 +1,322 @@
|
||||
<script lang="ts">
|
||||
import { hasEnoughStorageSpace, isIndexedDBSupported, writeData, openDatabase, readAllData, deleteData } from '@/interface/hooks/BackgroundDataLoader';
|
||||
import { setTheme } from '@/seqta/ui/themes/setTheme';
|
||||
import Spinner from '../Spinner.svelte';
|
||||
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||
import Fuse from 'fuse.js';
|
||||
import { backgroundUpdates } from '@/interface/hooks/BackgroundUpdates'
|
||||
|
||||
type Background = { id: string; category: string; type: string; lowResUrl: string; highResUrl: string; name: string; description: string; featured?: boolean };
|
||||
let { searchTerm } = $props<{ searchTerm: string }>();
|
||||
|
||||
// Existing states
|
||||
let backgrounds = $state<Background[]>([]);
|
||||
let selectedCategory = $state<string>('All');
|
||||
let error = $state<string | null>(null);
|
||||
let selectedBackground = $state<string | null>(null);
|
||||
let isLoading = $state<boolean>(true);
|
||||
let savedBackgrounds = $state<string[]>([]);
|
||||
let installingBackgrounds = $state<Set<string>>(new Set());
|
||||
let debugInfo = $state<string>('');
|
||||
|
||||
// New state variables
|
||||
let activeTab = $state<'all' | 'installed' | 'photos' | 'videos'>('all');
|
||||
let sortBy = $state<'newest' | 'popular' | 'name'>('newest');
|
||||
|
||||
// Add Fuse.js options
|
||||
const fuseOptions = {
|
||||
keys: ['name', 'description'],
|
||||
threshold: 0.4,
|
||||
ignoreLocation: true
|
||||
};
|
||||
let fuse: Fuse<Background>;
|
||||
|
||||
// Existing functions
|
||||
const loadStore = async () => {
|
||||
try {
|
||||
debugInfo = 'Fetching backgrounds...';
|
||||
const response = await fetch('https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/backgrounds.json');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
backgrounds = data.backgrounds;
|
||||
fuse = new Fuse(backgrounds, fuseOptions);
|
||||
debugInfo = `Loaded ${backgrounds.length} backgrounds`;
|
||||
await loadSavedBackgrounds();
|
||||
} catch (e) {
|
||||
error = 'Failed to load background store';
|
||||
debugInfo = `Error: ${e instanceof Error ? e.message : 'Unknown error'}`;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
async function loadSavedBackgrounds(): Promise<void> {
|
||||
try {
|
||||
if (!isIndexedDBSupported()) {
|
||||
throw new Error("Your browser doesn't support IndexedDB.");
|
||||
}
|
||||
await openDatabase();
|
||||
const data = await readAllData();
|
||||
savedBackgrounds = data.map(item => item.id);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Unknown error occurred';
|
||||
}
|
||||
}
|
||||
|
||||
// Load data on mount
|
||||
loadStore();
|
||||
|
||||
// Derived states
|
||||
let filteredBackgrounds = $derived((() => {
|
||||
let filtered = backgrounds;
|
||||
|
||||
// Use Fuse.js search if there's a search term
|
||||
if (searchTerm.trim()) {
|
||||
// @ts-ignore
|
||||
if (fuse) {
|
||||
filtered = fuse.search(searchTerm).map((result: any) => result.item) ?? [];
|
||||
} else {
|
||||
filtered = backgrounds.filter(bg => bg.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
}
|
||||
}
|
||||
|
||||
// Apply category filtering
|
||||
filtered = filtered.filter((bg: Background) => {
|
||||
return selectedCategory === 'All'
|
||||
? true
|
||||
: selectedCategory === 'Featured'
|
||||
? bg.featured
|
||||
: bg.category === selectedCategory;
|
||||
});
|
||||
|
||||
// Apply sorting
|
||||
filtered.sort((a: Background, b: Background) => {
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'newest':
|
||||
return -1;
|
||||
case 'popular':
|
||||
return -1;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
})());
|
||||
|
||||
let categories = $derived([...new Set(backgrounds.map(bg => bg.category))]);
|
||||
|
||||
// Background management functions
|
||||
async function saveBackgroundFromUrl(url: string, id: string, fileType: string): Promise<void> {
|
||||
try {
|
||||
if (!isIndexedDBSupported()) {
|
||||
throw new Error("Your browser doesn't support IndexedDB.");
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
const hasSpace = await hasEnoughStorageSpace(blob.size);
|
||||
|
||||
if (!hasSpace) {
|
||||
throw new Error("Not enough storage space.");
|
||||
}
|
||||
|
||||
await writeData(id, fileType, blob);
|
||||
savedBackgrounds = [...savedBackgrounds, id];
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Unknown error occurred';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBackground(fileId: string): Promise<void> {
|
||||
installingBackgrounds = new Set(installingBackgrounds).add(fileId);
|
||||
try {
|
||||
await deleteData(fileId);
|
||||
savedBackgrounds = savedBackgrounds.filter(id => id !== fileId);
|
||||
|
||||
if (selectedBackground === fileId) {
|
||||
selectNoBackground();
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? `Failed to delete background: ${e.message}` : 'Unknown error occurred';
|
||||
} finally {
|
||||
installingBackgrounds = new Set(installingBackgrounds);
|
||||
installingBackgrounds.delete(fileId);
|
||||
}
|
||||
}
|
||||
|
||||
async function installBackground(background: Background) {
|
||||
installingBackgrounds = new Set(installingBackgrounds).add(background.id);
|
||||
try {
|
||||
await saveBackgroundFromUrl(background.highResUrl, background.id, background.type);
|
||||
backgroundUpdates.triggerUpdate();
|
||||
} finally {
|
||||
installingBackgrounds = new Set(installingBackgrounds);
|
||||
installingBackgrounds.delete(background.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleBackgroundInstallation(background: Background) {
|
||||
if (savedBackgrounds.includes(background.id)) {
|
||||
await deleteBackground(background.id);
|
||||
} else {
|
||||
await installBackground(background);
|
||||
}
|
||||
}
|
||||
|
||||
function selectNoBackground() {
|
||||
selectedBackground = null;
|
||||
setTheme('');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full">
|
||||
<!-- Sidebar -->
|
||||
<div class="w-64 h-full p-4 border-r border-zinc-200 dark:border-zinc-700">
|
||||
<div class="mb-8">
|
||||
<h2 class="mb-4 text-lg font-semibold">Categories</h2>
|
||||
<nav class="space-y-2">
|
||||
<button
|
||||
class={`w-full px-4 py-2 text-left bg-transparent rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800 transition ${selectedCategory === 'All' ? 'bg-blue-100 dark:bg-zinc-800' : ''}`}
|
||||
onclick={() => selectedCategory = 'All'}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
class={`w-full px-4 py-2 text-left bg-transparent rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800 transition ${selectedCategory === 'Featured' ? 'bg-blue-100 dark:bg-zinc-800' : ''}`}
|
||||
onclick={() => selectedCategory = 'Featured'}
|
||||
>
|
||||
Featured
|
||||
</button>
|
||||
|
||||
<div class="my-2 border-b border-zinc-200 dark:border-zinc-700"></div>
|
||||
|
||||
{#each categories as category}
|
||||
<button
|
||||
class={`w-full px-4 py-2 text-left bg-transparent rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800 transition ${selectedCategory === category ? 'bg-blue-100 dark:bg-zinc-800' : ''}`}
|
||||
onclick={() => selectedCategory = category}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
<!-- Header -->
|
||||
<div class="sticky top-0 z-10 p-4 border-b bg-[#F1F1F3] dark:bg-zinc-900 dark:border-zinc-700">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-bold">Explore Backgrounds {searchTerm ? `- "${searchTerm}"` : ''}</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<select
|
||||
bind:value={sortBy}
|
||||
class="p-2 border rounded-lg border-zinc-200 dark:border-zinc-700 dark:bg-zinc-800"
|
||||
>
|
||||
<option value="newest">Newest</option>
|
||||
<option value="name">Name</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-2">
|
||||
{#each ['All', 'Installed', 'Photos', 'Videos'] as tab}
|
||||
<button
|
||||
class={`px-4 py-2 text-sm font-medium transition-colors rounded-full
|
||||
${activeTab === tab.toLowerCase() ? 'bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700' :
|
||||
'bg-zinc-100 dark:bg-transparent dark:outline dark:outline-1 dark:outline-zinc-700 hover:bg-zinc-200 dark:hover:bg-zinc-700/20'}`}
|
||||
onclick={() => activeTab = tab.toLowerCase() as typeof activeTab}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Background Grid -->
|
||||
<div class="p-4">
|
||||
{#if isLoading}
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each Array(9) as _}
|
||||
<div class="relative overflow-hidden rounded-lg animate-pulse">
|
||||
<!-- Image placeholder -->
|
||||
<div class="w-full h-48 bg-zinc-200 dark:bg-zinc-800"></div>
|
||||
<!-- Gradient overlay -->
|
||||
<div class="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-zinc-300 dark:from-zinc-700 to-transparent">
|
||||
<!-- Title placeholder -->
|
||||
<div class="absolute bottom-2 left-2 right-2">
|
||||
<div class="w-2/3 h-4 rounded-full bg-zinc-200 dark:bg-zinc-800"></div>
|
||||
<div class="w-1/2 h-3 mt-2 rounded-full bg-zinc-200 dark:bg-zinc-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="p-4 text-red-500 bg-red-100 rounded-lg">
|
||||
Error: {error}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each filteredBackgrounds.filter((bg: Background) => {
|
||||
if (activeTab === 'installed') return savedBackgrounds.includes(bg.id);
|
||||
if (activeTab === 'photos') return bg.type === 'image';
|
||||
if (activeTab === 'videos') return bg.type !== 'image';
|
||||
return true;
|
||||
}) as background (background.id)}
|
||||
<div
|
||||
class="relative overflow-hidden rounded-lg shadow-lg cursor-pointer group"
|
||||
onclick={() => toggleBackgroundInstallation(background)}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
toggleBackgroundInstallation(background);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{#if background.type === 'image'}
|
||||
<img src={background.lowResUrl} alt={background.name} class="object-cover w-full h-48 transition-all duration-300 group-hover:scale-105" />
|
||||
{:else}
|
||||
<video src={background.lowResUrl} class="object-cover w-full h-48" muted loop autoplay></video>
|
||||
{/if}
|
||||
<div class="absolute inset-0 flex items-center justify-center transition-opacity duration-300 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100">
|
||||
{#if installingBackgrounds.has(background.id)}
|
||||
<Spinner />
|
||||
{:else if savedBackgrounds.includes(background.id)}
|
||||
<span class="flex items-center text-white">
|
||||
<span class="mr-2 text-2xl not-italic font-IconFamily" aria-hidden="true"></span>
|
||||
<span class="text-sm font-semibold">Remove</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="flex items-center text-white">
|
||||
<span class="mr-2 text-2xl not-italic font-IconFamily" aria-hidden="true"></span>
|
||||
<span class="text-sm font-semibold">Install</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if settingsState.devMode}
|
||||
<div class="p-4 mt-8 rounded bg-zinc-100 dark:bg-zinc-800">
|
||||
<h3 class="mb-2 font-bold">Debug Info:</h3>
|
||||
<p>{debugInfo}</p>
|
||||
<p>Total backgrounds: {backgrounds.length}</p>
|
||||
<p>Categories: {categories.join(', ') || '<empty>'}</p>
|
||||
<p>Active Tab: {activeTab}</p>
|
||||
<p>Selected Category: {selectedCategory}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { Theme } from '@/interface/types/Theme';
|
||||
import emblaCarouselSvelte from 'embla-carousel-svelte';
|
||||
import Autoplay from 'embla-carousel-autoplay';
|
||||
|
||||
let { coverThemes, setDisplayTheme } = $props<{ coverThemes: Theme[], setDisplayTheme: (theme: Theme) => void }>();
|
||||
let emblaApi = $state();
|
||||
|
||||
const options = { loop: true };
|
||||
const plugins = [
|
||||
Autoplay({
|
||||
delay: 5000,
|
||||
stopOnInteraction: false,
|
||||
stopOnMouseEnter: true
|
||||
})
|
||||
];
|
||||
|
||||
function onInit(event: CustomEvent) {
|
||||
emblaApi = event.detail;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const slidePrev = () => emblaApi?.scrollPrev();
|
||||
// @ts-ignore
|
||||
const slideNext = () => emblaApi?.scrollNext();
|
||||
</script>
|
||||
|
||||
{#if coverThemes.length > 0}
|
||||
<div class="relative w-full transition-opacity rounded-xl overflow-clip" transition:fade>
|
||||
<div
|
||||
class="w-full aspect-[8/3]"
|
||||
use:emblaCarouselSvelte={{ options, plugins }}
|
||||
onemblaInit={onInit}
|
||||
>
|
||||
<div class="flex">
|
||||
{#each coverThemes as theme}
|
||||
<div
|
||||
class="relative flex-[0_0_100%] cursor-pointer rounded-xl overflow-clip"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }}
|
||||
onclick={() => setDisplayTheme(theme)}
|
||||
>
|
||||
<img src={theme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-full" />
|
||||
<div class='absolute bottom-0 left-0 p-8 z-[1]'>
|
||||
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2>
|
||||
<p class='text-lg text-white'>{theme.description}</p>
|
||||
</div>
|
||||
<div class='absolute bottom-0 left-0 w-full h-1/2 bg-gradient-to-t from-black/80 to-transparent'></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation buttons -->
|
||||
<div class='absolute z-10 flex gap-2 bottom-2 right-2'>
|
||||
<button aria-label="Previous" onclick={slidePrev} class='flex items-center justify-center w-8 h-8 text-white bg-black bg-opacity-50 rounded-full dark:bg-zinc-800 dark:bg-opacity-50'>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button aria-label="Next" onclick={slideNext} class='flex items-center justify-center w-8 h-8 text-white bg-black bg-opacity-50 rounded-full dark:bg-zinc-800 dark:bg-opacity-50'>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { Background } from './types';
|
||||
|
||||
export let filteredBackgrounds: Background[];
|
||||
|
||||
let dispatch = createEventDispatcher();
|
||||
|
||||
let filters = $state({
|
||||
type: [] as string[],
|
||||
color: [] as string[],
|
||||
resolution: [] as string[],
|
||||
orientation: [] as string[]
|
||||
});
|
||||
|
||||
$: {
|
||||
dispatch('filter', filters);
|
||||
}
|
||||
|
||||
function toggleFilter(category: keyof typeof filters, value: string) {
|
||||
if (filters[category].includes(value)) {
|
||||
filters[category] = filters[category].filter(v => v !== value);
|
||||
} else {
|
||||
filters[category] = [...filters[category], value];
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
filters = {
|
||||
type: [],
|
||||
color: [],
|
||||
resolution: [],
|
||||
orientation: []
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Filters</h2>
|
||||
|
||||
<div class="mb-4">
|
||||
<h3 class="mb-2 font-medium">Type</h3>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" checked={filters.type.includes('image')} on:change={() => toggleFilter('type', 'image')}>
|
||||
<span class="ml-2">Image</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" checked={filters.type.includes('video')} on:change={() => toggleFilter('type', 'video')}>
|
||||
<span class="ml-2">Video</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add similar sections for color, resolution, and orientation -->
|
||||
|
||||
<button
|
||||
class="px-4 py-2 mt-4 text-white bg-red-500 rounded hover:bg-red-600"
|
||||
on:click={clearFilters}
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import logo from '@/resources/icons/betterseqta-dark-full.png';
|
||||
import logoDark from '@/resources/icons/betterseqta-light-full.png';
|
||||
import { closeStore } from '@/seqta/ui/renderStore'
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
// Props
|
||||
let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{
|
||||
searchTerm: string,
|
||||
setSearchTerm: (term: string) => void,
|
||||
darkMode: boolean,
|
||||
activeTab: string,
|
||||
setActiveTab: (tab: string) => void
|
||||
}>();
|
||||
|
||||
// Clear search input function
|
||||
const clearSearch = () => {
|
||||
setSearchTerm('');
|
||||
};
|
||||
</script>
|
||||
|
||||
<header class="fixed top-0 z-50 w-full h-[4.25rem] bg-white border-b shadow-md border-b-white/10 dark:bg-zinc-950/90 backdrop-blur-xl dark:text-white">
|
||||
<div class="flex items-center justify-between px-4 py-1">
|
||||
<div class="flex gap-4 cursor-pointer place-items-center" onkeydown={(e) => { if (e.key === 'Enter') clearSearch() }} onclick={clearSearch} role="button" tabindex="0">
|
||||
<img src={browser.runtime.getURL(logo)} class="h-14 {darkMode ? 'hidden' : ''}" alt="Logo" />
|
||||
<img src={browser.runtime.getURL(logoDark)} class="h-14 {darkMode ? '' : 'hidden'}" alt="Dark Logo" />
|
||||
|
||||
<div class="w-[1px] h-10 my-auto bg-zinc-400 dark:bg-zinc-600"></div>
|
||||
|
||||
<button
|
||||
class="px-4 py-2 font-semibold text-lg transition-colors duration-200 {activeTab === 'themes' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400'}"
|
||||
onclick={() => setActiveTab('themes')}
|
||||
>
|
||||
Themes
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 font-semibold text-lg transition-colors duration-200 {activeTab === 'backgrounds' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400'}"
|
||||
onclick={() => setActiveTab('backgrounds')}
|
||||
>
|
||||
Backgrounds
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search themes..."
|
||||
value={searchTerm}
|
||||
oninput={(e: any) => setSearchTerm(e.target.value)}
|
||||
class="px-4 py-2 pl-10 text-lg transition bg-gray-100/80 rounded-lg ring-0 focus:bg-gray-100/0 dark:focus:bg-zinc-700/50 focus:ring-[1px] ring-zinc-200 dark:ring-zinc-600 dark:bg-zinc-700/80 dark:text-gray-100 focus:outline-none focus:border-transparent" />
|
||||
<svg
|
||||
class="absolute w-5 h-5 text-gray-400 transform -translate-y-1/2 left-3 top-1/2 dark:text-gray-200"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
onclick={closeStore}
|
||||
class="p-1 px-3"
|
||||
>
|
||||
<span class="text-2xl font-IconFamily"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
let { theme, onClick } = $props<{ theme: Theme; onClick: () => void }>();
|
||||
|
||||
import { fade } from 'svelte/transition';
|
||||
</script>
|
||||
|
||||
<div class="w-full cursor-pointer" role="button" tabindex="-1" onkeydown={onClick} onclick={onClick}>
|
||||
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border" transition:fade>
|
||||
<div class="absolute z-10 mb-1 text-xl font-bold text-white bottom-1 left-3">
|
||||
{theme.name}
|
||||
</div>
|
||||
<div class='absolute bottom-0 z-0 w-full h-3/4 bg-gradient-to-t from-black/80 to-transparent'></div>
|
||||
<div class='w-full'>
|
||||
<img src={theme.coverImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import type { Theme } from '@/interface/types/Theme'
|
||||
import ThemeCard from './ThemeCard.svelte';
|
||||
|
||||
let { themes, searchTerm, setDisplayTheme } = $props<{ themes: Theme[]; searchTerm: string, setDisplayTheme: (theme: Theme) => void }>();
|
||||
|
||||
let filteredThemes = $derived(themes.filter((theme: Theme) =>
|
||||
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
));
|
||||
</script>
|
||||
|
||||
<div class="relative" >
|
||||
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each filteredThemes as theme (theme.id)}
|
||||
<ThemeCard theme={theme} onClick={() => setDisplayTheme(theme)} />
|
||||
{/each}
|
||||
|
||||
{#if filteredThemes.length !== 0}
|
||||
<a href="https://betterseqta.gitbook.io/betterseqta-docs" class='w-full cursor-pointer'>
|
||||
<div class="bg-zinc-50 h-48 w-full transition-all hover:scale-105 duration-500 relative justify-center items-center group group/card flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] rounded-xl overflow-clip border">
|
||||
<div class="text-2xl font-IconFamily">{'\uecb3'}</div>
|
||||
<div class="text-xl font-bold text-center transition-all duration-500 dark:text-white">
|
||||
Got a Theme Idea?
|
||||
<p class="text-lg font-light subtitle">Transform it into a stunning theme!</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
{#if filteredThemes.length === 0}
|
||||
<div class="absolute top-0 flex flex-col items-center justify-center w-full text-center h-96">
|
||||
<h1 class="mt-4 text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-5xl">That doesn't exist! 😭😭😭</h1>
|
||||
<p class="mt-6 text-lg leading-7 text-zinc-600 dark:text-zinc-300">Sorry, we couldn't find the theme you're looking for. Maybe... you could create it?</p>
|
||||
<a href="https://betterseqta.gitbook.io/betterseqta-docs" class='p-2 px-3 mt-4 transition rounded-md cursor-pointer dark:text-white bg-zinc-500/10 hover:scale-105'>
|
||||
Show me how!
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import type { Theme } from '@/interface/types/Theme'
|
||||
import { fade } from 'svelte/transition';
|
||||
import { animate, spring } from 'motion';
|
||||
|
||||
let { theme, currentThemes, setDisplayTheme, onInstall, onRemove, allThemes, displayTheme } = $props<{
|
||||
theme: Theme | null;
|
||||
currentThemes: string[];
|
||||
setDisplayTheme: (theme: Theme | null) => void;
|
||||
onInstall: (themeId: string) => void;
|
||||
onRemove: (themeId: string) => void;
|
||||
allThemes: Theme[];
|
||||
displayTheme: Theme | null;
|
||||
}>();
|
||||
let installing = $state(false);
|
||||
let modalElement: HTMLElement;
|
||||
|
||||
// Function to get related themes
|
||||
function getRelatedThemes() {
|
||||
return allThemes
|
||||
.filter((t: Theme) => t.id !== theme.id)
|
||||
.sort((a: Theme, b: Theme) => a.name.localeCompare(theme.name) - b.name.localeCompare(theme.name))
|
||||
.slice(0, 4);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (displayTheme) {
|
||||
animate(
|
||||
modalElement,
|
||||
{ y: [500, 0], opacity: [0, 1] },
|
||||
{ easing: spring({ stiffness: 150, damping: 20 }) }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const hideModal = (relatedTheme?: Theme | null) => {
|
||||
animate(
|
||||
modalElement,
|
||||
{ y: [10, 500], opacity: [1, 0] },
|
||||
{ easing: spring({ stiffness: 150, damping: 20 }) }
|
||||
);
|
||||
setTimeout(() => {
|
||||
setDisplayTheme(relatedTheme ?? null);
|
||||
}, 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-end justify-center bg-black bg-opacity-70"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) hideModal();
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.target === e.currentTarget) hideModal();
|
||||
}}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
transition:fade
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={modalElement}
|
||||
class="w-full max-w-[600px] h-[95%] p-4 bg-white rounded-t-2xl dark:bg-zinc-800 overflow-scroll no-scrollbar cursor-auto"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="relative h-auto">
|
||||
<button class="absolute top-0 right-0 p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200" onclick={() => hideModal()}>
|
||||
{'\ued8a'}
|
||||
</button>
|
||||
<h2 class="mb-4 text-2xl font-bold">
|
||||
{theme.name}
|
||||
</h2>
|
||||
<img src={theme.marqueeImage} alt="Theme Cover" class="object-cover w-full mb-4 rounded-md" />
|
||||
<p class="mb-4 text-gray-700 dark:text-gray-300">
|
||||
{theme.description}
|
||||
</p>
|
||||
{#if currentThemes.includes(theme.id)}
|
||||
<button onclick={async () => {installing = true; await onRemove(theme.id); installing = false}} class="relative flex items-center justify-center w-32 px-4 py-2 mt-4 ml-auto text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200">
|
||||
{#if installing}
|
||||
<svg class="absolute w-4 h-4 { installing ? 'opacity-100' : 'opacity-0' }" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Remove</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button onclick={async () => {installing = true; await onInstall(theme.id); installing = false}} class="relative flex items-center justify-center w-32 px-4 py-2 mt-4 ml-auto text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200">
|
||||
{#if installing}
|
||||
<svg class="absolute w-4 h-4 { installing ? 'opacity-100' : 'opacity-0' }" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Install</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="my-8 border-b border-zinc-200 dark:border-zinc-700"></div>
|
||||
|
||||
<h3 class="mb-4 text-lg font-bold">
|
||||
Similar Themes
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{#each getRelatedThemes() as relatedTheme (relatedTheme.id)}
|
||||
<button onclick={() => { hideModal(relatedTheme) }} class="w-full cursor-pointer">
|
||||
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group group/card flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border">
|
||||
<div class="absolute z-10 mb-1 text-xl font-bold text-white transition-all duration-500 group-hover:-translate-y-0.5 bottom-1 left-3">
|
||||
{relatedTheme.name}
|
||||
</div>
|
||||
<div class="absolute bottom-0 z-0 w-full h-3/4 bg-gradient-to-t from-black/80 to-transparent"></div>
|
||||
<img src={relatedTheme.coverImage} alt="Theme Preview" class="object-cover w-full h-48" />
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,36 +0,0 @@
|
||||
import logo from '../../../resources/icons/betterseqta-dark-full.png';
|
||||
import logoDark from '../../../resources/icons/betterseqta-light-full.png';
|
||||
|
||||
export default function header({ searchTerm, setSearchTerm }: { searchTerm: string, setSearchTerm: (value: string) => void }) {
|
||||
return <header className="fixed top-0 z-50 w-full h-[4.25rem] bg-white border-b shadow-md border-b-white/10 dark:bg-zinc-800/90 backdrop-blur-xl">
|
||||
<div className="flex items-center justify-between px-4 py-1">
|
||||
<div className="flex gap-4 cursor-pointer place-items-center" onClick={() => setSearchTerm('')}>
|
||||
<img src={logo} className="h-14 dark:hidden" />
|
||||
<img src={logoDark} className="hidden h-14 dark:block" />
|
||||
|
||||
<div className="w-[1px] h-10 my-auto bg-zinc-400 dark:bg-zinc-600" />
|
||||
|
||||
<h1 className="text-xl font-semibold">Theme Store</h1>
|
||||
</div>
|
||||
<div className="relative flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search themes..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="px-4 py-2 pl-10 text-lg transition bg-gray-100/80 rounded-lg ring-0 focus:bg-gray-100/0 dark:focus:bg-zinc-700/50 focus:ring-[1px] ring-zinc-200 dark:ring-zinc-600 dark:bg-zinc-700/80 dark:text-gray-100 focus:outline-none focus:border-transparent" />
|
||||
<svg
|
||||
className="absolute w-5 h-5 text-gray-400 transform -translate-y-1/2 left-3 top-1/2 dark:text-gray-200"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</header>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<script lang="ts"></script>
|
||||
|
||||
<div class='w-full h-0.5 my-4 bg-zinc-200 dark:bg-zinc-700'></div>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
interface Background {
|
||||
id: string;
|
||||
type: string;
|
||||
blob: Blob | null;
|
||||
url?: string | undefined;
|
||||
}
|
||||
|
||||
let { bg, isSelected, isEditMode, onClick, onDelete } = $props<{ bg: Background, isSelected: boolean, isEditMode: boolean, onClick: () => void, onDelete: () => void }>();
|
||||
|
||||
</script>
|
||||
|
||||
<div
|
||||
onclick={onClick}
|
||||
onkeydown={onClick}
|
||||
tabindex="-1"
|
||||
role="button"
|
||||
class="relative w-16 h-16 cursor-pointer rounded-xl transition ring dark:ring-zinc-500/50 ring-zinc-300 {isEditMode ? 'animate-shake' : ''} {isSelected ? 'dark:ring-4 ring-4' : 'ring-0'}"
|
||||
>
|
||||
{#if isEditMode}
|
||||
<div
|
||||
tabindex="-1"
|
||||
role="button"
|
||||
class="absolute top-0 right-0 z-10 flex w-6 h-6 p-2 text-white translate-x-1/2 -translate-y-1/2 bg-red-600 rounded-full place-items-center"
|
||||
onclick={onDelete}
|
||||
onkeydown={onDelete}
|
||||
>
|
||||
<div class="w-4 h-0.5 bg-white"></div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if bg.url}
|
||||
{#if bg.type === 'image'}
|
||||
<img class="object-cover w-full h-full rounded-xl" src={bg.url} alt="swatch" />
|
||||
{:else if bg.type === 'video'}
|
||||
<video muted loop autoplay src={bg.url} class="object-cover w-full h-full rounded-xl"></video>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,235 @@
|
||||
<script lang="ts">
|
||||
import { hasEnoughStorageSpace, isIndexedDBSupported, writeData, openDatabase, readAllData, deleteData } from '@/interface/hooks/BackgroundDataLoader'
|
||||
import BackgroundUploader from './BackgroundUploader.svelte';
|
||||
import BackgroundItem from './BackgroundItem.svelte'
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
|
||||
import { delay } from 'lodash'
|
||||
import { backgroundUpdates } from '@/interface/hooks/BackgroundUpdates'
|
||||
|
||||
let { isEditMode, selectNoBackground = $bindable(), selectedBackground = $bindable() } = $props<{ isEditMode: boolean, selectNoBackground: () => void, selectedBackground: string | null }>();
|
||||
let backgrounds = $state<{ id: string; type: string; blob: Blob | null; url?: string }[]>([]);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let imageBackgrounds = $derived(backgrounds.filter(bg => bg.type === 'image'));
|
||||
let videoBackgrounds = $derived(backgrounds.filter(bg => bg.type === 'video'));
|
||||
|
||||
let isVisible = $state(false);
|
||||
let element: HTMLElement;
|
||||
let observer: MutationObserver;
|
||||
let parentElement: HTMLElement | null = null;
|
||||
|
||||
async function getTheme() {
|
||||
return localStorage.getItem('selectedBackground');
|
||||
}
|
||||
|
||||
async function setTheme(theme: string) {
|
||||
localStorage.setItem('selectedBackground', theme);
|
||||
}
|
||||
|
||||
async function handleFileChange(file: File): Promise<void> {
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
if (!isIndexedDBSupported()) {
|
||||
throw new Error("Your browser doesn't support IndexedDB. Unable to save backgrounds.");
|
||||
}
|
||||
|
||||
const hasSpace = await hasEnoughStorageSpace(file.size);
|
||||
if (!hasSpace) {
|
||||
throw new Error("Not enough storage space to save this background.");
|
||||
}
|
||||
|
||||
const fileId = `${Date.now()}-${file.name}`;
|
||||
const fileType = file.type.split('/')[0];
|
||||
const blob = new Blob([file], { type: file.type });
|
||||
|
||||
await writeData(fileId, fileType, blob);
|
||||
backgrounds = [...backgrounds, { id: fileId, type: fileType, blob, url: URL.createObjectURL(blob) }];
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
error = e.message;
|
||||
} else {
|
||||
error = 'An unknown error occurred';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBackgroundMetadata(): Promise<void> {
|
||||
try {
|
||||
error = null;
|
||||
|
||||
if (!isIndexedDBSupported()) {
|
||||
throw new Error("Your browser doesn't support IndexedDB. Unable to load backgrounds.");
|
||||
}
|
||||
|
||||
await openDatabase();
|
||||
const data = await readAllData();
|
||||
selectedBackground = await getTheme();
|
||||
|
||||
// Only load metadata (id and type) for placeholders
|
||||
backgrounds = data.map(({ id, type }) => ({ id, type, blob: null }));
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
error = e.message;
|
||||
} else {
|
||||
error = 'An unknown error occurred';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncBackgrounds(): Promise<void> {
|
||||
try {
|
||||
error = null;
|
||||
|
||||
if (!isIndexedDBSupported()) {
|
||||
throw new Error("Your browser doesn't support IndexedDB. Unable to load backgrounds.");
|
||||
}
|
||||
|
||||
const dbData = await readAllData();
|
||||
|
||||
// Release existing object URLs to prevent memory leaks
|
||||
backgrounds.forEach(bg => {
|
||||
if (bg.url) URL.revokeObjectURL(bg.url);
|
||||
});
|
||||
|
||||
// Create fresh background objects with new object URLs
|
||||
backgrounds = dbData.map(bg => ({
|
||||
id: bg.id,
|
||||
type: bg.type,
|
||||
blob: bg.blob,
|
||||
url: URL.createObjectURL(bg.blob)
|
||||
}));
|
||||
|
||||
// Check if selected background still exists
|
||||
if (selectedBackground && !backgrounds.some(bg => bg.id === selectedBackground)) {
|
||||
selectNoBackground();
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
error = e.message;
|
||||
} else {
|
||||
error = 'An unknown error occurred';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectBackground(fileId: string): void {
|
||||
if (selectedBackground === fileId) {
|
||||
selectNoBackground();
|
||||
return;
|
||||
}
|
||||
|
||||
selectedBackground = fileId;
|
||||
setTheme(fileId);
|
||||
}
|
||||
|
||||
async function deleteBackground(fileId: string): Promise<void> {
|
||||
try {
|
||||
await deleteData(fileId);
|
||||
backgrounds = backgrounds.filter(bg => bg.id !== fileId);
|
||||
|
||||
if (selectedBackground === fileId) {
|
||||
selectNoBackground();
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
error = `Failed to delete background: ${e.message}`;
|
||||
} else {
|
||||
error = 'An unknown error occurred';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectNoBackground = () => {
|
||||
selectedBackground = null;
|
||||
setTheme('');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadBackground();
|
||||
selectedBackground
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
function checkActiveClass() {
|
||||
if (parentElement?.classList.contains('active')) {
|
||||
delay(() => {
|
||||
isVisible = true;
|
||||
syncBackgrounds();
|
||||
}, 600);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadBackgroundMetadata();
|
||||
backgroundUpdates.addListener(syncBackgrounds);
|
||||
|
||||
parentElement = element.closest('.tab');
|
||||
if (parentElement) {
|
||||
observer = new MutationObserver(checkActiveClass);
|
||||
observer.observe(parentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
backgroundUpdates.removeListener(syncBackgrounds);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={element} class="relative px-1 { !( isEditMode && imageBackgrounds.length === 0 && videoBackgrounds.length === 0 ) && 'pt-2' }">
|
||||
{#if !(imageBackgrounds.length === 0 && isEditMode)}
|
||||
<h2 class="pb-2 text-lg font-bold">Background Images</h2>
|
||||
<div class="flex flex-wrap gap-4 mb-4">
|
||||
{#if !isEditMode}
|
||||
<BackgroundUploader on:fileChange={e => handleFileChange(e.detail)} />
|
||||
{/if}
|
||||
{#each imageBackgrounds as bg (bg.id)}
|
||||
{#if isVisible && bg.blob}
|
||||
<BackgroundItem
|
||||
bg={bg}
|
||||
isSelected={selectedBackground === bg.id}
|
||||
isEditMode={isEditMode}
|
||||
onClick={() => selectBackground(bg.id)}
|
||||
onDelete={() => deleteBackground(bg.id)}/>
|
||||
{:else}
|
||||
<div class="w-16 h-16 rounded-xl bg-zinc-100 dark:bg-zinc-900 animate-pulse"></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !(videoBackgrounds.length === 0 && isEditMode)}
|
||||
<h2 class="py-2 text-lg font-bold">Background Videos</h2>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
{#if !isEditMode}
|
||||
<BackgroundUploader on:fileChange={e => handleFileChange(e.detail)} />
|
||||
{/if}
|
||||
{#each videoBackgrounds as bg (bg.id)}
|
||||
{#if isVisible && bg.blob}
|
||||
<BackgroundItem
|
||||
bg={bg}
|
||||
isSelected={selectedBackground === bg.id}
|
||||
isEditMode={isEditMode}
|
||||
onClick={() => selectBackground(bg.id)}
|
||||
onDelete={() => deleteBackground(bg.id)}
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-16 h-16 rounded-xl bg-zinc-100 dark:bg-zinc-900 animate-pulse"></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function handleFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
dispatch('fileChange', file);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative w-16 h-16 overflow-hidden transition rounded-xl bg-zinc-100 dark:bg-zinc-900">
|
||||
<div class="flex items-center justify-center w-full h-full text-3xl font-bold text-gray-400 transition font-IconFamily hover:text-gray-500">
|
||||
<!-- Plus icon -->
|
||||
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*, video/mp4"
|
||||
on:change={handleFileChange}
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,210 @@
|
||||
<script lang="ts">
|
||||
import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
|
||||
import { getAvailableThemes } from '@/seqta/ui/themes/getAvailableThemes'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { OpenThemeCreator } from '@/seqta/ui/ThemeCreator'
|
||||
import shareTheme from '@/seqta/ui/themes/shareTheme'
|
||||
import { InstallTheme } from '@/seqta/ui/themes/downloadTheme'
|
||||
import { disableTheme } from '@/seqta/ui/themes/disableTheme'
|
||||
import { setTheme } from '@/seqta/ui/themes/setTheme'
|
||||
import { deleteTheme } from '@/seqta/ui/themes/deleteTheme'
|
||||
import { OpenStorePage } from '@/seqta/ui/renderStore'
|
||||
import { themeUpdates } from '@/interface/hooks/ThemeUpdates'
|
||||
import { closeExtensionPopup } from '@/SEQTA'
|
||||
|
||||
let themes = $state<ThemeList | null>(null);
|
||||
let { isEditMode } = $props<{ isEditMode: boolean }>();
|
||||
let isDragging = $state(false);
|
||||
let tempTheme = $state(null);
|
||||
|
||||
const handleThemeClick = async (theme: CustomTheme) => {
|
||||
if (isEditMode) return;
|
||||
if (theme.id === themes?.selectedTheme) {
|
||||
await disableTheme();
|
||||
themes.selectedTheme = '';
|
||||
} else {
|
||||
await setTheme(theme.id);
|
||||
if (!themes) return;
|
||||
themes.selectedTheme = theme.id;
|
||||
}
|
||||
}
|
||||
|
||||
const handleThemeDelete = async (themeId: string) => {
|
||||
try {
|
||||
await deleteTheme(themeId);
|
||||
if (!themes) return;
|
||||
|
||||
themes.themes = themes.themes.filter(theme => theme.id !== themeId);
|
||||
if (themeId === themes.selectedTheme) {
|
||||
themes.selectedTheme = '';
|
||||
await disableTheme();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting theme:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const handleShareTheme = async (theme: CustomTheme) => {
|
||||
try {
|
||||
await shareTheme(theme.id);
|
||||
} catch (error) {
|
||||
console.error('Error sharing theme:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
}
|
||||
|
||||
const handleDragLeave = () => {
|
||||
isDragging = false;
|
||||
}
|
||||
|
||||
const handleDrop = async (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
isDragging = false;
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event: ProgressEvent<FileReader>) => {
|
||||
try {
|
||||
const result = JSON.parse(event.target?.result as string);
|
||||
tempTheme = result;
|
||||
await InstallTheme(result);
|
||||
await fetchThemes();
|
||||
} catch (error) {
|
||||
alert('Error parsing file. Please upload a valid JSON theme file.');
|
||||
}
|
||||
tempTheme = null;
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
const fetchThemes = async () => {
|
||||
themes = await getAvailableThemes();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await fetchThemes();
|
||||
|
||||
themeUpdates.addListener(fetchThemes);
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
themeUpdates.removeListener(fetchThemes);
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="w-full pt-5 mb-1"
|
||||
role="list"
|
||||
tabindex="-1"
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
>
|
||||
<div class="{isDragging ? 'opacity-100' : 'opacity-0'} transition pointer-events-none absolute w-full p-2 z-50">
|
||||
<div class="sticky w-full h-64 bg-white shadow-xl dark:bg-zinc-900 top-5 dark:text-white rounded-xl outline-dashed outline-4 outline-zinc-200 dark:outline-zinc-700">
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<svg height="48" width="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="currentColor">
|
||||
<path d="M44,31a1,1,0,0,0-1,1v8a3,3,0,0,1-3,3H8a3,3,0,0,1-3-3V32a1,1,0,0,0-2,0v8a5.006,5.006,0,0,0,5,5H40a5.006,5.006,0,0,0,5-5V32A1,1,0,0,0,44,31Z" fill="currentColor"/>
|
||||
<path d="M23.2,33.6a1,1,0,0,0,1.6,0l9-12A1,1,0,0,0,33,20H26V5a2,2,0,0,0-4,0V20H15a1,1,0,0,0-.8,1.6Z" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
<span class="text-lg">Import Theme</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="pb-2 text-lg font-bold">Themes</h2>
|
||||
<div class="flex flex-col gap-2 px-2">
|
||||
{#if themes}
|
||||
{#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={() => handleThemeClick(theme)}
|
||||
>
|
||||
{#if isEditMode}
|
||||
<div
|
||||
class="absolute z-20 flex w-6 h-6 p-2 text-white bg-red-600 rounded-full opacity-100 right-2 place-items-center top-2"
|
||||
onclick={(event) => { event.stopPropagation(); handleThemeDelete(theme.id) }}
|
||||
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') handleThemeDelete(theme.id) }}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="w-4 h-0.5 bg-white"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isEditMode}
|
||||
<div
|
||||
class="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full delay-[20ms] opacity-0 top-1/4 right-2 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-1/2 -translate-y-1/2"
|
||||
onclick={(event) => { event.stopPropagation(); OpenThemeCreator(theme.id); closeExtensionPopup() }}
|
||||
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') OpenThemeCreator(theme.id); closeExtensionPopup() }}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<span class="text-lg font-IconFamily"></span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute z-20 flex w-8 h-8 p-2 text-center transition-all -translate-y-1/2 rounded-full opacity-0 text-white/80 top-1/4 right-12 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-1/2"
|
||||
onclick={(event) => { event.stopPropagation(); handleShareTheme(theme) }}
|
||||
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') handleShareTheme(theme) }}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<span class="text-lg font-IconFamily"></span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="relative top-0 z-10 flex justify-center w-full h-full overflow-hidden transition dark:text-white rounded-xl group place-items-center bg-zinc-100 dark:bg-zinc-900 { isEditMode ? 'animate-shake brightness-90' : ''}">
|
||||
{#if theme.coverImage}
|
||||
<img
|
||||
src={typeof theme.coverImage === 'string' ? theme.coverImage : URL.createObjectURL(theme.coverImage)}
|
||||
alt={theme.name}
|
||||
class="absolute inset-0 z-0 object-cover w-full h-full pointer-events-none"
|
||||
/>
|
||||
{/if}
|
||||
{#if !theme.hideThemeName}
|
||||
<div class="z-10 {theme.coverImage ? 'text-white' : ''}">{theme.name}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if tempTheme}
|
||||
<div class="flex justify-center w-full bg-gray-200 rounded-xl dark:bg-zinc-700/50 place-items-center aspect-theme animate-pulse">
|
||||
<svg class="w-5 h-5 text-white animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if themes && themes.themes.length > 0}
|
||||
<div id="divider" class="w-full h-[1px] my-2 bg-zinc-100 dark:bg-zinc-600"></div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={() => OpenStorePage()}
|
||||
class="flex items-center justify-center w-full transition aspect-theme rounded-xl bg-zinc-100 dark:bg-zinc-900 dark:text-white"
|
||||
>
|
||||
<span class="text-xl font-IconFamily"></span>
|
||||
<span class="ml-2">Theme Store</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => { OpenThemeCreator(); closeExtensionPopup() }}
|
||||
class="flex items-center justify-center w-full transition aspect-theme rounded-xl bg-zinc-100 dark:bg-zinc-900 dark:text-white"
|
||||
>
|
||||
<span class="text-xl font-IconFamily"></span>
|
||||
<span class="ml-2">Create your own</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface Options {
|
||||
root?: Element | null;
|
||||
rootMargin?: string;
|
||||
threshold?: number | number[];
|
||||
}
|
||||
|
||||
type UseVisibilityReturnType = [any | null, boolean];
|
||||
|
||||
const useVisibility = (options: Options): UseVisibilityReturnType => {
|
||||
const [isVisible, setIsVisible] = useState<boolean>(false);
|
||||
const elementRef = useRef<Element | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
setIsVisible(entry.isIntersecting);
|
||||
}, options);
|
||||
|
||||
if (elementRef.current) {
|
||||
observer.observe(elementRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (elementRef.current) {
|
||||
observer.unobserve(elementRef.current);
|
||||
}
|
||||
};
|
||||
}, [elementRef, options]);
|
||||
|
||||
return [elementRef, isVisible];
|
||||
};
|
||||
|
||||
export default useVisibility;
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
|
||||
const e = React.createElement;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
onMount(() => {
|
||||
const { el, children, class: _, ...props } = $$props;
|
||||
try {
|
||||
ReactDOM.render(e(el, props, children), container);
|
||||
} catch (err) {
|
||||
console.warn(`react-adapter failed to mount.`, { err });
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
try {
|
||||
ReactDOM.unmountComponentAtNode(container);
|
||||
} catch (err) {
|
||||
console.warn(`react-adapter failed to unmount.`, { err });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={container} class={$$props.class}></div>
|
||||
@@ -1,8 +0,0 @@
|
||||
import Browser from "webextension-polyfill";
|
||||
|
||||
(async () => {
|
||||
const result = await Browser.storage.local.get();
|
||||
if (result.DarkMode) {
|
||||
document.body.classList.add('dark');
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,75 @@
|
||||
import { type DBSchema, type IDBPDatabase, openDB } from 'idb';
|
||||
|
||||
interface BackgroundDB extends DBSchema {
|
||||
backgrounds: {
|
||||
key: string;
|
||||
value: {
|
||||
id: string;
|
||||
type: string;
|
||||
blob: Blob;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let db: IDBPDatabase<BackgroundDB> | null = null;
|
||||
|
||||
export async function openDatabase(): Promise<IDBPDatabase<BackgroundDB>> {
|
||||
if (db) return db;
|
||||
|
||||
db = await openDB<BackgroundDB>('BackgroundDB', 1, {
|
||||
upgrade(db: IDBPDatabase<BackgroundDB>) {
|
||||
db.createObjectStore('backgrounds', { keyPath: 'id' });
|
||||
},
|
||||
});
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function readAllData(): Promise<Array<{ id: string; type: string; blob: Blob }>> {
|
||||
const db = await openDatabase();
|
||||
return db.getAll('backgrounds');
|
||||
}
|
||||
|
||||
export async function writeData(id: string, type: string, blob: Blob): Promise<void> {
|
||||
const db = await openDatabase();
|
||||
await db.put('backgrounds', { id, type, blob });
|
||||
}
|
||||
|
||||
export async function deleteData(id: string): Promise<void> {
|
||||
const db = await openDatabase();
|
||||
await db.delete('backgrounds', id);
|
||||
}
|
||||
|
||||
export async function clearAllData(): Promise<void> {
|
||||
const db = await openDatabase();
|
||||
await db.clear('backgrounds');
|
||||
}
|
||||
|
||||
export async function getDataById(id: string): Promise<{ id: string; type: string; blob: Blob } | undefined> {
|
||||
const db = await openDatabase();
|
||||
return db.get('backgrounds', id);
|
||||
}
|
||||
|
||||
export function closeDatabase(): void {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if IndexedDB is supported
|
||||
export function isIndexedDBSupported(): boolean {
|
||||
return 'indexedDB' in window;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
// If we can't determine, assume there's enough space
|
||||
return true;
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Background } from "../components/BackgroundSelector";
|
||||
|
||||
export const downloadPresetBackground = async (background: Background, onProgress: (progress: number) => void): Promise<Background> => {
|
||||
const response = await fetch(background.url as string);
|
||||
|
||||
const totalLength = +response.headers.get('Content-Length')!;
|
||||
let receivedLength = 0;
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const chunks = [];
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const { done, value } = await reader!.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
chunks.push(value!);
|
||||
receivedLength += value!.length;
|
||||
|
||||
onProgress(Math.ceil(receivedLength / totalLength * 100));
|
||||
}
|
||||
|
||||
const blob = new Blob(chunks);
|
||||
await writeData(background.id, background.type, blob);
|
||||
|
||||
return {
|
||||
id: background.id,
|
||||
type: background.type,
|
||||
blob,
|
||||
url: URL.createObjectURL(blob),
|
||||
};
|
||||
};
|
||||
// IndexedDB utility functions
|
||||
export const openDB = () => {
|
||||
return new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open('MyDatabase', 1);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
db.createObjectStore('backgrounds', { keyPath: 'id' });
|
||||
};
|
||||
});
|
||||
};
|
||||
export const writeData = async (fileId: string, type: string, blob: Blob) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
openDB().then(async (db) => {
|
||||
const tx = db.transaction('backgrounds', 'readwrite');
|
||||
const store = tx.objectStore('backgrounds');
|
||||
const request = store.put({ id: fileId, type, blob });
|
||||
|
||||
await new Promise((res, rej) => {
|
||||
tx.oncomplete = () => res(request.result);
|
||||
tx.onerror = () => rej(tx.error);
|
||||
}).then(resolve, reject);
|
||||
|
||||
}).catch(reject);
|
||||
});
|
||||
};
|
||||
export const readAllData = async (): Promise<Background[]> => {
|
||||
const db = await openDB();
|
||||
const tx = db.transaction('backgrounds', 'readonly');
|
||||
const store = tx.objectStore('backgrounds');
|
||||
const request = store.getAll();
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
type BackgroundUpdateCallback = () => void;
|
||||
|
||||
class BackgroundUpdates {
|
||||
private static instance: BackgroundUpdates;
|
||||
private listeners: Set<BackgroundUpdateCallback> = new Set();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): BackgroundUpdates {
|
||||
if (!BackgroundUpdates.instance) {
|
||||
BackgroundUpdates.instance = new BackgroundUpdates();
|
||||
}
|
||||
return BackgroundUpdates.instance;
|
||||
}
|
||||
|
||||
public addListener(callback: BackgroundUpdateCallback): void {
|
||||
this.listeners.add(callback);
|
||||
}
|
||||
|
||||
public removeListener(callback: BackgroundUpdateCallback): void {
|
||||
this.listeners.delete(callback);
|
||||
}
|
||||
|
||||
public triggerUpdate(): void {
|
||||
this.listeners.forEach(callback => callback());
|
||||
}
|
||||
}
|
||||
|
||||
export const backgroundUpdates = BackgroundUpdates.getInstance();
|
||||
@@ -0,0 +1,37 @@
|
||||
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();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): SettingsPopup {
|
||||
if (!SettingsPopup.instance) {
|
||||
SettingsPopup.instance = new SettingsPopup();
|
||||
}
|
||||
return SettingsPopup.instance;
|
||||
}
|
||||
|
||||
public addListener(callback: SettingsPopupCallback): void {
|
||||
this.listeners.add(callback);
|
||||
}
|
||||
|
||||
public removeListener(callback: SettingsPopupCallback): void {
|
||||
this.listeners.delete(callback);
|
||||
}
|
||||
|
||||
public triggerClose(): void {
|
||||
this.listeners.forEach(callback => callback());
|
||||
}
|
||||
}
|
||||
|
||||
export const settingsPopup = SettingsPopup.getInstance();
|
||||
@@ -1,161 +0,0 @@
|
||||
import browser from 'webextension-polyfill'
|
||||
import { CustomTheme, DownloadedTheme, ThemeList } from '../types/CustomThemes';
|
||||
import localforage from 'localforage';
|
||||
|
||||
export const setTheme = async (themeID: string) => {
|
||||
// send message to the background script
|
||||
await browser.runtime.sendMessage({
|
||||
type: 'currentTab',
|
||||
info: 'SetTheme',
|
||||
body: {
|
||||
themeID: themeID
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const getDownloadedThemes = async (): Promise<DownloadedTheme[]> => {
|
||||
// send message to the background script
|
||||
const response: DownloadedTheme[] = await new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
let availableThemes = await localforage.getItem('availableThemes') as string[];
|
||||
availableThemes = Array.from(new Set(availableThemes));
|
||||
|
||||
const downloadedThemes: DownloadedTheme[] = [];
|
||||
for (let i = 0; i < availableThemes.length; i++) {
|
||||
let themeData = await localforage.getItem(availableThemes[i]) as DownloadedTheme;
|
||||
|
||||
downloadedThemes.push(themeData);
|
||||
}
|
||||
|
||||
resolve(downloadedThemes);
|
||||
} catch(error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const listThemes = async (): Promise<ThemeList> => {
|
||||
// send message to the background script
|
||||
const response: ThemeList = await new Promise((resolve, reject) => {
|
||||
browser.runtime.sendMessage({
|
||||
type: 'currentTab',
|
||||
info: 'ListThemes'
|
||||
}).then(async (response) => {
|
||||
if (response) {
|
||||
// convert the response themes coverImage to a bloburl
|
||||
response.themes = await Promise.all(
|
||||
response.themes.map(async (theme: Omit<CustomTheme, 'CustomImages'>) => {
|
||||
if (theme.coverImage) {
|
||||
const blob = await fetch(theme.coverImage as string).then((res) => res.blob());
|
||||
theme.coverImage = URL.createObjectURL(blob);
|
||||
}
|
||||
return theme;
|
||||
})
|
||||
);
|
||||
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(new Error('Failed to get response'));
|
||||
}
|
||||
}).catch((error: any) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const disableTheme = async () => {
|
||||
await browser.runtime.sendMessage({
|
||||
type: 'currentTab',
|
||||
info: 'DisableTheme',
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteTheme = async (themeID: string) => {
|
||||
await browser.runtime.sendMessage({
|
||||
type: 'currentTab',
|
||||
info: 'DeleteTheme',
|
||||
body: {
|
||||
themeID: themeID
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const sendThemeUpdate = async (updatedTheme: CustomTheme | DownloadedTheme, saveTheme?: boolean, updateImages?: boolean, enableTheme?: boolean) => {
|
||||
saveTheme = saveTheme || false;
|
||||
enableTheme = enableTheme || false;
|
||||
|
||||
const imageDataPromises = updatedTheme.CustomImages.map(async (image) => {
|
||||
if (saveTheme || updateImages) {
|
||||
const base64 = await blobToBase64(image.blob);
|
||||
return {
|
||||
id: image.id,
|
||||
variableName: image.variableName,
|
||||
url: base64,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: image.id,
|
||||
variableName: image.variableName,
|
||||
url: ''
|
||||
};
|
||||
});
|
||||
|
||||
Promise.all(imageDataPromises).then(async (imageData) => {
|
||||
const themeData = {
|
||||
...updatedTheme,
|
||||
CustomImages: imageData,
|
||||
};
|
||||
|
||||
if (saveTheme && updatedTheme.coverImage) {
|
||||
themeData.coverImage = await blobToBase64(updatedTheme.coverImage as Blob);
|
||||
} else {
|
||||
themeData.coverImage = null;
|
||||
}
|
||||
|
||||
browser.runtime.sendMessage({
|
||||
type: 'currentTab',
|
||||
info: 'UpdateThemePreview',
|
||||
body: themeData,
|
||||
save: saveTheme,
|
||||
enableTheme: enableTheme
|
||||
});
|
||||
|
||||
if (saveTheme) {
|
||||
browser.runtime.sendMessage({ type: 'currentTab', info: 'CloseThemeCreator' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to convert a Blob to base64
|
||||
const blobToBase64 = (blob: Blob): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64 = reader.result as string;
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = (error) => {
|
||||
reject(error);
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
};
|
||||
|
||||
export const enableCurrentTheme = async () => {
|
||||
await browser.runtime.sendMessage({
|
||||
type: 'currentTab',
|
||||
info: 'EnableCurrentTheme',
|
||||
});
|
||||
};
|
||||
|
||||
export const saveUpdatedTheme = async (updatedTheme: CustomTheme) => {
|
||||
await browser.runtime.sendMessage({
|
||||
type: 'currentTab',
|
||||
info: 'SaveTheme',
|
||||
body: updatedTheme,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
type ThemeUpdateCallback = () => void;
|
||||
|
||||
class ThemeUpdates {
|
||||
private static instance: ThemeUpdates;
|
||||
private listeners: Set<ThemeUpdateCallback> = new Set();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): ThemeUpdates {
|
||||
if (!ThemeUpdates.instance) {
|
||||
ThemeUpdates.instance = new ThemeUpdates();
|
||||
}
|
||||
return ThemeUpdates.instance;
|
||||
}
|
||||
|
||||
public addListener(callback: ThemeUpdateCallback): void {
|
||||
this.listeners.add(callback);
|
||||
}
|
||||
|
||||
public removeListener(callback: ThemeUpdateCallback): void {
|
||||
this.listeners.delete(callback);
|
||||
}
|
||||
|
||||
public triggerUpdate(): void {
|
||||
this.listeners.forEach(callback => callback());
|
||||
}
|
||||
}
|
||||
|
||||
export const themeUpdates = ThemeUpdates.getInstance();
|
||||
@@ -0,0 +1 @@
|
||||
export let selectedBackground = $state<string | null>(null);
|
||||
@@ -1,100 +0,0 @@
|
||||
import browser from 'webextension-polyfill'
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { SettingsProps } from "../types/SettingsProps";
|
||||
import { SettingsState } from "../types/AppProps";
|
||||
import { SettingsState as StorageSettingsState } from '../../types/storage';
|
||||
|
||||
let RanOnce = false;
|
||||
let previousSettingsState: SettingsState
|
||||
|
||||
const useSettingsState = ({ settingsState, setSettingsState }: SettingsProps) => {
|
||||
useEffect(() => {
|
||||
if (RanOnce) return;
|
||||
RanOnce = true;
|
||||
|
||||
// @ts-expect-error - TODO: Fix this
|
||||
browser.storage.local.get().then((result: StorageSettingsState) => {
|
||||
setSettingsState({
|
||||
notificationCollector: result.notificationcollector,
|
||||
lessonAlerts: result.lessonalert,
|
||||
animatedBackground: result.animatedbk,
|
||||
animatedBackgroundSpeed: result.bksliderinput,
|
||||
customThemeColor: result.selectedColor,
|
||||
betterSEQTAPlus: result.onoff,
|
||||
shortcuts: result.shortcuts,
|
||||
customshortcuts: result.customshortcuts,
|
||||
transparencyEffects: result.transparencyEffects,
|
||||
selectedTheme: result.selectedTheme,
|
||||
timeFormat: result.timeFormat,
|
||||
animations: result.animations,
|
||||
defaultPage: result.defaultPage,
|
||||
devMode: result.devMode || false
|
||||
});
|
||||
});
|
||||
});
|
||||
const keyToStateMap = useMemo(() => ({
|
||||
"notificationcollector": "notificationCollector",
|
||||
"lessonalert": "lessonAlerts",
|
||||
"animatedbk": "animatedBackground",
|
||||
"bksliderinput": "animatedBackgroundSpeed",
|
||||
"selectedColor": "customThemeColor",
|
||||
"onoff": "betterSEQTAPlus",
|
||||
"shortcuts": "shortcuts",
|
||||
"customshortcuts": "customshortcuts",
|
||||
"transparencyEffects": "transparencyEffects",
|
||||
"selectedTheme": "selectedTheme",
|
||||
"timeFormat": "timeFormat",
|
||||
"animations": "animations",
|
||||
"defaultPage": "defaultPage",
|
||||
"devMode": "devMode"
|
||||
}), []);
|
||||
|
||||
const storageChangeListener = (changes: browser.Storage.StorageChange) => {
|
||||
for (const [key, { newValue }] of Object.entries(changes)) {
|
||||
if (key === "DarkMode") {
|
||||
if (key === "DarkMode" && newValue) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error - TODO: Fix this
|
||||
const stateKey = keyToStateMap[key as keyof StorageSettingsState];
|
||||
if (stateKey) {
|
||||
setSettingsState((prevState: SettingsState) => ({
|
||||
...prevState,
|
||||
[stateKey]: newValue
|
||||
}));
|
||||
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
browser.storage.onChanged.addListener(storageChangeListener);
|
||||
return () => {
|
||||
browser.storage.onChanged.removeListener(storageChangeListener);
|
||||
};
|
||||
});
|
||||
|
||||
const setStorage = (key: keyof StorageSettingsState, value: any) => {
|
||||
browser.storage.local.set({ [key]: value });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (previousSettingsState) {
|
||||
for (const [key, value] of Object.entries(settingsState)) {
|
||||
// @ts-expect-error - TODO: Fix this
|
||||
const storageKey = Object.keys(keyToStateMap).find(k => keyToStateMap[k] === key);
|
||||
// @ts-expect-error - TODO: Fix this
|
||||
if (storageKey && value !== previousSettingsState[key]) {
|
||||
setStorage(storageKey as keyof StorageSettingsState, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
previousSettingsState = settingsState;
|
||||
}, [settingsState, keyToStateMap])
|
||||
}
|
||||
|
||||
export default useSettingsState;
|
||||
@@ -1,3 +1,5 @@
|
||||
@import './components/ColourPicker.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -15,3 +17,36 @@
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-width {
|
||||
width: var(--tab-width);
|
||||
}
|
||||
|
||||
input {
|
||||
&:focus {
|
||||
box-shadow: unset !important;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
scrollbar-width: none !important;
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
max-height: 400px;
|
||||
}
|
||||
@@ -5,10 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BetterSEQTA+ Settings</title>
|
||||
</head>
|
||||
<body class="dark:bg-zinc-900">
|
||||
<div id="ExtensionPopup"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
<script type="module" src="./dark.ts"></script>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./index.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import "./index.css"
|
||||
import { mount } from "svelte"
|
||||
import type { ComponentType } from "svelte"
|
||||
import Settings from "./pages/settings.svelte"
|
||||
import IconFamily from '@/resources/fonts/IconFamily.woff'
|
||||
import browser from "webextension-polyfill"
|
||||
|
||||
export default function renderSvelte(
|
||||
Component: ComponentType | any,
|
||||
mountPoint: ShadowRoot | HTMLElement,
|
||||
props: Record<string, any> = {},
|
||||
) {
|
||||
const app = mount(Component, {
|
||||
target: mountPoint,
|
||||
props: {
|
||||
standalone: true,
|
||||
...props,
|
||||
},
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
function InjectCustomIcons() {
|
||||
console.info('[BetterSEQTA+] Injecting Icons')
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const mountPoint = document.getElementById('app')
|
||||
if (!mountPoint) {
|
||||
console.error('Mount point #app not found')
|
||||
throw new Error('Mount point #app not found')
|
||||
}
|
||||
|
||||
InjectCustomIcons()
|
||||
renderSvelte(Settings, mountPoint)
|
||||
Vendored
-2
@@ -4,5 +4,3 @@ declare module "*.png";
|
||||
declare module "*.svg";
|
||||
declare module "*.jpeg";
|
||||
declare module "*.jpg";
|
||||
|
||||
declare module 'react-best-gradient-color-picker';
|
||||
@@ -0,0 +1,24 @@
|
||||
import styles from "./index.css?inline"
|
||||
import { mount } from "svelte"
|
||||
import type { ComponentType } from "svelte"
|
||||
|
||||
export default function renderSvelte(
|
||||
Component: ComponentType | any,
|
||||
mountPoint: ShadowRoot | HTMLElement,
|
||||
props: Record<string, any> = {},
|
||||
) {
|
||||
const app = mount(Component, {
|
||||
target: mountPoint,
|
||||
props: {
|
||||
standalone: false,
|
||||
...props,
|
||||
},
|
||||
})
|
||||
|
||||
const style = document.createElement("style")
|
||||
style.setAttribute("type", "text/css")
|
||||
style.innerHTML = styles
|
||||
mountPoint.appendChild(style)
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { HashRouter, Route, Routes } from 'react-router-dom';
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import './index.css';
|
||||
import SettingsPage from './pages/SettingsPage.js';
|
||||
import browser from 'webextension-polyfill';
|
||||
import font from '../resources/fonts/IconFamily.woff'
|
||||
|
||||
import ThemeCreator from './pages/ThemeCreator';
|
||||
import Store from './pages/Store';
|
||||
|
||||
browser.storage.local.get().then(({ DarkMode }) => {
|
||||
if (DarkMode) document.documentElement.classList.add('dark');
|
||||
})
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.setAttribute("type", "text/css");
|
||||
style.classList.add('iconFamily')
|
||||
style.innerHTML = `
|
||||
@font-face {
|
||||
font-family: 'IconFamily';
|
||||
src: url('${browser.runtime.getURL(font)}') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('ExtensionPopup')!);
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary fallback={
|
||||
<div className="grid w-full h-screen text-center place-content-center dark:text-white">
|
||||
<h1 className="text-2xl font-bold">An error occurred 😭😭😭</h1>
|
||||
<p className="text-lg">Try clicking this button and see if it helps...</p>
|
||||
<button className='flex gap-2 p-2 px-4 mx-auto mt-4 rounded-lg dark:text-white bg-zinc-100 dark:bg-zinc-800/20 outline outline-white/20 w-fit' onClick={() => window.location.reload()}>
|
||||
<svg height="18" width="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="currentColor">
|
||||
<path d="M9.03,12.22c-.293-.293-.768-.293-1.061,0s-.293,.768,0,1.061l1.208,1.208c-.059,.002-.118,.012-.178,.012-3.032,0-5.5-2.467-5.5-5.5,0-1.616,.706-3.143,1.938-4.191,.315-.269,.354-.742,.085-1.057s-.74-.353-1.058-.085c-1.567,1.333-2.466,3.277-2.466,5.333,0,3.76,2.983,6.829,6.704,6.985l-.735,.735c-.293,.293-.293,.768,0,1.061,.146,.146,.338,.22,.53,.22s.384-.073,.53-.22l2.25-2.25c.293-.293,.293-.768,0-1.061l-2.25-2.25Z" fill="currentColor"/>
|
||||
<path d="M9.296,2.015l.735-.735c.293-.293,.293-.768,0-1.061s-.768-.293-1.061,0l-2.25,2.25c-.293,.293-.293,.768,0,1.061l2.25,2.25c.146,.146,.338,.22,.53,.22s.384-.073,.53-.22c.293-.293,.293-.768,0-1.061l-1.208-1.208c.059-.002,.118-.012,.177-.012,3.032,0,5.5,2.467,5.5,5.5,0,1.616-.706,3.143-1.938,4.191-.315,.269-.354,.742-.085,1.057,.148,.174,.359,.264,.571,.264,.172,0,.345-.059,.486-.179,1.567-1.333,2.466-3.277,2.466-5.333,0-3.76-2.983-6.829-6.704-6.985Z" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
}>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/settings" element={<SettingsPage standalone={true} />} />
|
||||
<Route path="/settings/embedded" element={<SettingsPage standalone={false} />} />
|
||||
<Route path="/store" element={<Store />} />
|
||||
<Route path="/themeCreator" element={<ThemeCreator />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -1,50 +0,0 @@
|
||||
import TabbedContainer from '../components/TabbedContainer';
|
||||
import Settings from './SettingsPage/Settings';
|
||||
import logo from '../../resources/icons/betterseqta-dark-full.png';
|
||||
import logoDark from '../../resources/icons/betterseqta-light-full.png';
|
||||
import { SettingsContextProvider } from '../SettingsContext';
|
||||
import Shortcuts from './SettingsPage/Shortcuts';
|
||||
import Picker from '../components/Picker';
|
||||
import Themes from './SettingsPage/Themes';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import { memo } from 'react';
|
||||
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
interface SettingsPage {
|
||||
standalone: boolean;
|
||||
}
|
||||
|
||||
const SettingsPage = ({ standalone }: SettingsPage) => {
|
||||
const tabs = [
|
||||
{
|
||||
title: 'Settings',
|
||||
content: <Settings />
|
||||
},
|
||||
{
|
||||
title: 'Shortcuts',
|
||||
content: <Shortcuts />
|
||||
},
|
||||
{
|
||||
title: 'Themes',
|
||||
content: <Themes />
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsContextProvider>
|
||||
<ToastContainer stacked toastStyle={{ borderRadius: '16px' }} draggable theme={document.body.classList.contains('dark') ? 'dark' : 'light'} />
|
||||
<div className={`flex flex-col w-[384px] shadow-2xl gap-2 bg-white ${ standalone ? 'h-[600px]' : 'h-[100vh] rounded-xl' } overflow-clip dark:bg-zinc-800 dark:text-white`}>
|
||||
<div className="grid border-b border-b-zinc-200/40 place-items-center">
|
||||
<img src={logo} className="w-4/5 dark:hidden" />
|
||||
<img src={logoDark} className="hidden w-4/5 dark:block" />
|
||||
<button onClick={() => browser.runtime.sendMessage({ type: 'currentTab', info: 'OpenChangelog' })} className="absolute w-8 h-8 text-lg rounded-xl font-IconFamily top-1 right-1 bg-zinc-100 dark:bg-zinc-700"></button>
|
||||
</div>
|
||||
<Picker />
|
||||
<TabbedContainer tabs={tabs} animations={false} />
|
||||
</div>
|
||||
</SettingsContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(SettingsPage);
|
||||
@@ -1,19 +0,0 @@
|
||||
const About = () => {
|
||||
return (
|
||||
<div className="flex flex-col overflow-y-scroll divide-y divide-zinc-100/50 dark:divide-zinc-700/50">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">About</h2>
|
||||
<p className="py-2">BetterSEQTA+ is a branch of BetterSEQTA which was originally developed by Nulkem. It was discontinued. So BetterSEQTA+ has come in to fill in that gap!</p>
|
||||
<p className="py-2">We are currently working on fixing bugs and adding new features. If you want to request a feature or report a bug, you can do so on
|
||||
<a className="pl-1 text-blue-500 underline hover:text-blue-600" href="https://github.com/BetterSEQTA/BetterSEQTA-plus" target="_blank">Github</a>.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="pt-2 text-lg font-bold">Credits</h2>
|
||||
<p className="py-2">Nulkem for the original extension, OG-RandomTechChannel, Crazypersonalph, and the current maintainer SethBurkart123</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default About;
|
||||
@@ -1,160 +0,0 @@
|
||||
import Switch from '../../components/Switch';
|
||||
import Slider from '../../components/Slider';
|
||||
import PickerSwatch from '../../components/PickerSwatch';
|
||||
import Select from '../../components/Select';
|
||||
|
||||
import { SettingsList } from '../../types/SettingsProps';
|
||||
import { useSettingsContext } from '../../SettingsContext';
|
||||
|
||||
import browser from 'webextension-polyfill';
|
||||
import { memo, useCallback } from 'react';
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
const { settingsState, setSettingsState } = useSettingsContext();
|
||||
|
||||
const handleDevModeToggle = useCallback(() => {
|
||||
const secretSequence = 'dev';
|
||||
let typedSequence = '';
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
typedSequence += event.key.toLowerCase();
|
||||
|
||||
if (typedSequence.includes(secretSequence)) {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
setSettingsState(prevState => ({
|
||||
...prevState,
|
||||
devMode: !prevState.devMode
|
||||
}));
|
||||
|
||||
alert(`Dev mode is now ${!settingsState.devMode ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
|
||||
// Clear the sequence after 2 seconds of inactivity
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
typedSequence = '';
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// Cleanup function to remove the event listener
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [setSettingsState, settingsState.devMode]);
|
||||
|
||||
const handleSettingChange = useCallback((key: string, value: boolean | string | number) => {
|
||||
setSettingsState(prevState => ({
|
||||
...prevState,
|
||||
[key]: value,
|
||||
}));
|
||||
}, [setSettingsState]);
|
||||
|
||||
const settings: SettingsList[] = [
|
||||
{
|
||||
title: "Transparency Effects",
|
||||
description: "Enables transparency effects on certain elements such as blur. (May impact battery life)",
|
||||
modifyElement: <Switch state={settingsState.transparencyEffects} onChange={(isOn: boolean) => handleSettingChange('transparencyEffects', isOn)} />
|
||||
},
|
||||
{
|
||||
title: "Animated Background",
|
||||
description: "Adds an animated background to BetterSEQTA. (May impact battery life)",
|
||||
modifyElement: <Switch state={settingsState.animatedBackground} onChange={(isOn: boolean) => handleSettingChange('animatedBackground', isOn)} />
|
||||
},
|
||||
{
|
||||
title: "Animated Background Speed",
|
||||
description: "Controls the speed of the animated background.",
|
||||
modifyElement: <Slider state={parseInt(settingsState.animatedBackgroundSpeed)} onChange={(value: number) => handleSettingChange('animatedBackgroundSpeed', value)} />
|
||||
},
|
||||
{
|
||||
title: "Custom Theme Colour",
|
||||
description: "Customise the overall theme colour of SEQTA Learn.",
|
||||
modifyElement: <PickerSwatch />
|
||||
},
|
||||
{
|
||||
title: "Edit Sidebar Layout",
|
||||
description: "Customise the sidebar layout.",
|
||||
modifyElement: <button onClick={() => browser.runtime.sendMessage({ type: 'currentTab', info: 'EditSidebar' })} className='px-4 py-1 text-[0.75rem] dark:bg-[#38373D] bg-[#DDDDDD] dark:text-white rounded-md'>Edit</button>
|
||||
},
|
||||
{
|
||||
title: "Animations",
|
||||
description: "Enables animations on certain pages.",
|
||||
modifyElement: <Switch state={settingsState.animations} onChange={(isOn: boolean) => handleSettingChange('animations', isOn)} />
|
||||
},
|
||||
{
|
||||
title: "Notification Collector",
|
||||
description: "Uncaps the 9+ limit for notifications, showing the real number.",
|
||||
modifyElement: <Switch state={settingsState.notificationCollector} onChange={(isOn: boolean) => handleSettingChange('notificationCollector', isOn)} />
|
||||
},
|
||||
{
|
||||
title: "Lesson Alerts",
|
||||
description: "Sends a native browser notification ~5 minutes prior to lessons.",
|
||||
modifyElement: <Switch state={settingsState.lessonAlerts} onChange={(isOn: boolean) => handleSettingChange('lessonAlerts', isOn)} />
|
||||
},
|
||||
{
|
||||
title: "12 Hour Time",
|
||||
description: "Prefer 12 hour time format for SEQTA",
|
||||
modifyElement: <Switch state={settingsState.timeFormat == "12"} onChange={(isOn: boolean) => handleSettingChange('timeFormat', isOn ? "12" : "24")} />
|
||||
},
|
||||
{
|
||||
title: "Default Page",
|
||||
description: "The page to load when SEQTA Learn is opened.",
|
||||
modifyElement: <Select state={settingsState.defaultPage} onChange={(value: string) => handleSettingChange('defaultPage', value)} options={[
|
||||
{ value: 'home', label: 'Home' },
|
||||
{ value: 'dashboard', label: 'Dashboard' },
|
||||
{ value: 'timetable', label: 'Timetable' },
|
||||
{ value: 'welcome', label: 'Welcome' },
|
||||
{ value: 'messages', label: 'Messages' },
|
||||
{ value: 'documents', label: 'Documents' },
|
||||
{ value: 'reports', label: 'Reports' },
|
||||
]} />
|
||||
},
|
||||
{
|
||||
title: "BetterSEQTA+",
|
||||
description: "Enables BetterSEQTA+ features",
|
||||
modifyElement: <Switch state={settingsState.betterSEQTAPlus} onChange={(isOn: boolean) => handleSettingChange('betterSEQTAPlus', isOn)} />
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col -mt-4 overflow-y-scroll divide-y divide-zinc-100 dark:divide-zinc-700">
|
||||
{settings.map((setting, index) => (
|
||||
<div className="flex items-center justify-between px-4 py-3" key={index}>
|
||||
<div className="pr-4">
|
||||
<h2 onClick={setting.title.includes('BetterSEQTA+') ? handleDevModeToggle : undefined} className="text-sm font-bold">{setting.title}</h2>
|
||||
<p className="text-xs">{setting.description}</p>
|
||||
</div>
|
||||
<div>
|
||||
{setting.modifyElement}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{settingsState.devMode && (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="pr-4">
|
||||
<h2 className="text-sm font-bold">Dev Mode</h2>
|
||||
<p className="text-xs">Enables dev mode</p>
|
||||
</div>
|
||||
<Switch state={settingsState.devMode} onChange={(isOn: boolean) => handleSettingChange('devMode', isOn)} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="pr-4">
|
||||
<h2 className="text-sm font-bold">Sensitive Hider</h2>
|
||||
<p className="text-xs">Replace sensitive content with mock data</p>
|
||||
</div>
|
||||
<button onClick={() => browser.runtime.sendMessage({ type: 'currentTab', info: 'HideSensitive' })} className='px-4 py-1 text-[0.75rem] dark:bg-[#38373D] bg-[#DDDDDD] dark:text-white rounded-md'>Hide</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Settings);
|
||||
@@ -1,147 +0,0 @@
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import Switch from "../../components/Switch";
|
||||
import { useSettingsContext } from "../../SettingsContext";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { CustomShortcut } from "../../types/AppProps";
|
||||
|
||||
function formatUrl(inputUrl: string) {
|
||||
const protocolRegex = /^(http:\/\/|https:\/\/|ftp:\/\/)/;
|
||||
return protocolRegex.test(inputUrl) ? inputUrl : `https://${inputUrl}`;
|
||||
}
|
||||
|
||||
const Shortcuts = memo(() => {
|
||||
const { settingsState, setSettingsState } = useSettingsContext();
|
||||
|
||||
const [newTitle, setNewTitle] = useState<string>("");
|
||||
const [isFormVisible, setFormVisible] = useState(false);
|
||||
const [newURL, setNewURL] = useState<string>("");
|
||||
|
||||
const switchChange = useCallback((shortcutName: string, isOn: boolean) => {
|
||||
setSettingsState((prevState) => {
|
||||
const updatedShortcuts = prevState.shortcuts.map((shortcut) =>
|
||||
shortcut.name === shortcutName ? { ...shortcut, enabled: isOn } : shortcut
|
||||
);
|
||||
return { ...prevState, shortcuts: updatedShortcuts };
|
||||
});
|
||||
}, [setSettingsState]);
|
||||
|
||||
const isValidTitle = useCallback((title: string) => title.trim() !== "", []);
|
||||
|
||||
const isValidURL = useCallback((url: string) => {
|
||||
const pattern = new RegExp("^(https?:\\/\\/)?[\\w.-]+(?:\\.[\\w\\-]+)*(?::\\d+)?(/[\\w\\-./]*)*$", "i");
|
||||
return pattern.test(url);
|
||||
}, []);
|
||||
|
||||
const addNewCustomShortcut = useCallback(() => {
|
||||
if (isValidTitle(newTitle) && isValidURL(newURL)) {
|
||||
const newShortcut: CustomShortcut = { name: newTitle.trim(), url: formatUrl(newURL).trim(), icon: newTitle[0] };
|
||||
const updatedCustomShortcuts = [...settingsState.customshortcuts, newShortcut];
|
||||
setSettingsState({ ...settingsState, customshortcuts: updatedCustomShortcuts });
|
||||
|
||||
setNewTitle("");
|
||||
setNewURL("");
|
||||
|
||||
setFormVisible(false);
|
||||
} else {
|
||||
// Replace with a more user-friendly way to display errors
|
||||
alert("Please enter a valid title and URL.");
|
||||
}
|
||||
}, [newTitle, newURL, isValidTitle, isValidURL, setSettingsState]);
|
||||
|
||||
const deleteCustomShortcut = useCallback((index: number) => {
|
||||
setSettingsState((prevState) => ({
|
||||
...prevState,
|
||||
customshortcuts: prevState.customshortcuts.filter((_, i) => i !== index),
|
||||
}));
|
||||
}, [setSettingsState]);
|
||||
|
||||
const toggleForm = useCallback(() => {
|
||||
setFormVisible((isVisible) => !isVisible);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col divide-y divide-zinc-100 dark:divide-zinc-700">
|
||||
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={isFormVisible ? { opacity: 1, height: "auto" } : { opacity: 0, height: 0 }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ type: "spring", damping: 20 }}
|
||||
>
|
||||
{isFormVisible &&
|
||||
<div className="flex flex-col items-center mb-4">
|
||||
<motion.input
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="w-full p-2 rounded-md bg-zinc-100 dark:bg-zinc-700 focus:outline-none"
|
||||
type="text"
|
||||
placeholder="Shortcut Name"
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
/>
|
||||
<motion.input
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="w-full p-2 my-2 rounded-md bg-zinc-100 dark:bg-zinc-700 focus:outline-none"
|
||||
type="text"
|
||||
placeholder="URL eg. https://google.com"
|
||||
value={newURL}
|
||||
onChange={(e) => setNewURL(e.target.value)}
|
||||
/>
|
||||
<motion.button
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="w-full px-4 py-2 text-white bg-blue-500 rounded-md"
|
||||
onClick={ addNewCustomShortcut }
|
||||
>
|
||||
Add
|
||||
</motion.button>
|
||||
</div>
|
||||
}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
{!isFormVisible && (
|
||||
<button
|
||||
className="w-full px-4 py-2 mb-4 text-white bg-blue-500 rounded"
|
||||
onClick={toggleForm}
|
||||
>
|
||||
Add Custom Shortcut
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Shortcuts Section */}
|
||||
{settingsState.shortcuts ? (
|
||||
settingsState.shortcuts.map((shortcut, index) => shortcut.name && (
|
||||
<div className="flex items-center justify-between px-4 py-3" key={index}>
|
||||
{shortcut.name}
|
||||
<Switch state={shortcut.enabled} onChange={(isOn) => switchChange(shortcut.name, isOn)} />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p>Loading shortcuts...</p>
|
||||
)}
|
||||
|
||||
{/* Custom Shortcuts Section */}
|
||||
{settingsState.customshortcuts ? (
|
||||
settingsState.customshortcuts.map((shortcut, index) => (
|
||||
<div className="flex items-center justify-between px-4 py-3" key={index}>
|
||||
{shortcut.name}
|
||||
<button onClick={() => deleteCustomShortcut(index)}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p>Loading custom shortcuts...</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Shortcuts;
|
||||
@@ -1,30 +0,0 @@
|
||||
import { createRef, FC, useState } from 'react';
|
||||
import BackgroundSelector from '../../components/BackgroundSelector';
|
||||
import ThemeSelector from '../../components/ThemeSelector';
|
||||
import { memo } from 'react';
|
||||
|
||||
type ThemeSelectorRef = {
|
||||
disableTheme: () => void;
|
||||
};
|
||||
|
||||
const Themes: FC = () => {
|
||||
const [isEditMode, setIsEditMode] = useState<boolean>(false);
|
||||
const themeSelectorRef = createRef<ThemeSelectorRef>();
|
||||
|
||||
const disableTheme = async () => {
|
||||
themeSelectorRef?.current?.disableTheme();
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<button className="absolute top-12 z-20 right-0 p-2 text-[0.8rem] text-blue-500" onClick={() => setIsEditMode(!isEditMode)}>
|
||||
{isEditMode ? 'Done' : 'Edit'}
|
||||
</button>
|
||||
<BackgroundSelector disableTheme={disableTheme} isEditMode={isEditMode} />
|
||||
<ThemeSelector ref={themeSelectorRef} isEditMode={isEditMode} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Themes);
|
||||
@@ -1,335 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import Header from '../components/store/header';
|
||||
import { Autoplay } from 'swiper/modules';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
import 'swiper/css';
|
||||
import 'swiper/css/pagination';
|
||||
import 'swiper/css/scrollbar';
|
||||
import 'swiper/css/autoplay';
|
||||
import SpinnerIcon from '../components/LoadingSpinner';
|
||||
import localforage from 'localforage';
|
||||
import { StoreDownloadTheme } from '../../seqta/ui/themes/downloadTheme';
|
||||
|
||||
const textVariants = {
|
||||
hidden: { opacity: 0, y: 60 },
|
||||
visible: { opacity: 1, y: 0, transition: {
|
||||
type: 'spring',
|
||||
bounce: 0,
|
||||
stiffness: 80,
|
||||
damping: 12
|
||||
} },
|
||||
};
|
||||
|
||||
const containerVariants = {
|
||||
hidden: {
|
||||
y: '100vh',
|
||||
},
|
||||
visible: {
|
||||
y: 0,
|
||||
transition: {
|
||||
staggerChildren: 0.05,
|
||||
type: 'spring',
|
||||
bounce: 0,
|
||||
stiffness: 400,
|
||||
damping: 50
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type Theme = {
|
||||
name: string;
|
||||
description: string;
|
||||
coverImage: string;
|
||||
marqueeImage: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
type ThemesResponse = {
|
||||
themes: Theme[];
|
||||
}
|
||||
|
||||
export const DeleteDownloadedTheme = async (themeID: string) => {
|
||||
console.debug('DeleteDownloaded Theme:', themeID)
|
||||
await localforage.removeItem(themeID);
|
||||
|
||||
const availableThemesList = await localforage.getItem('availableThemes') as string[];
|
||||
const updatedThemesList = availableThemesList.filter(theme => theme !== themeID);
|
||||
|
||||
await localforage.setItem('availableThemes', updatedThemesList);
|
||||
}
|
||||
|
||||
const Store = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const swiperCover = useRef<any | null>(null);
|
||||
const [gridThemes, setGridThemes] = useState<Theme[]>([]);
|
||||
const [filteredThemes, setFilteredThemes] = useState<Theme[]>([]);
|
||||
const [coverThemes, setCoverThemes] = useState<Theme[]>([]);
|
||||
const [installingThemes, setInstallingThemes] = useState<string[]>([]);
|
||||
const [currentThemes, setCurrentThemes] = useState<string[]>([]);
|
||||
const [displayTheme, setDisplayTheme] = useState<Theme | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
const fetchThemes = async () => {
|
||||
try {
|
||||
const response = await fetch(`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${(new Date()).getTime()}`, { cache: 'no-store' });
|
||||
const data: ThemesResponse = await response.json();
|
||||
setGridThemes(data.themes);
|
||||
// Select up to 3 random themes to display in coverThemes
|
||||
const shuffled = [...data.themes].sort(() => 0.5 - Math.random());
|
||||
setCoverThemes(shuffled.slice(0, 3));
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch themes', error);
|
||||
// Retry after 5 seconds
|
||||
setTimeout(fetchThemes, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
document.title = 'BetterSEQTA+ Store';
|
||||
|
||||
fetchThemes();
|
||||
const availableThemes = await localforage.getItem('availableThemes') as string[] | null;
|
||||
if (availableThemes) {
|
||||
setCurrentThemes(availableThemes)
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredThemes(gridThemes.filter(theme =>
|
||||
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
));
|
||||
}, [searchTerm, gridThemes]);
|
||||
|
||||
const downloadTheme = (id: string) => {
|
||||
const themeContent = gridThemes.find(theme => theme.id === id);
|
||||
if (!themeContent) {
|
||||
alert('There was an error, The theme was not found!')
|
||||
return
|
||||
}
|
||||
|
||||
setInstallingThemes([...installingThemes, id]);
|
||||
|
||||
StoreDownloadTheme({ themeContent }).then(() => {
|
||||
setInstallingThemes(installingThemes.filter(theme => theme !== id));
|
||||
setCurrentThemes([...currentThemes, id]);
|
||||
});
|
||||
};
|
||||
|
||||
const removeTheme = async (id: string) => {
|
||||
const themeContent = gridThemes.find(theme => theme.id === id);
|
||||
if (!themeContent) {
|
||||
alert('There was an error, The theme was not found!')
|
||||
return
|
||||
}
|
||||
|
||||
setInstallingThemes([...installingThemes, id]);
|
||||
|
||||
DeleteDownloadedTheme(id).then(() => {
|
||||
setInstallingThemes(installingThemes.filter(theme => theme !== id));
|
||||
setCurrentThemes(currentThemes.filter(theme => theme !== id));
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen overflow-y-scroll pt-[4.25rem] bg-zinc-200/50 dark:bg-zinc-900 dark:text-white">
|
||||
|
||||
<Header searchTerm={searchTerm} setSearchTerm={setSearchTerm} />
|
||||
|
||||
{/* loader */}
|
||||
<div className={`flex items-center justify-center w-full h-full ${!loading && 'hidden'}`}>
|
||||
<SpinnerIcon className="w-16 h-16" />
|
||||
</div>
|
||||
|
||||
<div className={`px-24 py-12 ${loading && 'hidden'}`}>
|
||||
<div className={`relative w-full rounded-xl overflow-clip transition-opacity ${searchTerm == '' ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<motion.div className='overflow-clip' animate={{
|
||||
height: searchTerm == '' ? 'auto' : '0px'
|
||||
}} transition={{
|
||||
type: 'spring',
|
||||
bounce: 0,
|
||||
duration: 1,
|
||||
stiffness: 200,
|
||||
damping: 30
|
||||
}}>
|
||||
<Swiper
|
||||
ref={swiperCover}
|
||||
spaceBetween={20}
|
||||
slidesPerView={1}
|
||||
loop={true}
|
||||
className='w-full aspect-[8/3]'
|
||||
modules={[Autoplay]}
|
||||
autoplay={{
|
||||
delay: 5000,
|
||||
stopOnLastSlide: false,
|
||||
disableOnInteraction: false,
|
||||
pauseOnMouseEnter: true
|
||||
}}
|
||||
>
|
||||
{ [...coverThemes, ...coverThemes].map((theme, index) => (
|
||||
<SwiperSlide className='relative cursor-pointer rounded-xl overflow-clip' onClick={() => setDisplayTheme(theme)} key={index}>
|
||||
<img
|
||||
src={theme.marqueeImage}
|
||||
alt="Theme Preview"
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
<div className='absolute bottom-0 left-0 p-8 z-[1]'>
|
||||
<h2 className='text-4xl font-bold text-white'>{theme.name}</h2>
|
||||
<p className='text-lg text-white'>{theme.description}</p>
|
||||
</div>
|
||||
|
||||
{/* shadow from the bottom of the image */}
|
||||
<div className='absolute bottom-0 left-0 w-full h-1/2 bg-gradient-to-t from-black/80 to-transparent' />
|
||||
</SwiperSlide>
|
||||
)) }
|
||||
</Swiper>
|
||||
</motion.div>
|
||||
|
||||
<div className={displayTheme ? 'pointer-events-auto' : 'pointer-events-none'}>
|
||||
<AnimatePresence>
|
||||
{displayTheme && (
|
||||
<motion.div
|
||||
className={`fixed inset-0 z-50 flex items-end justify-center bg-black bg-opacity-70`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setDisplayTheme(null)}
|
||||
>
|
||||
<motion.div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-xl h-[95%] p-4 bg-white rounded-t-2xl dark:bg-zinc-800 overflow-scroll"
|
||||
exit={{ y: "100vh" }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<motion.div className="relative h-auto">
|
||||
<motion.button
|
||||
className="absolute top-0 right-0 p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200"
|
||||
onClick={() => setDisplayTheme(null)}
|
||||
variants={textVariants}
|
||||
>
|
||||
{'\ued8a'}
|
||||
</motion.button>
|
||||
<motion.h2 className="mb-4 text-2xl font-bold" variants={textVariants}>
|
||||
{displayTheme.name}
|
||||
</motion.h2>
|
||||
<motion.img src={displayTheme.marqueeImage} alt="Theme Cover" className="object-cover w-full mb-4 rounded-md" variants={textVariants} />
|
||||
<motion.p className="mb-4 text-gray-700 dark:text-gray-300" variants={textVariants}>
|
||||
{displayTheme.description}
|
||||
</motion.p>
|
||||
{
|
||||
currentThemes.includes(displayTheme.id) ?
|
||||
<motion.button
|
||||
variants={textVariants}
|
||||
onClick={() => removeTheme(displayTheme.id)}
|
||||
className="flex px-4 py-2 mt-4 ml-auto rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200 focus:outline-none focus:ring-2 focus:ring-zinc-800 focus:ring-offset-2">
|
||||
{ installingThemes.includes(displayTheme.id) ?
|
||||
<>
|
||||
<SpinnerIcon className="w-4 h-4 mr-2" />
|
||||
Removing...
|
||||
</> :
|
||||
<> Remove </>
|
||||
}
|
||||
</motion.button> :
|
||||
<motion.button
|
||||
variants={textVariants}
|
||||
onClick={() => downloadTheme(displayTheme.id)}
|
||||
className="flex px-4 py-2 mt-4 ml-auto rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200 focus:outline-none focus:ring-2 focus:ring-zinc-800 focus:ring-offset-2">
|
||||
{ installingThemes.includes(displayTheme.id) ?
|
||||
<>
|
||||
<SpinnerIcon className="w-4 h-4 mr-2" />
|
||||
Installing...
|
||||
</> :
|
||||
<> Install </>
|
||||
}
|
||||
</motion.button>
|
||||
}
|
||||
|
||||
<motion.div className="my-8 border-b border-zinc-200 dark:border-zinc-700" variants={textVariants} />
|
||||
|
||||
<motion.h3 className="mb-4 text-lg font-bold" variants={textVariants}>
|
||||
Similar Themes
|
||||
</motion.h3>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{gridThemes.filter(theme => theme.id !== displayTheme.id).sort((a, b) => a.name.localeCompare(displayTheme.name) - b.name.localeCompare(displayTheme.name)).map((theme, index) => (
|
||||
<motion.div key={index} onClick={() => { setDisplayTheme(null); setDisplayTheme(theme); }} className='w-full cursor-pointer' variants={textVariants}>
|
||||
<div className="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group group/card flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border">
|
||||
<div className="absolute z-10 mb-1 text-xl font-bold text-white transition-all duration-500 group-hover:-translate-y-0.5 bottom-1 left-3">
|
||||
{theme.name}
|
||||
</div>
|
||||
<div className='absolute bottom-0 z-0 w-full h-3/4 bg-gradient-to-t from-black/80 to-transparent' />
|
||||
<img src={theme.coverImage} alt="Theme Preview" className="object-cover w-full h-48" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* pagination */}
|
||||
<div className='absolute z-10 flex gap-2 bottom-2 right-2'>
|
||||
|
||||
<button onClick={ () => {swiperCover.current?.swiper.slidePrev() }} className='flex items-center justify-center w-8 h-8 text-white bg-black bg-opacity-50 rounded-full dark:bg-zinc-800 dark:bg-opacity-50'>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button onClick={ () => {swiperCover.current?.swiper.slideNext() }} className='flex items-center justify-center w-8 h-8 text-white bg-black bg-opacity-50 rounded-full dark:bg-zinc-800 dark:bg-opacity-50'>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredThemes.map((theme, index) => (
|
||||
<div onClick={() => setDisplayTheme(theme)} key={index} className='w-full cursor-pointer'>
|
||||
<div className="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group group/card flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border">
|
||||
<div className="absolute z-10 mb-1 text-xl font-bold text-white transition-all duration-500 group-hover:-translate-y-0.5 bottom-1 left-3">
|
||||
{theme.name}
|
||||
</div>
|
||||
<div className='absolute bottom-0 z-0 w-full h-3/4 bg-gradient-to-t from-black/80 to-transparent' />
|
||||
<div
|
||||
className='w-full'>
|
||||
<img src={theme.coverImage} alt="Theme Preview" className="object-cover w-full h-48 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<a href="https://betterseqta.gitbook.io/betterseqta-docs" className='w-full cursor-pointer'>
|
||||
<div className="bg-zinc-50 h-48 w-full transition-all hover:scale-105 duration-500 relative justify-center items-center group group/card flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] rounded-xl overflow-clip border">
|
||||
<div className="text-2xl font-IconFamily">{'\uecb3'}</div>
|
||||
<div className="text-xl font-bold text-center transition-all duration-500 dark:text-white">
|
||||
Got a Theme Idea?
|
||||
<p className="text-lg font-light subtitle">Transform it into a stunning theme!</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{filteredThemes.length == 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center w-full text-center h-96">
|
||||
<h1 className="mt-4 text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-5xl">That doesnt exist! 😭😭😭</h1>
|
||||
<p className="mt-6 text-lg leading-7 text-zinc-600 dark:text-zinc-300">Sorry, we couldn’t find the theme you’re looking for. Maybe... you could create it?</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Store;
|
||||
@@ -1,341 +0,0 @@
|
||||
import CodeEditor from '../components/CodeEditor';
|
||||
import { useEffect, useState } from 'react';
|
||||
import ColorPicker from 'react-best-gradient-color-picker';
|
||||
import Accordion from '../components/Accordian';
|
||||
import Switch from '../components/Switch';
|
||||
import { sendThemeUpdate } from '../hooks/ThemeManagment';
|
||||
import { MoonIcon, PlusIcon, SunIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { CustomTheme, CustomThemeBase64 } from '../types/CustomThemes';
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
function ThemeCreator() {
|
||||
// default settings for new themes
|
||||
const [theme, setTheme] = useState<CustomTheme>({
|
||||
id: uuidv4(),
|
||||
name: '',
|
||||
description: '',
|
||||
defaultColour: '',
|
||||
CanChangeColour: true,
|
||||
allowBackgrounds: true,
|
||||
CustomCSS: '',
|
||||
CustomImages: [],
|
||||
coverImage: null,
|
||||
isEditable: true,
|
||||
hideThemeName: false,
|
||||
forceDark: undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const getTheme = async (themeID: string) => {
|
||||
const theme = await browser.runtime.sendMessage({
|
||||
type: 'currentTab',
|
||||
info: 'GetTheme',
|
||||
body: {
|
||||
themeID: themeID,
|
||||
}
|
||||
}) as CustomThemeBase64 | undefined;
|
||||
|
||||
if (theme) {
|
||||
// base64toblob to convert it to a blob url
|
||||
const CustomImages = theme.CustomImages.map((image) => {
|
||||
const base64Index = image.url.indexOf(',') + 1;
|
||||
const imageBase64 = image.url.substring(base64Index);
|
||||
|
||||
// Convert base64 to blob
|
||||
const byteCharacters = atob(imageBase64);
|
||||
const byteNumbers = new Uint8Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], { type: 'image/png' });
|
||||
|
||||
return {
|
||||
id: image.id,
|
||||
blob: blob,
|
||||
variableName: image.variableName,
|
||||
};
|
||||
});
|
||||
|
||||
const coverImageBase64 = theme.coverImage;
|
||||
let coverImageBlob = null;
|
||||
|
||||
if (coverImageBase64) {
|
||||
const base64Index = coverImageBase64.indexOf(',') + 1;
|
||||
const imageBase64 = coverImageBase64.substring(base64Index);
|
||||
|
||||
// Convert base64 to blob
|
||||
const byteCharacters = atob(imageBase64);
|
||||
const byteNumbers = new Uint8Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
coverImageBlob = new Blob([byteArray], { type: 'image/png' });
|
||||
}
|
||||
|
||||
setTheme({
|
||||
...theme,
|
||||
CustomImages,
|
||||
coverImage: coverImageBlob
|
||||
});
|
||||
|
||||
sendThemeUpdate({
|
||||
...theme,
|
||||
CustomImages: CustomImages,
|
||||
}, false, true);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
// get ThemeID from URL params
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const themeID = urlParams.get('themeID');
|
||||
|
||||
if (themeID) {
|
||||
getTheme(themeID);
|
||||
}
|
||||
|
||||
}, []);
|
||||
|
||||
const generateImageId = () => {
|
||||
return Math.random().toString(36).substr(2, 9);
|
||||
};
|
||||
|
||||
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
const imageBlob = await fetch(reader.result as string).then(res => res.blob());
|
||||
const imageId = generateImageId();
|
||||
const variableName = `custom-image-${theme.CustomImages.length}`;
|
||||
const updatedTheme = {
|
||||
...theme,
|
||||
CustomImages: [...theme.CustomImages, { id: imageId, blob: imageBlob, variableName }],
|
||||
};
|
||||
setTheme(updatedTheme);
|
||||
sendThemeUpdate(updatedTheme, false, true);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImage = (imageId: string) => {
|
||||
const updatedTheme = {
|
||||
...theme,
|
||||
CustomImages: theme.CustomImages.filter((image) => image.id !== imageId),
|
||||
};
|
||||
setTheme(updatedTheme);
|
||||
};
|
||||
|
||||
const handleImageVariableChange = (imageId: string, variableName: string) => {
|
||||
const updatedTheme = {
|
||||
...theme,
|
||||
CustomImages: theme.CustomImages.map((image) =>
|
||||
image.id === imageId ? { ...image, variableName } : image
|
||||
),
|
||||
};
|
||||
setTheme(updatedTheme);
|
||||
};
|
||||
|
||||
function CodeUpdate(value: string) {
|
||||
setTheme((prevTheme) => ({
|
||||
...prevTheme,
|
||||
CustomCSS: value,
|
||||
}));
|
||||
}
|
||||
|
||||
const saveTheme = async () => {
|
||||
sendThemeUpdate(theme, true)
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
sendThemeUpdate(theme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<div className='w-full min-h-[100vh] bg-zinc-100 dark:bg-zinc-800 dark:text-white transition duration-30'>
|
||||
<div className='flex flex-col p-2'>
|
||||
<h1 className='text-xl font-semibold'>Theme Creator</h1>
|
||||
<a href="https://betterseqta.gitbook.io/betterseqta-docs" target="_blank" className='text-sm font-light text-zinc-500 dark:text-zinc-400'>
|
||||
<span className="no-underline font-IconFamily pr-0.5">{'\ueb44'}</span>
|
||||
<span className="underline">
|
||||
Need help? Check out the docs!
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<div className='pb-2 text-sm'>Theme Name</div>
|
||||
<input
|
||||
id='themeName'
|
||||
type='text'
|
||||
placeholder='What is your theme called?'
|
||||
value={theme.name}
|
||||
onChange={e => setTheme({ ...theme, name: e.target.value })}
|
||||
className='w-full p-2 mb-4 transition-all duration-300 rounded-lg focus:outline-none ring-0 focus:ring-1 ring-zinc-100 dark:ring-zinc-700 dark:bg-zinc-900 dark:text-white' />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='pb-2 text-sm'>Description <span className='italic font-light opacity-80'>(optional)</span></div>
|
||||
<textarea
|
||||
id='themeDescription'
|
||||
placeholder="Don't worry, this one's optional!"
|
||||
value={theme.description}
|
||||
onChange={e => setTheme({ ...theme, description: e.target.value })}
|
||||
className='w-full p-2 rounded-lg focus:outline-none ring-0 focus:ring-1 ring-zinc-100 dark:ring-zinc-700 dark:bg-zinc-900 dark:text-white' />
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<p className='pr-2 text-sm font-semibold'>Cover Image <span className='italic font-light opacity-80'>(optional)</span></p>
|
||||
<p className='pb-3 text-sm'>Upload an image to use as the cover image for your theme. <span className='italic font-light'>Recommended resolution: 640x128</span></p>
|
||||
|
||||
<div className="relative flex justify-center w-full gap-1 overflow-hidden transition rounded-lg aspect-theme group place-items-center bg-zinc-100 dark:bg-zinc-900">
|
||||
<PlusIcon className={`transition pointer-events-none z-30 ${ theme.coverImage ? 'opacity-0 group-hover:opacity-100' : ''}`} height={18} />
|
||||
<span className={`dark:text-white pointer-events-none z-30 transition ${ theme.coverImage ? 'opacity-0 group-hover:opacity-100' : ''}`}>{theme.coverImage ? 'Change' : 'Add'} cover image</span>
|
||||
<input type="file" accept='image/*' onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
const imageBlob = await fetch(reader.result as string).then(res => res.blob());
|
||||
setTheme({ ...theme, coverImage: imageBlob });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}} className="absolute inset-0 z-10 w-full h-full opacity-0 cursor-pointer" />
|
||||
{
|
||||
!theme.hideThemeName && theme.coverImage ?
|
||||
<div className="absolute z-30 transition-opacity opacity-100 pointer-events-none group-hover:opacity-0">{theme.name}</div> : <></>
|
||||
}
|
||||
{
|
||||
theme.coverImage &&
|
||||
<>
|
||||
<div className="absolute z-20 w-full h-full transition-opacity opacity-0 pointer-events-none group-hover:opacity-100 bg-black/20"></div>
|
||||
<img src={URL.createObjectURL(theme.coverImage as Blob)} alt='Cover Image' className="absolute z-0 object-cover w-full h-full rounded" />
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between pt-4'>
|
||||
<div>
|
||||
<div className='pr-2 text-sm font-semibold'>Hide Name</div>
|
||||
<div className='pr-2 text-[11px]'>Useful when your cover image contains text</div>
|
||||
</div>
|
||||
<Switch state={theme.hideThemeName} onChange={value => setTheme({ ...theme, hideThemeName: value })} />
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* <div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<div className='pr-2 text-sm font-semibold'>Custom Theme Colour</div>
|
||||
<div className='pr-2 text-[11px]'>Allow users to change the theme colour</div>
|
||||
</div>
|
||||
<Switch state={theme.CanChangeColour} onChange={value => setTheme({ ...theme, CanChangeColour: value })} />
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between pt-4'>
|
||||
<div>
|
||||
<div className='pr-2 text-sm font-semibold'>Custom Backgrounds</div>
|
||||
<div className='pr-2 text-[11px]'>Allow users to set image and video backgrounds</div>
|
||||
</div>
|
||||
<Switch state={theme.allowBackgrounds} onChange={value => setTheme({ ...theme, allowBackgrounds: value })} />
|
||||
</div>
|
||||
|
||||
<Divider /> */}
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<div className='pr-2 text-sm font-semibold'>Force Theme</div>
|
||||
<div className='pr-2 text-[11px]'>Force users to use either dark or light mode</div>
|
||||
</div>
|
||||
|
||||
<Switch state={theme.forceDark == undefined ? false : true} onChange={value => setTheme({ ...theme, forceDark: value ? false : undefined })} />
|
||||
</div>
|
||||
|
||||
{ theme.forceDark != undefined &&
|
||||
<div className='flex items-center justify-between pt-4'>
|
||||
<div>
|
||||
<div className='pr-2 text-sm font-semibold'>Force {theme.forceDark ? 'Dark' : 'Light'} Mode</div>
|
||||
<div className='pr-2 text-[11px]'>Force users to use {theme.forceDark ? 'dark' : 'light'} mode</div>
|
||||
</div>
|
||||
<button className='flex items-center justify-center p-2 transition rounded-lg bg-zinc-100 dark:bg-zinc-700' onClick={() => setTheme({ ...theme, forceDark: !theme.forceDark })}>
|
||||
{theme.forceDark ? <MoonIcon className='w-6 h-6' /> : <SunIcon className='w-6 h-6' />}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Accordion defaultOpened title='Default Theme Colour'>
|
||||
<div className='p-2 mt-2 bg-white rounded-lg w-fit dark:bg-zinc-900'>
|
||||
<ColorPicker
|
||||
width={278}
|
||||
disableDarkMode={true}
|
||||
hideInputs={true}
|
||||
value={theme.defaultColour}
|
||||
onChange={(color: string) => setTheme({ ...theme, defaultColour: color })} />
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
<Divider />
|
||||
|
||||
<h2 className='pb-1 text-lg font-semibold'>Custom Images</h2>
|
||||
<p className='pb-3 text-sm'>Upload images to include them in your theme via CSS variables (gifs supported).</p>
|
||||
|
||||
{theme.CustomImages.map((image) => (
|
||||
<div key={image.id} className="flex items-center h-16 py-2 mb-4 bg-white rounded-lg shadow-lg dark:bg-zinc-900">
|
||||
<div className="flex-1 h-full ">
|
||||
<img src={URL.createObjectURL(image.blob)} alt={image.variableName} className="object-contain h-full rounded" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={image.variableName}
|
||||
onChange={(e) => handleImageVariableChange(image.id, e.target.value)}
|
||||
placeholder="CSS Variable Name"
|
||||
className="flex-grow flex-[3] w-full p-2 transition-all duration-300 rounded-lg focus:outline-none ring-0 focus:ring-1 ring-zinc-100 dark:ring-zinc-700 dark:bg-zinc-800/50 dark:text-white"
|
||||
/>
|
||||
<button onClick={() => handleRemoveImage(image.id)} className="p-2 ml-1 transition dark:text-white">
|
||||
<XMarkIcon height={20} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="relative flex justify-center w-full h-8 gap-1 overflow-hidden transition rounded-lg place-items-center bg-zinc-100 dark:bg-zinc-900">
|
||||
<PlusIcon height={18} />
|
||||
<span className='dark:text-white'>Add image</span>
|
||||
<input type="file" accept='image/*' onChange={handleImageUpload} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Accordion defaultOpened title='Custom CSS'>
|
||||
<CodeEditor
|
||||
className='mt-2'
|
||||
height='800px'
|
||||
value={theme.CustomCSS}
|
||||
setValue={CodeUpdate} />
|
||||
</Accordion>
|
||||
|
||||
<Divider />
|
||||
|
||||
<button disabled={ theme.name === '' } onClick={saveTheme} className='w-full px-4 py-2 text-white transition bg-blue-500 rounded-lg dark:disabled:bg-zinc-700 disabled:bg-zinc-100 disabled:cursor-not-allowed dark:text-white'>
|
||||
Save theme
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <div className='w-full h-0.5 my-4 bg-zinc-200 dark:bg-zinc-700'></div>;
|
||||
}
|
||||
|
||||
export default ThemeCreator;
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import TabbedContainer from '../components/TabbedContainer.svelte';
|
||||
import Settings from './settings/general.svelte';
|
||||
import Shortcuts from './settings/shortcuts.svelte';
|
||||
import Theme from './settings/theme.svelte';
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
import { standalone as StandaloneStore } from '../utils/standalone.svelte';
|
||||
import { onMount } from 'svelte'
|
||||
import { initializeSettingsState, settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||
|
||||
import { closeExtensionPopup, OpenAboutPage, OpenWhatsNewPopup } from "@/SEQTA"
|
||||
import ColourPicker from '../components/ColourPicker.svelte'
|
||||
import { settingsPopup } from '../hooks/SettingsPopup'
|
||||
|
||||
let devModeSequence = '';
|
||||
|
||||
const handleDevModeToggle = () => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
devModeSequence += event.key.toLowerCase();
|
||||
if (devModeSequence.includes('dev')) {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
settingsState.devMode = true;
|
||||
alert('Dev mode is now enabled');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
setTimeout(() => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
devModeSequence = '';
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
const openColourPicker = () => {
|
||||
showColourPicker = true;
|
||||
}
|
||||
|
||||
const openChangelog = () => {
|
||||
OpenWhatsNewPopup();
|
||||
closeExtensionPopup();
|
||||
};
|
||||
|
||||
const openAbout = () => {
|
||||
OpenAboutPage();
|
||||
closeExtensionPopup();
|
||||
};
|
||||
|
||||
let { standalone } = $props<{ standalone?: boolean }>();
|
||||
let showColourPicker = $state<boolean>(false);
|
||||
|
||||
onMount(() => {
|
||||
settingsPopup.addListener(() => {
|
||||
showColourPicker = false;
|
||||
});
|
||||
|
||||
if (!standalone) return;
|
||||
initializeSettingsState();
|
||||
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="relative flex flex-col h-full gap-2 bg-white overflow-clip dark:bg-zinc-800 dark:text-white">
|
||||
<div class="grid border-b border-b-zinc-200/40 place-items-center">
|
||||
<!-- 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} />
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<img src={browser.runtime.getURL('resources/icons/betterseqta-light-full.png')} class="hidden w-4/5 dark:block" alt="Dark logo" onclick={handleDevModeToggle} />
|
||||
|
||||
{#if !standalone}
|
||||
<button onclick={openChangelog} class="absolute w-8 h-8 text-lg rounded-xl font-IconFamily top-1 right-1 bg-zinc-100 dark:bg-zinc-700">{'\ue929'}</button>
|
||||
<button onclick={openAbout} class="absolute w-8 h-8 text-lg rounded-xl font-IconFamily top-1 right-10 bg-zinc-100 dark:bg-zinc-700">{'\ueb73'}</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<TabbedContainer tabs={[
|
||||
{ title: 'Settings', Content: Settings, props: { showColourPicker: openColourPicker } },
|
||||
{ title: 'Shortcuts', Content: Shortcuts },
|
||||
{ title: 'Themes', Content: Theme },
|
||||
]} />
|
||||
</div>
|
||||
|
||||
{#if showColourPicker}
|
||||
<ColourPicker hidePicker={() => { showColourPicker = false }} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,176 @@
|
||||
<script lang="ts">
|
||||
import Switch from "../../components/Switch.svelte"
|
||||
import Button from "../../components/Button.svelte"
|
||||
import Slider from "../../components/Slider.svelte"
|
||||
import Select from "@/interface/components/Select.svelte"
|
||||
|
||||
import browser from "webextension-polyfill"
|
||||
|
||||
import type { SettingsList } from "@/interface/types/SettingsProps"
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
|
||||
import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
|
||||
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent"
|
||||
|
||||
const { showColourPicker } = $props<{ showColourPicker: () => void }>();
|
||||
</script>
|
||||
|
||||
{#snippet Setting({ title, description, Component, props }: SettingsList) }
|
||||
<div class="flex items-center justify-between px-4 py-3">
|
||||
<div class="pr-4">
|
||||
<h2 class="text-sm font-bold">{title}</h2>
|
||||
<p class="text-xs">{description}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Component {...props} />
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-col divide-y divide-zinc-100 dark:divide-zinc-700">
|
||||
{#each [
|
||||
{
|
||||
title: "Transparency Effects",
|
||||
description: "Enables transparency effects on certain elements such as blur. (May impact battery life)",
|
||||
id: 1,
|
||||
Component: Switch,
|
||||
props: {
|
||||
state: $settingsState.transparencyEffects,
|
||||
onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Animated Background",
|
||||
description: "Adds an animated background to BetterSEQTA. (May impact battery life)",
|
||||
id: 2,
|
||||
Component: Switch,
|
||||
props: {
|
||||
state: $settingsState.animatedbk,
|
||||
onChange: (isOn: boolean) => settingsState.animatedbk = isOn
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Animated Background Speed",
|
||||
description: "Controls the speed of the animated background.",
|
||||
id: 3,
|
||||
Component: Slider,
|
||||
props: {
|
||||
state: $settingsState.bksliderinput,
|
||||
onChange: (value: number) => settingsState.bksliderinput = `${value}`
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Custom Theme Colour",
|
||||
description: "Customise the overall theme colour of SEQTA Learn.",
|
||||
id: 4,
|
||||
Component: PickerSwatch,
|
||||
props: {
|
||||
onClick: showColourPicker
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Edit Sidebar Layout",
|
||||
description: "Customise the sidebar layout.",
|
||||
id: 5,
|
||||
Component: Button,
|
||||
props: {
|
||||
onClick: () => browser.runtime.sendMessage({ type: 'currentTab', info: 'EditSidebar' }),
|
||||
text: "Edit"
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Animations",
|
||||
description: "Enables animations on certain pages.",
|
||||
id: 6,
|
||||
Component: Switch,
|
||||
props: {
|
||||
state: $settingsState.animations,
|
||||
onChange: (isOn: boolean) => settingsState.animations = isOn
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Notification Collector",
|
||||
description: "Uncaps the 9+ limit for notifications, showing the real number.",
|
||||
id: 7,
|
||||
Component: Switch,
|
||||
props: {
|
||||
state: $settingsState.notificationcollector,
|
||||
onChange: (isOn: boolean) => settingsState.notificationcollector = isOn
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Lesson Alerts",
|
||||
description: "Sends a native browser notification ~5 minutes prior to lessons.",
|
||||
id: 8,
|
||||
Component: Switch,
|
||||
props: {
|
||||
state: $settingsState.lessonalert,
|
||||
onChange: (isOn: boolean) => settingsState.lessonalert = isOn
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "12 Hour Time",
|
||||
description: "Prefer 12 hour time format for SEQTA",
|
||||
id: 9,
|
||||
Component: Switch,
|
||||
props: {
|
||||
state: $settingsState.timeFormat === "12",
|
||||
onChange: (isOn: boolean) => settingsState.timeFormat = isOn ? "12" : "24"
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Default Page",
|
||||
description: "The page to load when SEQTA Learn is opened.",
|
||||
id: 10,
|
||||
Component: Select,
|
||||
props: {
|
||||
state: $settingsState.defaultPage,
|
||||
onChange: (value: string) => settingsState.defaultPage = value,
|
||||
options: [
|
||||
{ value: 'home', label: 'Home' },
|
||||
{ value: 'dashboard', label: 'Dashboard' },
|
||||
{ value: 'timetable', label: 'Timetable' },
|
||||
{ value: 'welcome', label: 'Welcome' },
|
||||
{ value: 'messages', label: 'Messages' },
|
||||
{ value: 'documents', label: 'Documents' },
|
||||
{ value: 'reports', label: 'Reports' },
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "BetterSEQTA+",
|
||||
description: "Enables BetterSEQTA+ features",
|
||||
id: 11,
|
||||
Component: Switch,
|
||||
props: {
|
||||
state: $settingsState.onoff,
|
||||
onChange: (isOn: boolean) => settingsState.onoff = isOn
|
||||
}
|
||||
}
|
||||
] as option}
|
||||
{@render Setting(option)}
|
||||
{/each}
|
||||
|
||||
{#if $settingsState.devMode}
|
||||
<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>
|
||||
<Switch state={$settingsState.devMode} onChange={(isOn: boolean) => settingsState.devMode = isOn} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between 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}
|
||||
</div>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script lang="ts">
|
||||
import MotionDiv from '@/interface/components/MotionDiv.svelte';
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
|
||||
import Switch from "@/interface/components/Switch.svelte"
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let isLoaded = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
// Wait for settingsState to be initialized
|
||||
await new Promise<void>((resolve) => {
|
||||
const checkState = () => {
|
||||
if ($settingsState?.shortcuts) {
|
||||
isLoaded = true;
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(checkState, 100);
|
||||
}
|
||||
};
|
||||
checkState();
|
||||
});
|
||||
});
|
||||
|
||||
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("");
|
||||
|
||||
const toggleForm = () => {
|
||||
isFormVisible = !isFormVisible;
|
||||
};
|
||||
|
||||
const formatUrl = (inputUrl: string) => {
|
||||
const protocolRegex = /^(http:\/\/|https:\/\/|ftp:\/\/)/;
|
||||
return protocolRegex.test(inputUrl) ? inputUrl : `https://${inputUrl}`;
|
||||
};
|
||||
|
||||
const isValidTitle = (title: string) => title.trim() !== "";
|
||||
|
||||
const isValidURL = (url: string) => {
|
||||
const pattern = new RegExp("^(https?:\\/\\/)?[\\w.-]+(?:\\.[\\w\\-]+)*(?::\\d+)?(/[\\w\\-./]*)*$", "i");
|
||||
return pattern.test(url);
|
||||
};
|
||||
|
||||
const addNewCustomShortcut = () => {
|
||||
if (isValidTitle(newTitle) && isValidURL(newURL)) {
|
||||
const newShortcut = { name: newTitle.trim(), url: formatUrl(newURL).trim(), icon: newTitle[0] };
|
||||
settingsState.customshortcuts = [...settingsState.customshortcuts, newShortcut];
|
||||
|
||||
newTitle = "";
|
||||
newURL = "";
|
||||
isFormVisible = false;
|
||||
} else {
|
||||
alert("Please enter a valid title and URL.");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCustomShortcut = (index: number) => {
|
||||
settingsState.customshortcuts = settingsState.customshortcuts.filter((_, i) => i !== index);
|
||||
};
|
||||
</script>
|
||||
|
||||
{#snippet Shortcuts([index, Shortcut]: [string, { name: string, enabled: boolean }]) }
|
||||
<div class="flex items-center justify-between 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>
|
||||
<MotionDiv
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={isFormVisible ? { opacity: 1, height: 'auto' } : { opacity: 0, height: 0 }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
config: { stiffness: 400, damping: 25 }
|
||||
}}
|
||||
>
|
||||
{#if isFormVisible}
|
||||
<div class="flex flex-col items-center">
|
||||
<MotionDiv
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0, duration: 0.2 }}
|
||||
class="w-full"
|
||||
>
|
||||
<input
|
||||
class="w-full p-2 transition border-0 rounded-lg placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
|
||||
type="text"
|
||||
placeholder="Shortcut Name"
|
||||
bind:value={newTitle}
|
||||
/>
|
||||
</MotionDiv>
|
||||
<MotionDiv
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.05, duration: 0.2 }}
|
||||
class="w-full"
|
||||
>
|
||||
<input
|
||||
class="w-full p-2 my-2 transition border-0 rounded-lg 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}
|
||||
</MotionDiv>
|
||||
|
||||
<MotionDiv
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<button
|
||||
class="w-full px-4 py-2 mb-4 text-[13px] dark:text-white transition rounded-xl bg-zinc-200 dark:bg-zinc-700/50"
|
||||
onclick={isFormVisible ? addNewCustomShortcut : toggleForm}
|
||||
>
|
||||
{#if isFormVisible}
|
||||
Add
|
||||
{:else}
|
||||
Add Custom Shortcut
|
||||
{/if}
|
||||
</button>
|
||||
</MotionDiv>
|
||||
</div>
|
||||
|
||||
{#each Object.entries($settingsState.shortcuts) as shortcut}
|
||||
{@render Shortcuts(shortcut)}
|
||||
{/each}
|
||||
|
||||
<!-- Custom Shortcuts Section -->
|
||||
{#each $settingsState.customshortcuts as shortcut, index}
|
||||
<div class="flex items-center justify-between px-4 py-3">
|
||||
{shortcut.name}
|
||||
<button onclick={() => deleteCustomShortcut(index)}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="p-4 text-center">
|
||||
Loading shortcuts...
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import BackgroundSelector from "@/interface/components/themes/BackgroundSelector.svelte"
|
||||
import ThemeSelector from "@/interface/components/themes/ThemeSelector.svelte"
|
||||
import { standalone } from "@/interface/utils/standalone.svelte"
|
||||
|
||||
// backgrounds
|
||||
let selectedBackground = $state<string | null>(null);
|
||||
let selectNoBackground = $state<() => void>(() => { });
|
||||
|
||||
let clearTheme = $derived(selectedBackground !== null);
|
||||
let editMode = $state<boolean>(false);
|
||||
</script>
|
||||
|
||||
<div class="py-4">
|
||||
{#if !standalone.standalone}
|
||||
<button
|
||||
onclick={() => selectNoBackground()}
|
||||
class="w-full px-4 py-2 mb-4 text-[13px] dark:text-white transition rounded-xl bg-zinc-200 dark:bg-zinc-700/50">
|
||||
{ clearTheme ? 'Clear Theme' : 'Select a Theme' }
|
||||
</button>
|
||||
<div class="relative w-full">
|
||||
<button
|
||||
onclick={() => editMode = !editMode}
|
||||
class="absolute top-0 right-0 z-10 w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700">{editMode ? '\ue9e4' : '\uec38'}</button>
|
||||
|
||||
<BackgroundSelector isEditMode={editMode} bind:selectedBackground={selectedBackground} bind:selectNoBackground={selectNoBackground} />
|
||||
<ThemeSelector isEditMode={editMode} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center w-full h-full">
|
||||
<div class="text-lg">
|
||||
Open SEQTA and use the embedded settings to access theme settings. 🫠
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
// Import existing components
|
||||
import CoverSwiper from '../components/store/CoverSwiper.svelte';
|
||||
import ThemeGrid from '../components/store/ThemeGrid.svelte';
|
||||
import SkeletonLoader from '../components/SkeletonLoader.svelte';
|
||||
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||
import type { Theme } from '../types/Theme'
|
||||
import browser from 'webextension-polyfill'
|
||||
import ThemeModal from '../components/store/ThemeModal.svelte'
|
||||
import { StoreDownloadTheme } from '@/seqta/ui/themes/downloadTheme'
|
||||
import { setTheme } from '@/seqta/ui/themes/setTheme'
|
||||
import Header from '../components/store/Header.svelte'
|
||||
import { deleteTheme } from '@/seqta/ui/themes/deleteTheme'
|
||||
import { getAvailableThemes } from '@/seqta/ui/themes/getAvailableThemes'
|
||||
import { themeUpdates } from '../hooks/ThemeUpdates'
|
||||
|
||||
import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
|
||||
import Backgrounds from '../components/store/Backgrounds.svelte'
|
||||
|
||||
// State variables
|
||||
let searchTerm = $state('');
|
||||
let themes = $state<Theme[]>([]);
|
||||
let coverThemes = $state<Theme[]>([]);
|
||||
let loading = $state(true);
|
||||
let darkMode = $state(false);
|
||||
let displayTheme = $state<Theme | null>(null);
|
||||
let currentThemes = $state<string[]>([]);
|
||||
let activeTab = $state('themes');
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
let selectedBackground = $state<string | null>(null);
|
||||
|
||||
const fetchCurrentThemes = async () => {
|
||||
const themes = await getAvailableThemes();
|
||||
currentThemes = themes.themes.filter(theme => theme !== null).map(theme => theme.id);
|
||||
};
|
||||
|
||||
const setDisplayTheme = (theme: Theme | null) => {
|
||||
displayTheme = theme;
|
||||
};
|
||||
|
||||
const setSearchTerm = (term: string) => {
|
||||
searchTerm = term;
|
||||
};
|
||||
|
||||
const setActiveTab = (tab: string) => {
|
||||
activeTab = tab;
|
||||
};
|
||||
|
||||
// Fetch themes and initialize app
|
||||
const fetchThemes = async () => {
|
||||
try {
|
||||
const response = await fetch(`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${(new Date()).getTime()}`, { cache: 'no-store' });
|
||||
const data = await response.json();
|
||||
themes = data.themes;
|
||||
|
||||
// Shuffle for cover themes
|
||||
const shuffled = [...themes].sort(() => 0.5 - Math.random());
|
||||
coverThemes = shuffled.slice(0, 3);
|
||||
|
||||
loading = false;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch themes', error);
|
||||
setTimeout(fetchThemes, 5000); // Retry after 5 seconds if failure occurs
|
||||
}
|
||||
};
|
||||
|
||||
// On mount
|
||||
onMount(async () => {
|
||||
await fetchThemes();
|
||||
await fetchCurrentThemes();
|
||||
|
||||
darkMode = (await browser.storage.local.get('DarkMode')).DarkMode === 'true';
|
||||
darkMode = $settingsState.DarkMode;
|
||||
});
|
||||
|
||||
// Filter themes based on search term
|
||||
let filteredThemes = $derived(themes.filter(theme =>
|
||||
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
theme.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
));
|
||||
|
||||
$effect(() => {
|
||||
loadBackground();
|
||||
selectedBackground
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="w-screen h-screen bg-white {darkMode ? 'dark' : ''}">
|
||||
<div class="h-full overflow-y-scroll bg-zinc-200/50 dark:bg-zinc-900 dark:text-white pt-[4.25rem]">
|
||||
<Header {searchTerm} {setSearchTerm} {darkMode} {activeTab} {setActiveTab} />
|
||||
|
||||
<div class={`px-12 h-full ${activeTab === 'backgrounds' ? 'pt-0' : 'pt-6 md:px-24 lg:px-48'}`}>
|
||||
<!-- Loading State -->
|
||||
{#if loading}
|
||||
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
|
||||
<SkeletonLoader width="100%" height="200px" />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Themes Tab Content -->
|
||||
{#if activeTab === 'themes'}
|
||||
{#if searchTerm === ''}
|
||||
<CoverSwiper {coverThemes} {setDisplayTheme} />
|
||||
{/if}
|
||||
|
||||
<!-- ThemeGrid to display filtered themes -->
|
||||
<ThemeGrid themes={filteredThemes} {searchTerm} {setDisplayTheme} />
|
||||
|
||||
{#if displayTheme}
|
||||
<ThemeModal
|
||||
currentThemes={currentThemes}
|
||||
allThemes={themes}
|
||||
theme={displayTheme}
|
||||
{displayTheme}
|
||||
{setDisplayTheme}
|
||||
onInstall={async () => {
|
||||
if (displayTheme) {
|
||||
await StoreDownloadTheme({themeContent: displayTheme})
|
||||
setTheme(displayTheme.id);
|
||||
themeUpdates.triggerUpdate();
|
||||
await fetchCurrentThemes();
|
||||
}
|
||||
}}
|
||||
onRemove={async () => {
|
||||
if (displayTheme?.id) {
|
||||
console.debug('deleting theme', displayTheme.id);
|
||||
deleteTheme(displayTheme.id)
|
||||
themeUpdates.triggerUpdate();
|
||||
await fetchCurrentThemes();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{:else if activeTab === 'backgrounds'}
|
||||
<Backgrounds {searchTerm} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user