mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +00:00
Compare commits
249 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e26d2c192 | |||
| 7445e8be78 | |||
| d1a876ff22 | |||
| e2176ea2fa | |||
| a999e4384b | |||
| 4bf5420140 | |||
| 8fb29f7f21 | |||
| 44e3ed34d0 | |||
| 32228ee4db | |||
| 1692bd3e92 | |||
| 71cf9dbca8 | |||
| 372b591b16 | |||
| 7578ecee74 | |||
| a2b4f81b86 | |||
| fcd95f6823 | |||
| f2ea7c8104 | |||
| 379a3ebda0 | |||
| d1850e8ddb | |||
| 88a87692cd | |||
| 4e6e4870b0 | |||
| 18f215fa5f | |||
| 2ea8ada439 | |||
| 34b2501617 | |||
| 88d4d3aa11 | |||
| 5ed3a05f6a | |||
| 430f158957 | |||
| 547caabc45 | |||
| f6e549c5da | |||
| dc1ae9c0a1 | |||
| 34306e77cf | |||
| 00c9f03827 | |||
| 6c93477998 | |||
| 9784b6162f | |||
| 96cf8e3eac | |||
| 0e23ea0cc3 | |||
| 7dfe347562 | |||
| 21a8472c94 | |||
| 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.
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
npm install
|
||||
npm install --legacy-peer-deps
|
||||
npm run build
|
||||
|
||||
- name: Zip dist folder
|
||||
+3
-12
@@ -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
|
||||
**/.DS_Store
|
||||
@@ -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": ["*://*/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
+64
-47
@@ -1,15 +1,20 @@
|
||||
{
|
||||
"name": "betterseqtaplus",
|
||||
"version": "3.3.0",
|
||||
"version": "3.4.1",
|
||||
"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,76 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"@babel/plugin-transform-runtime": "^7.25.9",
|
||||
"@babel/runtime": "^7.26.0",
|
||||
"@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",
|
||||
"caniuse-lite": "^1.0.30001684",
|
||||
"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": "^11.12.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: {},
|
||||
},
|
||||
}
|
||||
+763
-472
File diff suppressed because it is too large
Load Diff
+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) => {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* along with EvenBetterSEQTA. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@import './injected/popup.scss';
|
||||
@use 'injected/popup.scss';
|
||||
|
||||
html {
|
||||
background: #161616 !important;
|
||||
|
||||
+228
-88
@@ -1,9 +1,10 @@
|
||||
@use "sass:meta";
|
||||
@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';
|
||||
@include meta.load-css("injected/sidebar-animation.scss");
|
||||
@include meta.load-css("injected/theme.scss");
|
||||
@include meta.load-css("injected/transparency.scss");
|
||||
|
||||
:root {
|
||||
background: var(--better-main) !important;
|
||||
@@ -16,7 +17,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 +32,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 +41,8 @@ html {
|
||||
height: 100%;
|
||||
visibility: visible !important;
|
||||
}
|
||||
#themeCreatorIframe {
|
||||
|
||||
#themeCreator {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
@@ -62,6 +65,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 +119,10 @@ html {
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.modaliser-container {
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
.connectedNotificationsWrapper > div > button > svg > g {
|
||||
fill: var(--theme-primary) !important;
|
||||
}
|
||||
@@ -165,7 +206,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 +217,8 @@ html {
|
||||
}
|
||||
|
||||
.dark .dashboard section {
|
||||
input, select {
|
||||
input,
|
||||
select {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -197,7 +240,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 +282,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 +300,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 +341,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 +386,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 +497,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 +524,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 +555,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 {
|
||||
@@ -1057,9 +1111,9 @@ div > ol:has(.uiFileHandlerWrapper) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background-primary);
|
||||
transition: 200ms;
|
||||
box-shadow: inset 0px 5px 20px 1px rgba(0, 0, 0, 0.3);
|
||||
padding-bottom: 25px;
|
||||
transition: none;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.dummynotice {
|
||||
@@ -1290,7 +1344,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;
|
||||
@@ -1392,7 +1448,7 @@ div > ol:has(.uiFileHandlerWrapper) {
|
||||
font-size: 12px;
|
||||
margin-right: 4px;
|
||||
font-family: "IconFamily";
|
||||
pointer-events: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.MessageList__MessageList___3DxoC ol .Button__Button___3SRFo {
|
||||
@@ -1496,7 +1552,13 @@ iframe.userHTML {
|
||||
.Collapsible__Collapsible___3O8P3 > .Collapsible__header___-Afvq {
|
||||
background: none;
|
||||
}
|
||||
.AssessmentList__AssessmentList___1GdCl > .AssessmentList__searchFilter___3N70o + .AssessmentList__items___3LcmQ {
|
||||
.Collapsible__Collapsible___3O8P3 > .Collapsible__content___2c6of.Collapsible__enterActive___3b2ow,
|
||||
.Collapsible__Collapsible___3O8P3 > .Collapsible__content___2c6of.Collapsible__exitActive___3rFL1 {
|
||||
animation-timing-function: ease-out !important;
|
||||
}
|
||||
.AssessmentList__AssessmentList___1GdCl
|
||||
> .AssessmentList__searchFilter___3N70o
|
||||
+ .AssessmentList__items___3LcmQ {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.Thermoscore__Thermoscore___2tWMi {
|
||||
@@ -1546,19 +1608,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 +1620,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 +1656,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 +1787,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 +1989,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 +2032,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 +2079,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 +2101,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 +2177,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);
|
||||
}
|
||||
|
||||
@@ -2104,44 +2193,55 @@ li.MessageList__unread___3imtO {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
padding: 0 !important;
|
||||
margin: 0px auto 30px !important;
|
||||
background: var(--background-primary) !important;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
-webkit-box-shadow: 0px 5px 16px 6px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0px 5px 16px 6px rgba(0, 0, 0, 0.3);
|
||||
|
||||
&:hover {
|
||||
.ArticleText a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.ArticleText a {
|
||||
padding: 10px 20px;
|
||||
margin: 0;
|
||||
font-weight: 800;
|
||||
font-size: 2em;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.ArticleText p {
|
||||
padding: 10px 20px;
|
||||
margin: 0;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.ArticleText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 65%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.articleimage {
|
||||
width: 35%;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
min-height: 18em;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
}
|
||||
.articleimage {
|
||||
width: 35%;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
min-height: 18em;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.NewsArticle img {
|
||||
width: 35%;
|
||||
}
|
||||
.ArticleText a {
|
||||
padding: 10px 20px;
|
||||
margin: 0;
|
||||
font-weight: 800;
|
||||
font-size: 2em;
|
||||
background: none;
|
||||
}
|
||||
.NewsArticle:hover > .ArticleText a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.ArticleText p {
|
||||
padding: 10px 20px;
|
||||
margin: 0;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.ArticleText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 65%;
|
||||
height: 100%;
|
||||
|
||||
#news-container {
|
||||
gap: 16px;
|
||||
|
||||
> h1 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
.editmenu {
|
||||
position: absolute;
|
||||
@@ -2509,20 +2609,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 +2682,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 +2693,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;
|
||||
@@ -2623,11 +2728,11 @@ li.MessageList__unread___3imtO {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--text-primary);
|
||||
transition: 200ms;
|
||||
transition: 200ms, background-color 0s;
|
||||
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);
|
||||
@@ -2900,14 +3005,12 @@ li.MessageList__unread___3imtO {
|
||||
width: 96%;
|
||||
display: flex;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
.whatsnewImg {
|
||||
margin: 8px auto;
|
||||
width: 90%;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.3);
|
||||
|
||||
}
|
||||
.whatsnewTextContainer {
|
||||
display: flex;
|
||||
@@ -2977,9 +3080,46 @@ 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;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
&.upcoming-items,
|
||||
&.day-container {
|
||||
background: linear-gradient(90deg,
|
||||
var(--background-primary) 0%,
|
||||
var(--background-secondary) 50%,
|
||||
var(--background-primary) 100%
|
||||
);
|
||||
background-size: 1000px 100%;
|
||||
animation: shimmer 2s infinite linear;
|
||||
}
|
||||
|
||||
&.upcoming-items {
|
||||
height: 35em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,102 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import ColourPicker from './ColourPicker.tsx';
|
||||
import ReactAdapter from './utils/ReactAdapter.svelte';
|
||||
import { animate } 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] },
|
||||
{
|
||||
type: 'spring',
|
||||
stiffness: 400,
|
||||
damping: 30
|
||||
}
|
||||
);
|
||||
|
||||
animate(
|
||||
background,
|
||||
{ opacity: [1, 0] },
|
||||
{ ease: [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, ease: [0.4, 0, 0.2, 1] }
|
||||
);
|
||||
|
||||
animate(
|
||||
content,
|
||||
{ scale: [0.4, 1], opacity: [0, 1] },
|
||||
{
|
||||
type: 'spring',
|
||||
stiffness: 400,
|
||||
damping: 30
|
||||
}
|
||||
);
|
||||
|
||||
const handleEscapeKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
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,79 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { animate as motionAnimate } 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 playAnimation = (keyframe: any) => {
|
||||
if (divElement && keyframe) {
|
||||
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`;
|
||||
}
|
||||
|
||||
const defaultSpringConfig = { stiffness: 250, damping: 25 };
|
||||
|
||||
const animation = motionAnimate(
|
||||
[divElement],
|
||||
finalKeyframe,
|
||||
{
|
||||
type: 'spring',
|
||||
stiffness: transition?.stiffness || defaultSpringConfig.stiffness,
|
||||
damping: transition?.damping || defaultSpringConfig.damping
|
||||
}
|
||||
);
|
||||
return animation;
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
if (initial) {
|
||||
Object.assign(divElement.style, initial);
|
||||
await playAnimation(animate || {});
|
||||
} else if (animate) {
|
||||
await playAnimation(animate);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (animate) {
|
||||
playAnimation(animate);
|
||||
}
|
||||
});
|
||||
|
||||
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,51 @@
|
||||
<script lang="ts">
|
||||
import { animate } 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,
|
||||
},
|
||||
{
|
||||
type: 'spring',
|
||||
stiffness: springParams.stiffness,
|
||||
damping: springParams.damping,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// 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,126 @@
|
||||
<script lang="ts">
|
||||
import type { Theme } from '@/interface/types/Theme'
|
||||
import { fade } from 'svelte/transition';
|
||||
import { animate } 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] },
|
||||
{
|
||||
type: 'spring',
|
||||
stiffness: 150,
|
||||
damping: 20
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const hideModal = (relatedTheme?: Theme | null) => {
|
||||
animate(
|
||||
modalElement,
|
||||
{ y: [10, 500], opacity: [1, 0] },
|
||||
{
|
||||
type: '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;
|
||||
@@ -14,4 +16,37 @@
|
||||
|
||||
::-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>
|
||||
|
||||
</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
+1
-3
@@ -3,6 +3,4 @@ import './index.css';
|
||||
declare module "*.png";
|
||||
declare module "*.svg";
|
||||
declare module "*.jpeg";
|
||||
declare module "*.jpg";
|
||||
|
||||
declare module 'react-best-gradient-color-picker';
|
||||
declare module "*.jpg";
|
||||
@@ -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,186 @@
|
||||
<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: "Assessment Average",
|
||||
description: "Shows your subject average for assessments.",
|
||||
id: 8,
|
||||
Component: Switch,
|
||||
props: {
|
||||
state: $settingsState.assessmentsAverage,
|
||||
onChange: (isOn: boolean) => settingsState.assessmentsAverage = 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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user