Merge pull request #155 from BetterSEQTA/svelte

Change Settings UI to svelte under the hood
This commit is contained in:
Seth Burkart
2024-11-13 09:46:22 +11:00
committed by GitHub
135 changed files with 8173 additions and 3549 deletions
+2 -2
View File
@@ -3,12 +3,12 @@
"browser": true, "browser": true,
"commonjs": true, "commonjs": true,
"es2021": true, "es2021": true,
"node": true // add this line to allow Node.js-specific globals "node": true
}, },
"extends": "eslint:recommended", "extends": "eslint:recommended",
"parserOptions": { "parserOptions": {
"ecmaVersion": "latest", "ecmaVersion": "latest",
"sourceType": "module" // add this line to allow 'import' and 'export' statements "sourceType": "module"
}, },
"rules": { "rules": {
// allow importing ts extensions // allow importing ts extensions
+5
View File
@@ -1,3 +1,8 @@
# SVELTE BUILD - NOT STABLE
Please don't use this in a production environment - it is quite buggy and is not fully completed as of yet. <br>
#
<a href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel"> <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" /> <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> </a>
+46 -46
View File
@@ -1,16 +1,16 @@
{ {
"name": "betterseqtaplus", "name": "betterseqtaplus",
"version": "3.3.2", "version": "3.4.0",
"type": "module", "type": "module",
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development, while incorporating a plethora of new and improved 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", "browserslist": "> 0.5%, last 2 versions, not dead",
"scripts": { "scripts": {
"dev": "MODE=chrome vite dev", "dev": "cross-env MODE=chrome vite dev",
"dev:firefox": "MODE=firefox vite build --watch", "dev:firefox": "cross-env MODE=firefox vite build --watch",
"build": "MODE=chrome vite build && MODE=firefox vite build", "build": "cross-env MODE=chrome vite build && cross-env MODE=firefox vite build",
"build:chrome": "MODE=chrome vite build", "build:chrome": "cross-env MODE=chrome vite build",
"build:firefox": "MODE=firefox vite build", "build:firefox": "cross-env MODE=firefox vite build",
"build:safari": "MODE=safari 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", "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", "release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes",
"publish": "bun lib/publish.js --b", "publish": "bun lib/publish.js --b",
@@ -31,66 +31,66 @@
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@crxjs/vite-plugin": "^2.0.0-beta.23", "@crxjs/vite-plugin": "2.0.0-beta.25",
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"@vitejs/plugin-react-swc": "^3.6.0", "@vitejs/plugin-react-swc": "^3.7.0",
"eslint": "^8.56.0", "cross-env": "^7.0.3",
"eslint": "^8.57.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"parcel": "^2.11.0", "prettier": "^3.3.3",
"prettier": "^3.2.5",
"process": "^0.11.10", "process": "^0.11.10",
"sass": "^1.70.0", "sass": "^1.78.0",
"sass-loader": "^13.3.3", "sass-loader": "^13.3.3",
"semver": "^7.6.3", "semver": "^7.6.3",
"url": "^0.11.3" "url": "^0.11.4"
}, },
"dependencies": { "dependencies": {
"@bedframe/cli": "^0.0.85", "@bedframe/cli": "^0.0.85",
"@blocknote/core": "^0.14.1", "@codemirror/lang-css": "^6.3.0",
"@blocknote/mantine": "^0.14.1",
"@blocknote/react": "^0.14.1",
"@codemirror/lang-less": "^6.0.2", "@codemirror/lang-less": "^6.0.2",
"@heroicons/react": "^2.1.3", "@codemirror/theme-one-dark": "^6.1.2",
"@million/lint": "latest", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.9",
"@tsconfig/svelte": "^5.0.4",
"@types/chrome": "^0.0.270",
"@types/color": "^3.0.6", "@types/color": "^3.0.6",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@types/lodash": "^4.17.4", "@types/lodash": "^4.17.7",
"@types/node": "^20.11.30", "@types/node": "^20.16.5",
"@types/react": "^18.2.55", "@types/react": "17",
"@types/react-dom": "^18.2.19", "@types/react-dom": "17",
"@types/sortablejs": "^1.15.7", "@types/sortablejs": "^1.15.8",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"@types/webextension-polyfill": "^0.10.7", "@types/webextension-polyfill": "^0.10.7",
"@uiw/codemirror-extensions-color": "^4.21.25", "@uiw/codemirror-extensions-color": "^4.23.3",
"@uiw/codemirror-theme-github": "^4.21.25", "@uiw/codemirror-theme-github": "^4.23.3",
"@uiw/react-codemirror": "^4.21.25", "@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.17", "autoprefixer": "^10.4.20",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"codemirror": "^6.0.1",
"color": "^4.2.3", "color": "^4.2.3",
"dompurify": "^3.0.8", "dompurify": "^3.1.6",
"framer-motion": "^11.0.25", "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", "kolorist": "^1.8.0",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"million": "latest", "million": "^3.1.11",
"motion": "^10.17.0", "motion": "^10.18.0",
"postcss": "^8.4.45",
"publish-browser-extension": "^2.2.1", "publish-browser-extension": "^2.2.1",
"react": "^18.2.0", "react": "17",
"react-best-gradient-color-picker": "3.0.5", "react-best-gradient-color-picker": "^3.0.10",
"react-dom": "^18.2.0", "react-dom": "17",
"react-error-boundary": "^4.0.13", "sortablejs": "^1.15.3",
"react-router-dom": "^6.22.0", "svelte": "^5.1.9",
"react-toastify": "^10.0.5", "tailwindcss": "^3.4.11",
"rimraf": "^5.0.5", "typescript": "^5.6.2",
"sortablejs": "^1.15.2",
"swiper": "latest",
"tailwindcss": "^3.4.1",
"ts-loader": "^9.5.1",
"typescript": "^5.3.3",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"vite": "^5.4.2", "vite": "^5.4.4",
"webextension-polyfill": "^0.10.0" "webextension-polyfill": "^0.10.0"
} }
} }
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+152 -71
View File
@@ -35,6 +35,10 @@ import coursesicon from '@/seqta/icons/coursesIcon'
import iframeCSS from '@/css/iframe.scss?raw' import iframeCSS from '@/css/iframe.scss?raw'
import injectedCSS from '@/css/injected.scss?inline' import injectedCSS from '@/css/injected.scss?inline'
import documentLoadCSS from '@/css/documentload.scss?inline' import documentLoadCSS from '@/css/documentload.scss?inline'
import renderSvelte from '@/interface/main'
import Settings from '@/interface/pages/settings.svelte'
import { settingsPopup } from './interface/hooks/SettingsPopup'
import { migrateBackgrounds } from './seqta/utils/migrateBackgrounds'
let SettingsClicked = false let SettingsClicked = false
export let MenuOptionsOpen = false export let MenuOptionsOpen = false
@@ -42,10 +46,13 @@ let currentSelectedDate = new Date()
let LessonInterval: any let LessonInterval: any
var IsSEQTAPage = false var IsSEQTAPage = false
let hasSEQTAText = false
// This check is placed outside of the document load event due to issues with EP (https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues/84) // This check is placed outside of the document load event due to issues with EP (https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues/84)
const hasSEQTAText = document.childNodes[1].textContent?.includes('Copyright (c) SEQTA Software') if (document.childNodes[1]) {
init() hasSEQTAText = document.childNodes[1].textContent?.includes('Copyright (c) SEQTA Software') ?? false
init()
}
async function init() { async function init() {
CheckForMenuList() CheckForMenuList()
@@ -153,6 +160,15 @@ export function OpenWhatsNewPopup() {
let text = stringToHTML( let text = stringToHTML(
/* html */ ` /* html */ `
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: scroll;"> <div class="whatsnewTextContainer" style="height: 50%;overflow-y: scroll;">
<h1>3.4.0 - Major Performance Update</h1>
<li>Completely rebuilt the extension popup using Svelte for dramatically improved performance</li>
<li>Added a brand new background store with search functionality and downloadable backgrounds</li>
<li>Significant code cleanup and optimization across the extension</li>
<li>Improved overall responsiveness and load times</li>
<li>Smoother animations and improved scrolling</li>
<li>Fixed Firefox compatibility issues</li>
<li>Other minor bug fixes and under the hood improvements</li>
<h1>3.3.1 - Hot Fix</h1> <h1>3.3.1 - Hot Fix</h1>
<li>Fixed assessments not loading when no notices are available</li> <li>Fixed assessments not loading when no notices are available</li>
@@ -342,6 +358,102 @@ export function OpenWhatsNewPopup() {
}) })
} }
export function OpenAboutPage() {
const background = document.createElement('div')
background.id = 'whatsnewbk'
background.classList.add('whatsnewBackground')
const container = document.createElement('div')
container.classList.add('whatsnewContainer')
var header: any = stringToHTML(
/* html */
`<div class="whatsnewHeader">
<h1>About</h1>
<p>BetterSEQTA+ V${browser.runtime.getManifest().version}</p>
</div>`
).firstChild
let text = stringToHTML(
/* html */ `
<div class="whatsnewTextContainer" style="overflow-y: scroll;">
<img src="${settingsState.DarkMode ? 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/branding/dark.jpg' : 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/branding/light.jpg'}" class="aboutImg" />
<p>BetterSEQTA+ is a fork of BetterSEQTA which was originally developed by Nulkem, which was discontinued. BetterSEQTA+ continued development of BetterSEQTA, while incorporating a plethora of features. </p>
<p>We are currently working on fixing bugs and adding good features. If you want to make a feature request or report a bug, you can do so on GitHub (find icon below).</p>
<h1>Credits</h1>
<p>Nulkem created the original extension, was ported to Manifest V3 by MEGA-Dawg68, and is under active development by Crazypersonalph and SethBurkart123.</p>
</div>
`,
).firstChild
let footer = stringToHTML(
/* html */ `
<div class="whatsnewFooter">
<div>
Report bugs and feedback:
<a class="socials" href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" style="background: none !important; margin: 0 5px; padding:0;">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25px" height="25px" viewBox="0 0 256 250" version="1.1" preserveAspectRatio="xMidYMid">
<g><path d="M128.00106,0 C57.3172926,0 0,57.3066942 0,128.00106 C0,184.555281 36.6761997,232.535542 87.534937,249.460899 C93.9320223,250.645779 96.280588,246.684165 96.280588,243.303333 C96.280588,240.251045 96.1618878,230.167899 96.106777,219.472176 C60.4967585,227.215235 52.9826207,204.369712 52.9826207,204.369712 C47.1599584,189.574598 38.770408,185.640538 38.770408,185.640538 C27.1568785,177.696113 39.6458206,177.859325 39.6458206,177.859325 C52.4993419,178.762293 59.267365,191.04987 59.267365,191.04987 C70.6837675,210.618423 89.2115753,204.961093 96.5158685,201.690482 C97.6647155,193.417512 100.981959,187.77078 104.642583,184.574357 C76.211799,181.33766 46.324819,170.362144 46.324819,121.315702 C46.324819,107.340889 51.3250588,95.9223682 59.5132437,86.9583937 C58.1842268,83.7344152 53.8029229,70.715562 60.7532354,53.0843636 C60.7532354,53.0843636 71.5019501,49.6441813 95.9626412,66.2049595 C106.172967,63.368876 117.123047,61.9465949 128.00106,61.8978432 C138.879073,61.9465949 149.837632,63.368876 160.067033,66.2049595 C184.49805,49.6441813 195.231926,53.0843636 195.231926,53.0843636 C202.199197,70.715562 197.815773,83.7344152 196.486756,86.9583937 C204.694018,95.9223682 209.660343,107.340889 209.660343,121.315702 C209.660343,170.478725 179.716133,181.303747 151.213281,184.472614 C155.80443,188.444828 159.895342,196.234518 159.895342,208.176593 C159.895342,225.303317 159.746968,239.087361 159.746968,243.303333 C159.746968,246.709601 162.05102,250.70089 168.53925,249.443941 C219.370432,232.499507 256,184.536204 256,128.00106 C256,57.3066942 198.691187,0 128.00106,0 Z M47.9405593,182.340212 C47.6586465,182.976105 46.6581745,183.166873 45.7467277,182.730227 C44.8183235,182.312656 44.2968914,181.445722 44.5978808,180.80771 C44.8734344,180.152739 45.876026,179.97045 46.8023103,180.409216 C47.7328342,180.826786 48.2627451,181.702199 47.9405593,182.340212 Z M54.2367892,187.958254 C53.6263318,188.524199 52.4329723,188.261363 51.6232682,187.366874 C50.7860088,186.474504 50.6291553,185.281144 51.2480912,184.70672 C51.8776254,184.140775 53.0349512,184.405731 53.8743302,185.298101 C54.7115892,186.201069 54.8748019,187.38595 54.2367892,187.958254 Z M58.5562413,195.146347 C57.7719732,195.691096 56.4895886,195.180261 55.6968417,194.042013 C54.9125733,192.903764 54.9125733,191.538713 55.713799,190.991845 C56.5086651,190.444977 57.7719732,190.936735 58.5753181,192.066505 C59.3574669,193.22383 59.3574669,194.58888 58.5562413,195.146347 Z M65.8613592,203.471174 C65.1597571,204.244846 63.6654083,204.03712 62.5716717,202.981538 C61.4524999,201.94927 61.1409122,200.484596 61.8446341,199.710926 C62.5547146,198.935137 64.0575422,199.15346 65.1597571,200.200564 C66.2704506,201.230712 66.6095936,202.705984 65.8613592,203.471174 Z M75.3025151,206.281542 C74.9930474,207.284134 73.553809,207.739857 72.1039724,207.313809 C70.6562556,206.875043 69.7087748,205.700761 70.0012857,204.687571 C70.302275,203.678621 71.7478721,203.20382 73.2083069,203.659543 C74.6539041,204.09619 75.6035048,205.261994 75.3025151,206.281542 Z M86.046947,207.473627 C86.0829806,208.529209 84.8535871,209.404622 83.3316829,209.4237 C81.8013,209.457614 80.563428,208.603398 80.5464708,207.564772 C80.5464708,206.498591 81.7483088,205.631657 83.2786917,205.606221 C84.8005962,205.576546 86.046947,206.424403 86.046947,207.473627 Z M96.6021471,207.069023 C96.7844366,208.099171 95.7267341,209.156872 94.215428,209.438785 C92.7295577,209.710099 91.3539086,209.074206 91.1652603,208.052538 C90.9808515,206.996955 92.0576306,205.939253 93.5413813,205.66582 C95.054807,205.402984 96.4092596,206.021919 96.6021471,207.069023 Z" fill="currentColor" /></g>
</svg>
</a>
<a class="socials" href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel" style="background: none !important; margin: 0 5px; padding:0;">
<svg style="width:25px;height:25px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,20L15.46,14H15.45C15.79,13.4 16,12.73 16,12C16,10.8 15.46,9.73 14.62,9H19.41C19.79,9.93 20,10.94 20,12A8,8 0 0,1 12,20M4,12C4,10.54 4.39,9.18 5.07,8L8.54,14H8.55C9.24,15.19 10.5,16 12,16C12.45,16 12.88,15.91 13.29,15.77L10.89,19.91C7,19.37 4,16.04 4,12M15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12M12,4C14.96,4 17.54,5.61 18.92,8H12C10.06,8 8.45,9.38 8.08,11.21L5.7,7.08C7.16,5.21 9.44,4 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
</svg>
</a>
</div>
</div>
`).firstChild
let exitbutton = document.createElement('div')
exitbutton.id = 'whatsnewclosebutton'
container.append(header)
container.append(text as ChildNode)
container.append(footer as ChildNode)
container.append(exitbutton)
background.append(container)
document.getElementById('container')!.append(background)
let bkelement = document.getElementById('whatsnewbk')
let popup = document.getElementsByClassName('whatsnewContainer')[0]
if (settingsState.animations) {
animate(
[popup, bkelement as HTMLElement],
{ scale: [0, 1], opacity: [0, 1] },
{ easing: spring({ stiffness: 220, damping: 18 }) }
)
animate(
'.whatsnewTextContainer *',
{ opacity: [0, 1], y: [10, 0] },
{
delay: stagger(0.05, { start: 0.1 }),
duration: 0.5,
easing: [.22, .03, .26, 1]
}
)
}
delete settingsState.justupdated
bkelement!.addEventListener('click', function (event) {
// Check if the click event originated from the element itself and not any of its children
if (event.target === bkelement) {
DeleteWhatsNew()
}
});
var closeelement = document.getElementById('whatsnewclosebutton')
closeelement!.addEventListener('click', function () {
DeleteWhatsNew()
})
}
export async function finishLoad() { export async function finishLoad() {
try { try {
document.querySelector('.legacy-root')?.classList.remove('hidden'); document.querySelector('.legacy-root')?.classList.remove('hidden');
@@ -356,7 +468,10 @@ export async function finishLoad() {
if (settingsState.justupdated && !document.getElementById('whatsnewbk')) { if (settingsState.justupdated && !document.getElementById('whatsnewbk')) {
OpenWhatsNewPopup(); OpenWhatsNewPopup();
/* Background Migration script */
} }
migrateBackgrounds();
} }
async function DeleteWhatsNew() { async function DeleteWhatsNew() {
@@ -522,12 +637,9 @@ function applyDarkModeToIframe(iframe: HTMLIFrameElement, cssLink: HTMLStyleElem
const iframeDocument = iframe.contentDocument; const iframeDocument = iframe.contentDocument;
if (!iframeDocument) return; if (!iframeDocument) return;
if (iframeDocument.readyState !== 'complete') { iframe.onload = () => {
iframe.onload = () => { applyDarkModeToIframe(iframe, cssLink);
applyDarkModeToIframe(iframe, cssLink); };
};
return;
}
if (settingsState.DarkMode) { if (settingsState.DarkMode) {
iframeDocument.documentElement.classList.add('dark') iframeDocument.documentElement.classList.add('dark')
@@ -935,30 +1047,22 @@ export function AppendElementsToDisabledPage() {
document.head.append(settingsStyle) document.head.append(settingsStyle)
} }
export function closeSettings() { export const closeExtensionPopup = (extensionPopup?: HTMLElement) => {
const ExtensionSettings = document.getElementById('ExtensionPopup')! if (!extensionPopup) extensionPopup = document.getElementById('ExtensionPopup')!
const ExtensionIframe = document.getElementById('ExtensionIframe') as HTMLIFrameElement
if (SettingsClicked == true) { extensionPopup.classList.add('hide')
ExtensionSettings!.classList.add('hide') if (settingsState.animations) {
if (settingsState.animations) { animate((progress) => {
animate( extensionPopup.style.opacity = Math.max(0, 1 - progress).toString()
'#ExtensionPopup', extensionPopup.style.transform = `scale(${Math.max(0, 1 - progress)})`
{ opacity: [1, 0], scale: [1, 0] }, }, { easing: spring({ stiffness: 520, damping: 20 }) })
{ easing: spring({ stiffness: 220, damping: 18 }) } } else {
) extensionPopup.style.opacity = '0'
} else { extensionPopup.style.transform = 'scale(0)'
ExtensionSettings.style.opacity = '0'
ExtensionSettings.style.transform = 'scale(0)'
}
SettingsClicked = false
if (ExtensionIframe.contentWindow) {
ExtensionIframe.contentWindow.postMessage('popupClosed', '*')
}
} }
ExtensionSettings!.classList.add('hide') settingsPopup.triggerClose()
SettingsClicked = false
} }
export function addExtensionSettings() { export function addExtensionSettings() {
@@ -969,42 +1073,23 @@ export function addExtensionSettings() {
const extensionContainer = document.querySelector('#container') as HTMLDivElement const extensionContainer = document.querySelector('#container') as HTMLDivElement
if (extensionContainer) extensionContainer.appendChild(extensionPopup) if (extensionContainer) extensionContainer.appendChild(extensionPopup)
const extensionIframe: HTMLIFrameElement = document.createElement('iframe') // create shadow dom and render svelte app
extensionIframe.src = `${browser.runtime.getURL('interface/index.html')}#settings/embedded` try {
extensionIframe.id = 'ExtensionIframe' const shadow = extensionPopup.attachShadow({ mode: 'open' });
extensionIframe.setAttribute('allowTransparency', 'true') requestIdleCallback(() => renderSvelte(Settings, shadow));
extensionIframe.setAttribute('excludeDarkCheck', 'true') } catch (err) {
extensionIframe.style.width = '384px' console.error(err)
extensionIframe.style.height = '100%' }
extensionIframe.style.border = 'none'
extensionPopup.appendChild(extensionIframe)
const container = document.getElementById('container') const container = document.getElementById('container')
new SettingsResizer(); new SettingsResizer();
const closeExtensionPopup = () => {
const ExtensionIframe = document.getElementById('ExtensionIframe') as HTMLIFrameElement
extensionPopup.classList.add('hide')
if (settingsState.animations) {
animate(
'#ExtensionPopup',
{ opacity: [1, 0], scale: [1, 0] },
{ easing: spring({ stiffness: 220, damping: 18 }) }
)
} else {
extensionPopup.style.opacity = '0'
extensionPopup.style.transform = 'scale(0)'
}
if (ExtensionIframe.contentWindow) {
ExtensionIframe.contentWindow.postMessage('popupClosed', '*')
}
SettingsClicked = false
}
container!.onclick = (event) => { container!.onclick = (event) => {
if ((event.target as HTMLElement).closest('#AddedSettings') == null && SettingsClicked) { if (!SettingsClicked) return;
if (!(event.target as HTMLElement).closest('#AddedSettings')) {
if (event.target == extensionPopup) return;
closeExtensionPopup() closeExtensionPopup()
} }
} }
@@ -1239,21 +1324,17 @@ export function setupSettingsButton() {
var AddedSettings = document.getElementById('AddedSettings'); var AddedSettings = document.getElementById('AddedSettings');
var extensionPopup = document.getElementById('ExtensionPopup'); var extensionPopup = document.getElementById('ExtensionPopup');
AddedSettings!.addEventListener('click', function () { AddedSettings!.addEventListener('click', async () => {
if (SettingsClicked) { if (SettingsClicked) {
extensionPopup!.classList.add('hide'); closeExtensionPopup(extensionPopup as HTMLElement);
if (settingsState.animations) {
animate('#ExtensionPopup', { opacity: [1, 0], scale: [1, 0] }, { easing: spring({ stiffness: 220, damping: 18 }) });
} else {
extensionPopup!.style.opacity = '0'
extensionPopup!.style.transform = 'scale(0)'
}
(document.getElementById('ExtensionIframe')! as HTMLIFrameElement).contentWindow!.postMessage('popupClosed', '*');
SettingsClicked = false;
} else { } else {
extensionPopup!.classList.remove('hide');
if (settingsState.animations) { if (settingsState.animations) {
animate('#ExtensionPopup', { opacity: [0, 1], scale: [0, 1] }, { easing: spring({ stiffness: 260, damping: 24 }) }); animate((progress) => {
extensionPopup!.style.opacity = progress.toString()
extensionPopup!.style.transform = `scale(${progress})`
}, { easing: spring({ stiffness: 280, damping: 20 }) });
extensionPopup!.classList.remove('hide');
} else { } else {
extensionPopup!.style.opacity = '1' extensionPopup!.style.opacity = '1'
extensionPopup!.style.transform = 'scale(1)' extensionPopup!.style.transform = 'scale(1)'
+1 -1
View File
@@ -1,5 +1,5 @@
import browser from 'webextension-polyfill' import browser from 'webextension-polyfill'
import { SettingsState } from "@/types/storage"; import type { SettingsState } from "@/types/storage";
export const openDB = () => { export const openDB = () => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
+297 -51
View File
@@ -1,9 +1,9 @@
@charset "UTF-8"; @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/sidebar-animation.scss";
@import './injected/theme.scss'; @import "./injected/theme.scss";
@import './injected/transparency.scss'; @import "./injected/transparency.scss";
:root { :root {
background: var(--better-main) !important; background: var(--better-main) !important;
@@ -16,7 +16,12 @@
} }
body, 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 { html {
font-family: Rubik, sans-serif !important; font-family: Rubik, sans-serif !important;
} }
@@ -26,10 +31,6 @@ html {
} }
* { * {
--theme-fg-parts: white; --theme-fg-parts: white;
transition:
background-color 200ms ease-in-out,
backdrop-filter 200ms ease-in-out;
} }
.extension-editor { .extension-editor {
background: var(--background-primary); background: var(--background-primary);
@@ -39,7 +40,8 @@ html {
height: 100%; height: 100%;
visibility: visible !important; visibility: visible !important;
} }
#themeCreatorIframe {
#themeCreator {
position: fixed; position: fixed;
right: 0; right: 0;
height: 100%; height: 100%;
@@ -62,6 +64,40 @@ html {
} }
} }
#store {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 9998;
animation: fadeIn 500ms forwards;
animation-delay: 200ms;
opacity: 0;
&.hide {
animation: fadeOut 500ms forwards;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.dark .resizeBar { .dark .resizeBar {
background-color: rgb(28 28 30); background-color: rgb(28 28 30);
} }
@@ -169,7 +205,8 @@ html {
background: unset; 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; background-image: unset !important;
} }
@@ -179,7 +216,8 @@ html {
} }
.dark .dashboard section { .dark .dashboard section {
input, select { input,
select {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
} }
} }
@@ -201,7 +239,7 @@ html {
input, input,
select { select {
border: transparent; border: transparent;
background: rgba(0, 0, 0, .1); background: rgba(0, 0, 0, 0.1);
color: var(--text-primary); color: var(--text-primary);
} }
@@ -243,7 +281,7 @@ html {
color: var(--text-primary); color: var(--text-primary);
} }
ul.magicDelete > li:hover { ul.magicDelete > li:hover {
background: rgba(0, 0, 0, .1); background: rgba(0, 0, 0, 0.1);
} }
.dashlet-notes > .editor { .dashlet-notes > .editor {
background: unset; background: unset;
@@ -261,7 +299,17 @@ ul.magicDelete > li.deleting {
background: transparent !important; background: transparent !important;
color: var(--text-color) !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 li,
#menu section { #menu section {
margin: 8px auto !important; margin: 8px auto !important;
@@ -292,7 +340,6 @@ ul.magicDelete > li.deleting {
#menu { #menu {
width: 270px; width: 270px;
z-index: 19; z-index: 19;
transition-duration: 0.4s;
background: var(--better-main) !important; background: var(--better-main) !important;
color: var(--text-color); color: var(--text-color);
border-right: none; border-right: none;
@@ -338,14 +385,16 @@ ul.magicDelete > li.deleting {
position: relative; position: relative;
&::before { &::before {
content: ''; content: "";
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
bottom: 0; bottom: 0;
width: 3px; width: 3px;
background: var(--item-colour, transparent); 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; border-radius: 8px 0 0 8px;
} }
@@ -447,7 +496,7 @@ ol:has(.MessageList__avatar___2wxyb svg) {
.quickbar .actions [title="Choose a colour"] > svg { .quickbar .actions [title="Choose a colour"] > svg {
scale: 0.9; scale: 0.9;
} }
.quickbar[data-yiq='light'] .actions { .quickbar[data-yiq="light"] .actions {
color: white !important; color: white !important;
} }
.singleSelect > li { .singleSelect > li {
@@ -474,7 +523,7 @@ ol:has(.MessageList__avatar___2wxyb svg) {
} }
#main .timetablepage .quickbar { #main .timetablepage .quickbar {
border: none; 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; border-radius: 16px;
} }
.quickbar .actions { .quickbar .actions {
@@ -505,7 +554,7 @@ ol:has(.MessageList__avatar___2wxyb svg) {
margin: 0 0 0 -12px; margin: 0 0 0 -12px;
background-color: rgba(255, 255, 255, 0.2); background-color: rgba(255, 255, 255, 0.2);
clip-path: polygon(50% 40%, 0 0, 100% 0); 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; border-top-color: transparent;
} }
#main > .timetablepage > .quickbar.above::before { #main > .timetablepage > .quickbar.above::before {
@@ -1294,7 +1343,9 @@ div > ol:has(.uiFileHandlerWrapper) {
height: 25px; height: 25px;
width: 24px; width: 24px;
} }
.notifications__notifications___3mmLY > button > .notifications__bubble___1EkSQ { .notifications__notifications___3mmLY
> button
> .notifications__bubble___1EkSQ {
background: var(--better-alert-highlight); background: var(--better-alert-highlight);
width: 25px; width: 25px;
height: 25px; height: 25px;
@@ -1500,7 +1551,9 @@ iframe.userHTML {
.Collapsible__Collapsible___3O8P3 > .Collapsible__header___-Afvq { .Collapsible__Collapsible___3O8P3 > .Collapsible__header___-Afvq {
background: none; background: none;
} }
.AssessmentList__AssessmentList___1GdCl > .AssessmentList__searchFilter___3N70o + .AssessmentList__items___3LcmQ { .AssessmentList__AssessmentList___1GdCl
> .AssessmentList__searchFilter___3N70o
+ .AssessmentList__items___3LcmQ {
color: var(--text-primary); color: var(--text-primary);
} }
.Thermoscore__Thermoscore___2tWMi { .Thermoscore__Thermoscore___2tWMi {
@@ -1550,19 +1603,10 @@ iframe.userHTML {
#main > .course > .content > .resources > h2 { #main > .course > .content > .resources > h2 {
color: var(--text-primary); color: var(--text-primary);
} }
::-webkit-scrollbar {
width: 10px; /* set button(top and bottom of the scrollbar) */
height: 10px; body::-webkit-scrollbar-button {
transition: 1s; display: none !important;
border-radius: 16px;
//width: 0px !important;
//background: none;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
::-webkit-scrollbar-corner {
background: none;
} }
:root, :root,
html, html,
@@ -1571,8 +1615,19 @@ div,
ol, ol,
ul { ul {
scrollbar-width: thin !important; 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 { .connectedNotificationsWrapper > div > button {
color: var(--text-primary) !important; color: var(--text-primary) !important;
height: 45px; height: 45px;
@@ -1596,10 +1651,13 @@ ul {
> .SelectedAssessment__clearBtn___21D85 { > .SelectedAssessment__clearBtn___21D85 {
background: var(--better-main); 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); 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); border-bottom-color: var(--better-main);
} }
.TabSet__TabSet___Vo-SZ > ol.TabSet__tabs___1RRZk { .TabSet__TabSet___Vo-SZ > ol.TabSet__tabs___1RRZk {
@@ -1724,7 +1782,15 @@ div.entry.class[style*="left: 46.5%"] {
div.entry.class[style*="width: 46.5%"] { div.entry.class[style*="width: 46.5%"] {
width: 50% !important; width: 50% !important;
} }
.timetablepage .dailycal > .content > .wrapper > .days > tbody > tr > td > .entriesWrapper { .timetablepage
.dailycal
> .content
> .wrapper
> .days
> tbody
> tr
> td
> .entriesWrapper {
min-width: 0; min-width: 0;
width: auto !important; width: auto !important;
} }
@@ -1918,7 +1984,6 @@ div.bar.flat {
.cke_toolbox > .cke_toolbar .cke_button_on { .cke_toolbox > .cke_toolbar .cke_button_on {
background-color: #3d3d3e !important; background-color: #3d3d3e !important;
} }
} }
.legacy-root input.singleSelect:focus { .legacy-root input.singleSelect:focus {
background: var(--auto-background); background: var(--auto-background);
@@ -1962,7 +2027,15 @@ body {
.forumView .assessment { .forumView .assessment {
background: var(--better-main); background: var(--better-main);
} }
.dailycal > .content > .wrapper > .days > tbody > tr > td > .entriesWrapper > .entry { .dailycal
> .content
> .wrapper
> .days
> tbody
> tr
> td
> .entriesWrapper
> .entry {
padding: 3px; padding: 3px;
} }
.Viewer__Viewer___32BH- { .Viewer__Viewer___32BH- {
@@ -2001,7 +2074,9 @@ li.MessageList__unread___3imtO {
border-radius: 1600px; 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; box-shadow: none;
} }
@@ -2021,7 +2096,9 @@ li.MessageList__unread___3imtO {
transition: width 0.1s; transition: width 0.1s;
} }
.MessageList__MessageList___3DxoC > ol > li.MessageList__unread___3imtO::before { .MessageList__MessageList___3DxoC
> ol
> li.MessageList__unread___3imtO::before {
width: 3px; width: 3px;
} }
.connectedNotificationsWrapper > div > button { .connectedNotificationsWrapper > div > button {
@@ -2095,7 +2172,10 @@ li.MessageList__unread___3imtO {
cursor: pointer; cursor: pointer;
} }
.dark .MessageList__MessageList___3DxoC > ol > li.MessageList__selected___1SJNz { .dark
.MessageList__MessageList___3DxoC
> ol
> li.MessageList__selected___1SJNz {
background: var(--background-secondary); background: var(--background-secondary);
} }
@@ -2513,20 +2593,21 @@ li.MessageList__unread___3imtO {
border-radius: 5px; border-radius: 5px;
color: var(--text-color); color: var(--text-color);
} }
/* On mouse-over, add a grey background color */
.upcoming-checkbox-container:hover input ~ .upcoming-checkmark { .upcoming-checkbox-container:hover input ~ .upcoming-checkmark {
filter: brightness(0.8); filter: brightness(0.8);
} }
/* When the checkbox is checked, add a blue background */
.upcoming-checkbox-container input:checked ~ .upcoming-checkmark { .upcoming-checkbox-container input:checked ~ .upcoming-checkmark {
background: var(--item-colour); background: var(--item-colour);
} }
/* Create the checkmark/indicator (hidden when not checked) */
.upcoming-checkmark:after { .upcoming-checkmark:after {
content: ""; content: "";
position: absolute; position: absolute;
display: none; display: none;
} }
/* Show the checkmark when checked */ /* Show the checkmark when checked */
.upcoming-checkbox-container input:checked ~ .upcoming-checkmark:after { .upcoming-checkbox-container input:checked ~ .upcoming-checkmark:after {
display: block; display: block;
@@ -2585,7 +2666,9 @@ li.MessageList__unread___3imtO {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; 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; transition: 200ms;
position: relative; position: relative;
height: 15em; height: 15em;
@@ -2594,7 +2677,9 @@ li.MessageList__unread___3imtO {
font-family: Rubik, sans-serif; font-family: Rubik, sans-serif;
} }
.dark .day { .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 { .clickable {
cursor: pointer; cursor: pointer;
@@ -2619,6 +2704,157 @@ li.MessageList__unread___3imtO {
margin: 0; margin: 0;
} }
.upcoming-items {
background: var(--background-primary);
width: 100%;
max-height: 55em;
overflow-y: auto;
display: flex;
flex-direction: column;
color: var(--text-primary);
transition: 200ms;
border-radius: 16px;
}
.dark .upcoming-items {
box-shadow: inset 0px 40px 80px -40px rgba(0, 0, 0, 0.6);
}
.upcoming-assessment-title {
color: var(--text-primary);
transition: 200ms;
font-size: 10px;
margin: 0;
}
.upcoming-assessment {
border: 3px solid var(--item-colour);
margin: 5px 50px;
height: 6em;
padding: 0px;
border-radius: 10px;
}
.upcoming-assessment {
display: flex;
}
.upcoming-date-container {
margin-bottom: 20px;
}
.upcoming-date-title h5 {
margin: 0;
font-weight: 500;
}
.upcoming-details {
width: 60%;
display: flex;
flex-direction: column;
justify-content: center;
padding: 0px 12px;
}
.upcoming-details h5 {
text-transform: uppercase;
color: #aaaaaa;
padding: 0px 4px;
margin: 0;
}
.upcoming-details p {
font-size: 15px;
padding: 4px;
}
.upcoming-special-day {
font-size: 20px;
}
.upcoming-blank {
display: flex;
border-bottom: 2px solid #bebebe;
margin: 5px 50px;
height: 2em;
padding: 0px;
}
.upcoming-blank p {
padding: 0;
margin: 0;
}
.upcoming-title {
display: flex;
align-content: space-between;
}
.upcoming-filters {
display: flex;
height: 26px;
width: 65%;
align-self: center;
align-items: center;
color: var(--text-color);
padding: 5px;
overflow-x: scroll;
overflow-y: hidden;
}
.upcoming-checkbox-container {
position: relative;
padding: none !important;
padding-left: 25px !important;
padding-right: 10px !important;
cursor: pointer;
font-size: 12px;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
height: 20px;
align-items: center;
display: flex;
}
/* Hide the browser's default checkbox */
.upcoming-checkbox-container input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
padding: 0;
}
/* Create a custom checkbox */
.upcoming-checkmark {
position: absolute;
top: 0;
left: 0;
height: 15px;
width: 15px;
border: 3px solid var(--item-colour);
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;
}
/* Style the checkmark/indicator */
.upcoming-checkbox-container .upcoming-checkmark:after {
left: 3.5px;
top: 0px;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 3px 3px 0;
-webkit-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.upcoming-hiddenassessment {
color: #797979;
}
.titlebar { .titlebar {
align-items: center; align-items: center;
transition: 200ms; transition: 200ms;
@@ -2760,7 +2996,6 @@ li.MessageList__unread___3imtO {
width: 90%; width: 90%;
border-radius: 16px; border-radius: 16px;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.3); box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.3);
} }
.whatsnewTextContainer { .whatsnewTextContainer {
display: flex; display: flex;
@@ -2830,9 +3065,20 @@ li.MessageList__unread___3imtO {
} }
.whatsnewTextContainer h1:not(.whatsnewTextHeader) { .whatsnewTextContainer h1:not(.whatsnewTextHeader) {
position: sticky; position: sticky;
font-size: 1.2em;
width: 100%; width: 100%;
top: 0; top: 0;
background: var(--background-primary) !important; background: var(--background-primary) !important;
z-index: 1; 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;
} }
+1
View File
@@ -36,4 +36,5 @@
transform-origin: 70% 0; transform-origin: 70% 0;
will-change: opacity, transform; will-change: opacity, transform;
transform: translateZ(0); // promotes GPU rendering transform: translateZ(0); // promotes GPU rendering
transition: opacity 0.05s, transform 0.05s;
} }
-51
View File
@@ -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
View File
@@ -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

-32
View File
@@ -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);
+7
View File
@@ -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>
-45
View File
@@ -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;
-11
View File
@@ -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>
-48
View File
@@ -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} />
)
}
+86
View File
@@ -0,0 +1,86 @@
div:has(> #rbgcp-wrapper) {
background: transparent !important;
}
.dark {
#rbgcp-wrapper {
div[style="padding-top: 11px; position: relative;"] div {
color: white !important;
}
div:has(> #rbgcp-solid-btn),
div:has(> #rbgcp-advanced-btn),
#rbgcp-color-model-btn > div,
#rbgcp-gradient-controls-wrap {
background-color: #37373b !important;
color: white !important;
svg {
circle {
fill: white !important;
}
polyline,
line,
g,
path {
stroke: white !important;
}
}
#rbgcp-radial-btn,
#rbgcp-linear-btn {
&[style*="background: white;"] {
background-color: #28282b !important;
}
svg {
path,
g,
polyline,
circle {
stroke: white !important;
fill: transparent !important;
}
}
}
div:has(> #rbgcp-stop-input) svg {
path {
stroke: unset !important;
fill: white !important;
}
}
#rbgcp-comparibles-btn svg path {
fill: white !important;
}
> div {
color: white !important;
&[style*="background: white;"] {
background: #28282b !important;
}
}
}
div:has(> #rbgcp-degree-input) {
width: 70px !important;
}
#rbgcp-degree-input {
width: 50px !important;
}
#rbgcp-degree-input,
#rbgcp-stop-input {
color: white !important;
}
#rbgcp-gradient-controls-wrap > div,
#rbgcp-gradient-controls-wrap > div > div:not([role="button"]) {
background-color: #37373b !important;
}
}
}
@@ -0,0 +1,94 @@
<script lang="ts">
import { onMount } from 'svelte'
import ColourPicker from './ColourPicker.tsx';
import ReactAdapter from './utils/ReactAdapter.svelte';
import { animate, spring } from 'motion';
import { delay } from '@/seqta/utils/delay.ts'
const { hidePicker, standalone = false, savePresets = true, customOnChange = null, customState = null } = $props<{
hidePicker?: () => void,
standalone?: boolean,
savePresets?: boolean,
customOnChange?: (color: string) => void,
customState?: string
}>();
let background = $state<HTMLDivElement | null>(null);
let content = $state<HTMLDivElement | null>(null);
const closePicker = async () => {
if (standalone) return;
if (!background || !content) return;
animate(
content,
{ scale: [1, 0.4], opacity: [1, 0] },
{ easing: spring({ stiffness: 400, damping: 30 }) }
);
animate(
background,
{ opacity: [1, 0] },
{ easing: [0.4, 0, 0.2, 1] }
);
await delay(400);
hidePicker();
}
onMount(() => {
if (standalone) return;
if (!background || !content) return;
animate(
background,
{ opacity: [0, 1] },
{ duration: 0.3, easing: [0.4, 0, 0.2, 1] }
);
animate(
content,
{ scale: [0.4, 1], opacity: [0, 1] },
{ easing: spring({ stiffness: 400, damping: 30 }) }
);
const handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
closePicker();
}
};
document.addEventListener('keydown', handleEscapeKey);
return () => {
document.removeEventListener('keydown', handleEscapeKey);
};
});
function handleBackgroundClick(event: MouseEvent) {
if (event.target === background) {
closePicker();
}
}
</script>
{#if standalone}
<div class="h-auto rounded-xl overflow-clip">
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} />
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={background}
class="absolute top-0 left-0 z-50 flex items-center justify-center w-full h-full cursor-pointer bg-black/20"
onclick={handleBackgroundClick}
onkeydown={(e) => { e.key === 'Enter' && handleBackgroundClick }}
>
<div
bind:this={content}
class="h-auto p-4 bg-white border shadow-lg cursor-auto rounded-xl dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
>
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} />
</div>
</div>
{/if}
+108
View File
@@ -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;
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
import { animate as motionAnimate, spring } from 'motion';
let { initial, animate, exit, transition, children, class: className } = $props<{
initial?: any,
animate?: any,
exit?: any,
transition?: any,
children?: any,
class?: string
}>();
let divElement: HTMLElement;
const dispatch = createEventDispatcher();
const playAnimation = (keyframe: any) => {
if (divElement && keyframe) {
let animationOptions = transition;
let finalKeyframe = { ...keyframe };
if (finalKeyframe.height === 'auto') {
const prevHeight = divElement.style.height;
const prevVisibility = divElement.style.visibility;
divElement.style.height = 'auto';
divElement.style.visibility = 'hidden';
divElement.style.position = 'absolute';
const autoHeight = divElement.offsetHeight;
divElement.style.height = prevHeight;
divElement.style.visibility = prevVisibility;
divElement.style.position = '';
finalKeyframe.height = `${autoHeight}px`;
}
if (!transition || transition.type === 'spring') {
const springConfig = transition?.config || { stiffness: 250, damping: 25 };
animationOptions = {
...transition,
easing: spring(springConfig)
};
}
const animation = motionAnimate(divElement, finalKeyframe, animationOptions);
return animation.finished;
}
return Promise.resolve();
};
onMount(async () => {
if (initial) {
Object.assign(divElement.style, initial);
await playAnimation(animate || {});
} else if (animate) {
await playAnimation(animate);
}
dispatch('animationend');
});
$effect(() => {
if (animate) {
playAnimation(animate);
}
dispatch('animationend');
});
onDestroy(async () => {
if (exit) {
await playAnimation(exit);
}
});
</script>
<div class={className} bind:this={divElement} style="will-change: transform, opacity;">
{#if children}
{@render children()}
{/if}
</div>
-32
View File
@@ -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();
}
-127
View File
@@ -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>
-20
View File
@@ -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);
+22
View File
@@ -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>
-7
View File
@@ -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>
-19
View File
@@ -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%;
}
+37
View File
@@ -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>
-25
View File
@@ -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);
+34
View File
@@ -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>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import { animate, spring } from 'motion';
import { standalone } from '../utils/standalone.svelte'
let { state, onChange } = $props<{ state: boolean, onChange: (newState: boolean) => void }>();
let handle: HTMLElement | null = null;
const springParams = {
stiffness: 600,
damping: 30,
};
const animateSwitch = (enabled: boolean) => {
if (!handle) return;
animate(
handle,
{
x: enabled ? (standalone.standalone ? 24 : 20) : 0,
},
{
easing: spring(springParams),
}
);
};
// Trigger animation whenever state changes
$effect(() => animateSwitch(state));
</script>
<div
class="flex w-14 p-1 cursor-pointer transition-all duration-150 rounded-full dark:bg-[#38373D] bg-[#DDDDDD] switch select-none"
data-ison={state}
onclick={() => onChange(!state)}
onkeydown={(e) => e.key === "Enter" && onChange(!state)}
role="switch"
aria-checked={state}
tabindex="0"
>
<div
bind:this={handle}
class="w-6 h-6 bg-white dark:bg-[#FEFEFE] rounded-full drop-shadow-md"
></div>
</div>
<style>
.switch[data-ison="true"] {
background-color: #30D259;
}
</style>
-35
View File
@@ -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);
-104
View File
@@ -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>;
};
-269
View File
@@ -1,269 +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>}
<a
href={browser.runtime.getURL('interface/index.html#store')}
target="_blank"
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>
</a>
<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">&#xed2c;</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">&#xea9a;</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">&#xed8a;</span>
</button>
</div>
</div>
</header>
@@ -0,0 +1,17 @@
<script lang="ts">
let { theme, onClick } = $props<{ theme: Theme; onClick: () => void }>();
import { fade } from 'svelte/transition';
</script>
<div class="w-full cursor-pointer" role="button" tabindex="-1" onkeydown={onClick} onclick={onClick}>
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border" transition:fade>
<div class="absolute z-10 mb-1 text-xl font-bold text-white bottom-1 left-3">
{theme.name}
</div>
<div class='absolute bottom-0 z-0 w-full h-3/4 bg-gradient-to-t from-black/80 to-transparent'></div>
<div class='w-full'>
<img src={theme.coverImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
</div>
</div>
</div>
@@ -0,0 +1,40 @@
<script lang="ts">
import type { Theme } from '@/interface/types/Theme'
import ThemeCard from './ThemeCard.svelte';
let { themes, searchTerm, setDisplayTheme } = $props<{ themes: Theme[]; searchTerm: string, setDisplayTheme: (theme: Theme) => void }>();
let filteredThemes = $derived(themes.filter((theme: Theme) =>
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase())
));
</script>
<div class="relative" >
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
{#each filteredThemes as theme (theme.id)}
<ThemeCard theme={theme} onClick={() => setDisplayTheme(theme)} />
{/each}
{#if filteredThemes.length !== 0}
<a href="https://betterseqta.gitbook.io/betterseqta-docs" class='w-full cursor-pointer'>
<div class="bg-zinc-50 h-48 w-full transition-all hover:scale-105 duration-500 relative justify-center items-center group group/card flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] rounded-xl overflow-clip border">
<div class="text-2xl font-IconFamily">{'\uecb3'}</div>
<div class="text-xl font-bold text-center transition-all duration-500 dark:text-white">
Got a Theme Idea?
<p class="text-lg font-light subtitle">Transform it into a stunning theme!</p>
</div>
</div>
</a>
{/if}
</div>
{#if filteredThemes.length === 0}
<div class="absolute top-0 flex flex-col items-center justify-center w-full text-center h-96">
<h1 class="mt-4 text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-5xl">That doesn't exist! 😭😭😭</h1>
<p class="mt-6 text-lg leading-7 text-zinc-600 dark:text-zinc-300">Sorry, we couldn't find the theme you're looking for. Maybe... you could create it?</p>
<a href="https://betterseqta.gitbook.io/betterseqta-docs" class='p-2 px-3 mt-4 transition rounded-md cursor-pointer dark:text-white bg-zinc-500/10 hover:scale-105'>
Show me how!
</a>
</div>
{/if}
</div>
@@ -0,0 +1,118 @@
<script lang="ts">
import type { Theme } from '@/interface/types/Theme'
import { fade } from 'svelte/transition';
import { animate, spring } from 'motion';
let { theme, currentThemes, setDisplayTheme, onInstall, onRemove, allThemes, displayTheme } = $props<{
theme: Theme | null;
currentThemes: string[];
setDisplayTheme: (theme: Theme | null) => void;
onInstall: (themeId: string) => void;
onRemove: (themeId: string) => void;
allThemes: Theme[];
displayTheme: Theme | null;
}>();
let installing = $state(false);
let modalElement: HTMLElement;
// Function to get related themes
function getRelatedThemes() {
return allThemes
.filter((t: Theme) => t.id !== theme.id)
.sort((a: Theme, b: Theme) => a.name.localeCompare(theme.name) - b.name.localeCompare(theme.name))
.slice(0, 4);
}
$effect(() => {
if (displayTheme) {
animate(
modalElement,
{ y: [500, 0], opacity: [0, 1] },
{ easing: spring({ stiffness: 150, damping: 20 }) }
);
}
});
const hideModal = (relatedTheme?: Theme | null) => {
animate(
modalElement,
{ y: [10, 500], opacity: [1, 0] },
{ easing: spring({ stiffness: 150, damping: 20 }) }
);
setTimeout(() => {
setDisplayTheme(relatedTheme ?? null);
}, 100);
}
</script>
<div
class="fixed inset-0 z-50 flex items-end justify-center bg-black bg-opacity-70"
onclick={(e) => {
if (e.target === e.currentTarget) hideModal();
}}
onkeydown={(e) => {
if (e.target === e.currentTarget) hideModal();
}}
role="button"
tabindex="-1"
transition:fade
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={modalElement}
class="w-full max-w-[600px] h-[95%] p-4 bg-white rounded-t-2xl dark:bg-zinc-800 overflow-scroll no-scrollbar cursor-auto"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<div class="relative h-auto">
<button class="absolute top-0 right-0 p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200" onclick={() => hideModal()}>
{'\ued8a'}
</button>
<h2 class="mb-4 text-2xl font-bold">
{theme.name}
</h2>
<img src={theme.marqueeImage} alt="Theme Cover" class="object-cover w-full mb-4 rounded-md" />
<p class="mb-4 text-gray-700 dark:text-gray-300">
{theme.description}
</p>
{#if currentThemes.includes(theme.id)}
<button onclick={async () => {installing = true; await onRemove(theme.id); installing = false}} class="relative flex items-center justify-center w-32 px-4 py-2 mt-4 ml-auto text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200">
{#if installing}
<svg class="absolute w-4 h-4 { installing ? 'opacity-100' : 'opacity-0' }" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
</svg>
{/if}
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Remove</span>
</button>
{:else}
<button onclick={async () => {installing = true; await onInstall(theme.id); installing = false}} class="relative flex items-center justify-center w-32 px-4 py-2 mt-4 ml-auto text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200">
{#if installing}
<svg class="absolute w-4 h-4 { installing ? 'opacity-100' : 'opacity-0' }" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
</svg>
{/if}
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Install</span>
</button>
{/if}
<div class="my-8 border-b border-zinc-200 dark:border-zinc-700"></div>
<h3 class="mb-4 text-lg font-bold">
Similar Themes
</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
{#each getRelatedThemes() as relatedTheme (relatedTheme.id)}
<button onclick={() => { hideModal(relatedTheme) }} class="w-full cursor-pointer">
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group group/card flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border">
<div class="absolute z-10 mb-1 text-xl font-bold text-white transition-all duration-500 group-hover:-translate-y-0.5 bottom-1 left-3">
{relatedTheme.name}
</div>
<div class="absolute bottom-0 z-0 w-full h-3/4 bg-gradient-to-t from-black/80 to-transparent"></div>
<img src={relatedTheme.coverImage} alt="Theme Preview" class="object-cover w-full h-48" />
</div>
</button>
{/each}
</div>
</div>
</div>
</div>
-36
View File
@@ -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">&#xeaa5;</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">&#xecb3;</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">&#xecc5;</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">&#xec60;</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>
-8
View File
@@ -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);
});
};
+29
View File
@@ -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();
+37
View File
@@ -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();
-161
View File
@@ -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,
});
};
+29
View File
@@ -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);
-100
View File
@@ -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;
+33 -2
View File
@@ -1,3 +1,5 @@
@import './components/ColourPicker.css';
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@@ -16,6 +18,35 @@
display: none; display: none;
} }
button, a { .tab-width {
@apply text-[0.875rem]; 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;
} }
+3 -5
View File
@@ -5,10 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BetterSEQTA+ Settings</title> <title>BetterSEQTA+ Settings</title>
</head> </head>
<body class="dark:bg-zinc-900"> <body>
<div id="ExtensionPopup"></div> <div id="app"></div>
<script type="module" src="./main.tsx"></script> <script type="module" src="./index.ts"></script>
<script type="module" src="./dark.ts"></script>
</body> </body>
</html> </html>
+46
View File
@@ -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)
-2
View File
@@ -4,5 +4,3 @@ declare module "*.png";
declare module "*.svg"; declare module "*.svg";
declare module "*.jpeg"; declare module "*.jpeg";
declare module "*.jpg"; declare module "*.jpg";
declare module 'react-best-gradient-color-picker';
+24
View File
@@ -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
}
-59
View File
@@ -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>,
);
-50
View File
@@ -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);
-335
View File
@@ -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 couldnt find the theme youre looking for. Maybe... you could create it?</p>
</div>
)}
</div>
</div>
);
};
export default Store;
-341
View File
@@ -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;
+89
View File
@@ -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>
+176
View File
@@ -0,0 +1,176 @@
<script lang="ts">
import Switch from "../../components/Switch.svelte"
import Button from "../../components/Button.svelte"
import Slider from "../../components/Slider.svelte"
import Select from "@/interface/components/Select.svelte"
import browser from "webextension-polyfill"
import type { SettingsList } from "@/interface/types/SettingsProps"
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent"
const { showColourPicker } = $props<{ showColourPicker: () => void }>();
</script>
{#snippet Setting({ title, description, Component, props }: SettingsList) }
<div class="flex items-center justify-between px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">{title}</h2>
<p class="text-xs">{description}</p>
</div>
<div>
<Component {...props} />
</div>
</div>
{/snippet}
<div class="flex flex-col divide-y divide-zinc-100 dark:divide-zinc-700">
{#each [
{
title: "Transparency Effects",
description: "Enables transparency effects on certain elements such as blur. (May impact battery life)",
id: 1,
Component: Switch,
props: {
state: $settingsState.transparencyEffects,
onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn
}
},
{
title: "Animated Background",
description: "Adds an animated background to BetterSEQTA. (May impact battery life)",
id: 2,
Component: Switch,
props: {
state: $settingsState.animatedbk,
onChange: (isOn: boolean) => settingsState.animatedbk = isOn
}
},
{
title: "Animated Background Speed",
description: "Controls the speed of the animated background.",
id: 3,
Component: Slider,
props: {
state: $settingsState.bksliderinput,
onChange: (value: number) => settingsState.bksliderinput = `${value}`
}
},
{
title: "Custom Theme Colour",
description: "Customise the overall theme colour of SEQTA Learn.",
id: 4,
Component: PickerSwatch,
props: {
onClick: showColourPicker
}
},
{
title: "Edit Sidebar Layout",
description: "Customise the sidebar layout.",
id: 5,
Component: Button,
props: {
onClick: () => browser.runtime.sendMessage({ type: 'currentTab', info: 'EditSidebar' }),
text: "Edit"
}
},
{
title: "Animations",
description: "Enables animations on certain pages.",
id: 6,
Component: Switch,
props: {
state: $settingsState.animations,
onChange: (isOn: boolean) => settingsState.animations = isOn
}
},
{
title: "Notification Collector",
description: "Uncaps the 9+ limit for notifications, showing the real number.",
id: 7,
Component: Switch,
props: {
state: $settingsState.notificationcollector,
onChange: (isOn: boolean) => settingsState.notificationcollector = isOn
}
},
{
title: "Lesson Alerts",
description: "Sends a native browser notification ~5 minutes prior to lessons.",
id: 8,
Component: Switch,
props: {
state: $settingsState.lessonalert,
onChange: (isOn: boolean) => settingsState.lessonalert = isOn
}
},
{
title: "12 Hour Time",
description: "Prefer 12 hour time format for SEQTA",
id: 9,
Component: Switch,
props: {
state: $settingsState.timeFormat === "12",
onChange: (isOn: boolean) => settingsState.timeFormat = isOn ? "12" : "24"
}
},
{
title: "Default Page",
description: "The page to load when SEQTA Learn is opened.",
id: 10,
Component: Select,
props: {
state: $settingsState.defaultPage,
onChange: (value: string) => settingsState.defaultPage = value,
options: [
{ value: 'home', label: 'Home' },
{ value: 'dashboard', label: 'Dashboard' },
{ value: 'timetable', label: 'Timetable' },
{ value: 'welcome', label: 'Welcome' },
{ value: 'messages', label: 'Messages' },
{ value: 'documents', label: 'Documents' },
{ value: 'reports', label: 'Reports' },
]
}
},
{
title: "BetterSEQTA+",
description: "Enables BetterSEQTA+ features",
id: 11,
Component: Switch,
props: {
state: $settingsState.onoff,
onChange: (isOn: boolean) => settingsState.onoff = isOn
}
}
] as option}
{@render Setting(option)}
{/each}
{#if $settingsState.devMode}
<div class="flex items-center justify-between px-4 py-3 mt-4 pt-[1.75rem]">
<div class="pr-4">
<h2 class="text-sm font-bold">Developer Mode</h2>
<p class="text-xs">Enables developer mode, allowing you to test new features and changes.</p>
</div>
<div>
<Switch state={$settingsState.devMode} onChange={(isOn: boolean) => settingsState.devMode = isOn} />
</div>
</div>
<div class="flex items-center justify-between px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Sensitive Hider</h2>
<p class="text-xs">Replace sensitive content with mock data</p>
</div>
<div>
<Button
onClick={() => hideSensitiveContent()}
text="Hide"
/>
</div>
</div>
{/if}
</div>
@@ -0,0 +1,159 @@
<script lang="ts">
import MotionDiv from '@/interface/components/MotionDiv.svelte';
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
import Switch from "@/interface/components/Switch.svelte"
import { onMount } from 'svelte';
let isLoaded = $state(false);
onMount(async () => {
// Wait for settingsState to be initialized
await new Promise<void>((resolve) => {
const checkState = () => {
if ($settingsState?.shortcuts) {
isLoaded = true;
resolve();
} else {
setTimeout(checkState, 100);
}
};
checkState();
});
});
const switchChange = (index: number) => {
const updatedShortcuts = [...settingsState.shortcuts];
updatedShortcuts[index].enabled = !updatedShortcuts[index].enabled;
settingsState.shortcuts = updatedShortcuts;
}
let isFormVisible = $state(false);
let newTitle = $state("");
let newURL = $state("");
const toggleForm = () => {
isFormVisible = !isFormVisible;
};
const formatUrl = (inputUrl: string) => {
const protocolRegex = /^(http:\/\/|https:\/\/|ftp:\/\/)/;
return protocolRegex.test(inputUrl) ? inputUrl : `https://${inputUrl}`;
};
const isValidTitle = (title: string) => title.trim() !== "";
const isValidURL = (url: string) => {
const pattern = new RegExp("^(https?:\\/\\/)?[\\w.-]+(?:\\.[\\w\\-]+)*(?::\\d+)?(/[\\w\\-./]*)*$", "i");
return pattern.test(url);
};
const addNewCustomShortcut = () => {
if (isValidTitle(newTitle) && isValidURL(newURL)) {
const newShortcut = { name: newTitle.trim(), url: formatUrl(newURL).trim(), icon: newTitle[0] };
settingsState.customshortcuts = [...settingsState.customshortcuts, newShortcut];
newTitle = "";
newURL = "";
isFormVisible = false;
} else {
alert("Please enter a valid title and URL.");
}
};
const deleteCustomShortcut = (index: number) => {
settingsState.customshortcuts = settingsState.customshortcuts.filter((_, i) => i !== index);
};
</script>
{#snippet Shortcuts([index, Shortcut]: [string, { name: string, enabled: boolean }]) }
<div class="flex items-center justify-between px-4 py-3">
<div class="pr-4">
<h2 class="text-sm">{Shortcut.name}</h2>
</div>
<Switch state={Shortcut.enabled} onChange={() => switchChange(parseInt(index))} />
</div>
{/snippet}
<div class="flex flex-col pt-4 divide-y divide-zinc-100 dark:divide-zinc-700">
{#if isLoaded}
<div>
<MotionDiv
initial={{ opacity: 0, height: 0 }}
animate={isFormVisible ? { opacity: 1, height: 'auto' } : { opacity: 0, height: 0 }}
exit={{ opacity: 0, height: 0 }}
transition={{
type: 'spring',
config: { stiffness: 400, damping: 25 }
}}
>
{#if isFormVisible}
<div class="flex flex-col items-center">
<MotionDiv
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0, duration: 0.2 }}
class="w-full"
>
<input
class="w-full p-2 transition border-0 rounded-lg placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
type="text"
placeholder="Shortcut Name"
bind:value={newTitle}
/>
</MotionDiv>
<MotionDiv
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05, duration: 0.2 }}
class="w-full"
>
<input
class="w-full p-2 my-2 transition border-0 rounded-lg placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
type="text"
placeholder="URL eg. https://google.com"
bind:value={newURL}
/>
</MotionDiv>
</div>
{/if}
</MotionDiv>
<MotionDiv
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<button
class="w-full px-4 py-2 mb-4 text-[13px] dark:text-white transition rounded-xl bg-zinc-200 dark:bg-zinc-700/50"
onclick={isFormVisible ? addNewCustomShortcut : toggleForm}
>
{#if isFormVisible}
Add
{:else}
Add Custom Shortcut
{/if}
</button>
</MotionDiv>
</div>
{#each Object.entries($settingsState.shortcuts) as shortcut}
{@render Shortcuts(shortcut)}
{/each}
<!-- Custom Shortcuts Section -->
{#each $settingsState.customshortcuts as shortcut, index}
<div class="flex items-center justify-between px-4 py-3">
{shortcut.name}
<button onclick={() => deleteCustomShortcut(index)}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
{:else}
<div class="p-4 text-center">
Loading shortcuts...
</div>
{/if}
</div>
+36
View File
@@ -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>
+148
View File
@@ -0,0 +1,148 @@
<script lang="ts">
import { onMount } from 'svelte';
// Import existing components
import CoverSwiper from '../components/store/CoverSwiper.svelte';
import ThemeGrid from '../components/store/ThemeGrid.svelte';
import SkeletonLoader from '../components/SkeletonLoader.svelte';
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
import type { Theme } from '../types/Theme'
import browser from 'webextension-polyfill'
import ThemeModal from '../components/store/ThemeModal.svelte'
import { StoreDownloadTheme } from '@/seqta/ui/themes/downloadTheme'
import { setTheme } from '@/seqta/ui/themes/setTheme'
import Header from '../components/store/Header.svelte'
import { deleteTheme } from '@/seqta/ui/themes/deleteTheme'
import { getAvailableThemes } from '@/seqta/ui/themes/getAvailableThemes'
import { themeUpdates } from '../hooks/ThemeUpdates'
import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
import Backgrounds from '../components/store/Backgrounds.svelte'
// State variables
let searchTerm = $state('');
let themes = $state<Theme[]>([]);
let coverThemes = $state<Theme[]>([]);
let loading = $state(true);
let darkMode = $state(false);
let displayTheme = $state<Theme | null>(null);
let currentThemes = $state<string[]>([]);
let activeTab = $state('themes');
let error = $state<string | null>(null);
let selectedBackground = $state<string | null>(null);
const fetchCurrentThemes = async () => {
const themes = await getAvailableThemes();
currentThemes = themes.themes.filter(theme => theme !== null).map(theme => theme.id);
};
const setDisplayTheme = (theme: Theme | null) => {
displayTheme = theme;
};
const setSearchTerm = (term: string) => {
searchTerm = term;
};
const setActiveTab = (tab: string) => {
activeTab = tab;
};
// Fetch themes and initialize app
const fetchThemes = async () => {
try {
const response = await fetch(`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${(new Date()).getTime()}`, { cache: 'no-store' });
const data = await response.json();
themes = data.themes;
// Shuffle for cover themes
const shuffled = [...themes].sort(() => 0.5 - Math.random());
coverThemes = shuffled.slice(0, 3);
loading = false;
} catch (error) {
console.error('Failed to fetch themes', error);
setTimeout(fetchThemes, 5000); // Retry after 5 seconds if failure occurs
}
};
// On mount
onMount(async () => {
await fetchThemes();
await fetchCurrentThemes();
darkMode = (await browser.storage.local.get('DarkMode')).DarkMode === 'true';
darkMode = $settingsState.DarkMode;
});
// Filter themes based on search term
let filteredThemes = $derived(themes.filter(theme =>
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
theme.description.toLowerCase().includes(searchTerm.toLowerCase())
));
$effect(() => {
loadBackground();
selectedBackground
});
$effect(() => {
if (error) {
console.error(error);
}
});
</script>
<div class="w-screen h-screen bg-white {darkMode ? 'dark' : ''}">
<div class="h-full overflow-y-scroll bg-zinc-200/50 dark:bg-zinc-900 dark:text-white pt-[4.25rem]">
<Header {searchTerm} {setSearchTerm} {darkMode} {activeTab} {setActiveTab} />
<div class={`px-12 h-full ${activeTab === 'backgrounds' ? 'pt-0' : 'pt-6 md:px-24 lg:px-48'}`}>
<!-- Loading State -->
{#if loading}
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
<SkeletonLoader width="100%" height="200px" />
</div>
{:else}
<!-- Themes Tab Content -->
{#if activeTab === 'themes'}
{#if searchTerm === ''}
<CoverSwiper {coverThemes} {setDisplayTheme} />
{/if}
<!-- ThemeGrid to display filtered themes -->
<ThemeGrid themes={filteredThemes} {searchTerm} {setDisplayTheme} />
{#if displayTheme}
<ThemeModal
currentThemes={currentThemes}
allThemes={themes}
theme={displayTheme}
{displayTheme}
{setDisplayTheme}
onInstall={async () => {
if (displayTheme) {
await StoreDownloadTheme({themeContent: displayTheme})
setTheme(displayTheme.id);
themeUpdates.triggerUpdate();
await fetchCurrentThemes();
}
}}
onRemove={async () => {
if (displayTheme?.id) {
console.debug('deleting theme', displayTheme.id);
deleteTheme(displayTheme.id)
themeUpdates.triggerUpdate();
await fetchCurrentThemes();
}
}}
/>
{/if}
{:else if activeTab === 'backgrounds'}
<Backgrounds {searchTerm} />
{/if}
{/if}
</div>
</div>
</div>
+358
View File
@@ -0,0 +1,358 @@
<script lang="ts">
import { v4 as uuidv4 } from 'uuid';
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { fade } from 'svelte/transition';
import { type LoadedCustomTheme } from '@/types/CustomThemes'
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
import { getTheme } from '@/seqta/ui/themes/getTheme'
import Divider from '@/interface/components/themeCreator/divider.svelte'
import Switch from '@/interface/components/Switch.svelte'
import Button from '@/interface/components/Button.svelte'
import Slider from '@/interface/components/Slider.svelte'
import ColourPicker from '../components/ColourPicker.svelte'
import CodeEditor from '../components/CodeEditor.svelte'
import {
handleImageUpload,
handleRemoveImage,
handleImageVariableChange,
handleCoverImageUpload
} from '../utils/themeImageHandlers';
import { ClearThemePreview, UpdateThemePreview } from '@/seqta/ui/themes/UpdateThemePreview'
import { saveTheme } from '@/seqta/ui/themes/saveTheme'
import { CloseThemeCreator } from '@/seqta/ui/ThemeCreator'
import { themeUpdates } from '../hooks/ThemeUpdates'
import { disableTheme } from '@/seqta/ui/themes/disableTheme'
const { themeID } = $props<{ themeID: string }>()
let theme = $state<LoadedCustomTheme>({
id: uuidv4(),
name: '',
description: '',
defaultColour: 'blue',
CanChangeColour: true,
allowBackgrounds: true,
CustomCSS: '',
CustomImages: [],
coverImage: null,
isEditable: true,
hideThemeName: false,
forceDark: undefined
})
let closedAccordions = $state<string[]>([])
let themeLoaded = $state(false);
function toggleAccordion(title: string) {
if (closedAccordions.includes(title)) {
closedAccordions = closedAccordions.filter(t => t !== title);
} else {
closedAccordions = [...closedAccordions, title];
}
}
onMount(async () => {
disableTheme();
if (themeID) {
const tempTheme = await getTheme(themeID)
if (!tempTheme) return
// convert temptheme to LoadedCustomTheme
const loadedTheme = {
...tempTheme,
CustomImages: tempTheme.CustomImages.map(image => ({
...image,
url: image.blob ? URL.createObjectURL(image.blob) : null
})),
coverImageUrl: tempTheme.coverImage ? URL.createObjectURL(tempTheme.coverImage) : undefined
}
if (tempTheme) {
theme = loadedTheme
themeLoaded = true
}
} else {
themeLoaded = true
}
themeUpdates.triggerUpdate();
});
async function onImageUpload(event: Event) {
theme = await handleImageUpload(event, theme);
}
function onRemoveImage(imageId: string) {
theme = handleRemoveImage(imageId, theme);
}
function onImageVariableChange(imageId: string, variableName: string) {
theme = handleImageVariableChange(imageId, variableName, theme);
}
async function onCoverImageUpload(event: Event) {
theme = await handleCoverImageUpload(event, theme);
}
function submitTheme() {
const themeClone = JSON.parse(JSON.stringify(theme));
// re-insert blobs into themeClone
themeClone.CustomImages = theme.CustomImages.map((image) => ({
...image,
blob: image.blob
}))
themeClone.coverImage = theme.coverImage
ClearThemePreview();
saveTheme(themeClone);
themeUpdates.triggerUpdate();
CloseThemeCreator();
}
$effect(() => {
UpdateThemePreview(theme);
});
type SettingType = 'switch' | 'button' | 'slider' | 'colourPicker' | 'select' | 'codeEditor' | 'imageUpload' | 'conditional' | 'lightDarkToggle';
type SwitchProps = { state: boolean; onChange: (value: boolean) => void };
type ButtonProps = { onClick: () => void; text: string };
type SliderProps = { state: number; onChange: (value: number) => void; min?: number; max?: number };
type ColourPickerProps = { color: string; onChange: (color: string) => void };
type SelectProps = { options: Array<{ value: string; label: string }>; value: string; onChange: (value: string) => void };
type CodeEditorProps = { value: string; onChange: (value: string) => void };
type LightDarkToggleProps = { state: boolean; onChange: (value: boolean) => void };
type ConditionalProps = {
condition: boolean;
children: SettingItem;
};
type ComponentProps = SwitchProps | ButtonProps | SliderProps | ColourPickerProps | SelectProps | CodeEditorProps | LightDarkToggleProps | ConditionalProps;
type SettingItem = {
type: SettingType;
title: string;
description: string;
direction?: 'horizontal' | 'vertical';
props: ComponentProps;
};
</script>
{#snippet settingItem(item: SettingItem)}
{#if item.type === 'conditional'}
{#if (item.props as ConditionalProps).condition }
<div transition:slide={{ duration: 300 }}>
{@render settingItem((item.props as ConditionalProps).children)}
</div>
{/if}
{:else}
<div class="flex justify-between {item.direction === 'vertical' ? 'flex-col items-start' : 'items-center'} py-3">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={() => { item.direction === 'vertical' && toggleAccordion(item.title) }}
onkeydown={(e) => { e.key === 'Enter' && item.direction === 'vertical' && toggleAccordion(item.title) }}
class="flex justify-between pr-4 {item.direction === 'vertical' ? 'cursor-pointer w-full select-none' : ''}">
<div>
<h2 class="text-sm font-bold">{item.title}</h2>
<p class="text-xs">{item.description}</p>
</div>
{#if item.direction === 'vertical'}
<div class="flex items-center justify-center h-full text-xl font-light text-zinc-500 dark:text-zinc-300">
<span class='font-IconFamily transition-transform duration-300 {closedAccordions.includes(item.title) ? 'rotate-180' : ''}'>{'\ue9e6'}</span>
</div>
{/if}
</div>
{#if !closedAccordions.includes(item.title)}
<div class="{item.direction === 'vertical' ? 'w-full mt-2' : ''}" transition:slide={{ duration: 300 }}>
{#if item.type === 'switch'}
<Switch {...(item.props as SwitchProps)} />
{:else if item.type === 'button'}
<Button {...(item.props as ButtonProps)} />
{:else if item.type === 'slider'}
<Slider {...(item.props as SliderProps)} />
{:else if item.type === 'colourPicker'}
{#key themeLoaded}
<ColourPicker savePresets={false} standalone={true} {...(item.props)} />
{/key}
{:else if item.type === 'codeEditor'}
{#key themeLoaded}
<CodeEditor {...(item.props as CodeEditorProps)} />
{/key}
{:else if item.type === 'imageUpload'}
{#each theme.CustomImages as image (image.id)}
<div class="flex items-center h-16 gap-2 px-2 py-2 mb-4 bg-white rounded-lg shadow-lg dark:bg-zinc-700">
<div class="h-full ">
<img src={image.url} alt={image.variableName} class="object-contain h-full rounded" />
</div>
<input
type="text"
bind:value={image.variableName}
oninput={(e) => onImageVariableChange(image.id, e.currentTarget.value)}
placeholder="CSS Variable Name"
class="flex-grow flex-[3] w-full p-2 transition border-0 rounded-lg dark:placeholder-zinc-300 bg-zinc-200 dark:bg-zinc-600/50 focus:bg-zinc-300/50 dark:focus:bg-zinc-600"
/>
<button onclick={() => onRemoveImage(image.id)} class="p-2 transition dark:text-white">
<span class='text-xl font-IconFamily'>{'\ued8c'}</span>
</button>
</div>
{/each}
<div class="relative flex justify-center w-full h-8 gap-1 overflow-hidden transition rounded-lg place-items-center bg-zinc-200 dark:bg-zinc-700">
<span class='font-IconFamily'>{'\uec60'}</span>
<span class='dark:text-white'>Add image</span>
<input type="file" accept='image/*' onchange={onImageUpload} class="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
</div>
{:else if item.type === 'lightDarkToggle'}
<button
class="relative px-4 py-1 overflow-hidden text-xl font-medium transition rounded-lg bg-zinc-200 dark:bg-zinc-700 hover:bg-zinc-300 dark:hover:bg-zinc-600 font-IconFamily"
onclick={() => (item.props as LightDarkToggleProps).onChange(!(item.props as LightDarkToggleProps).state)}
>
{#key (item.props as LightDarkToggleProps).state}
<span
class="absolute"
in:fade={{ duration: 150 }}
out:fade={{ duration: 150 }}
>
{(item.props as LightDarkToggleProps).state ? '\uec12' : '\uecfe'}
</span>
{/key}
<span class='opacity-0'>{'\uec12'}</span>
</button>
{/if}
</div>
{/if}
</div>
{/if}
{/snippet}
<div class='h-screen overflow-y-scroll {$settingsState.DarkMode && "dark"} no-scrollbar'>
<div class='flex flex-col w-full min-h-screen p-2 bg-zinc-100 dark:bg-zinc-800 dark:text-white'>
<h1 class='text-xl font-semibold'>Theme Creator</h1>
<a href='https://betterseqta.gitbook.io/betterseqta-docs' target='_blank' class='text-sm font-light text-zinc-500 dark:text-zinc-400'>
<span class='no-underline font-IconFamily pr-0.5'>{'\ueb44'}</span>
<span class='underline'>
Need help? Check out the docs!
</span>
</a>
<Divider />
<div>
<div class='pb-2 text-sm'>Theme Name</div>
<input
id='themeName'
type='text'
placeholder='What is your theme called?'
bind:value={theme.name}
class='w-full p-2 mb-4 transition border-0 rounded-lg dark:placeholder-zinc-300 bg-zinc-200 dark:bg-zinc-700 focus:bg-zinc-300/50 dark:focus:bg-zinc-600' />
</div>
<div>
<div class='pb-2 text-sm'>Description <span class='italic font-light opacity-80'>(optional)</span></div>
<textarea
id='themeDescription'
placeholder="Don't worry, this one's optional!"
bind:value={theme.description}
class='w-full p-2 transition border-0 rounded-lg dark:placeholder-zinc-300 bg-zinc-200 dark:bg-zinc-700 focus:outline-none focus:ring-1 focus:ring-zinc-100 dark:focus:ring-zinc-700 focus:bg-zinc-300/50 dark:focus:bg-zinc-600'></textarea>
</div>
<Divider />
<div class="relative flex justify-center w-full gap-1 overflow-hidden transition rounded-lg aspect-theme group place-items-center bg-zinc-200 dark:bg-zinc-700">
<div class={`transition pointer-events-none z-30 font-IconFamily ${ theme.coverImage ? 'opacity-0 group-hover:opacity-100' : ''}`}>
{'\uec60'}
</div>
<span class={`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={onCoverImageUpload} class="absolute inset-0 z-10 w-full h-full opacity-0 cursor-pointer" />
{#if !theme.hideThemeName && theme.coverImage}
<div class="absolute z-30 transition-opacity opacity-100 pointer-events-none group-hover:opacity-0">{theme.name}</div>
{/if}
{#if theme.coverImage}
<div class="absolute z-20 w-full h-full transition-opacity opacity-0 pointer-events-none group-hover:opacity-100 bg-black/20"></div>
<img src={theme.coverImageUrl} alt='Cover' class="absolute z-0 object-cover w-full h-full rounded" />
{/if}
</div>
<Divider />
{#each [
{
type: 'switch',
title: 'Hide Theme Name',
description: 'Useful when your cover image contains text',
props: {
state: theme.hideThemeName,
onChange: (value: boolean) => theme = { ...theme, hideThemeName: value }
}
},
{
type: 'switch',
title: 'Force Theme',
description: 'Force users to use either dark or light mode',
props: {
state: theme.forceDark !== undefined,
onChange: (value: boolean) => theme = { ...theme, forceDark: value ? false : undefined }
}
},
{
type: 'conditional',
props: {
condition: theme.forceDark !== undefined,
children: {
type: 'lightDarkToggle',
title: 'Mode',
description: 'Choose whether to force light or dark mode',
props: {
state: theme.forceDark === true,
onChange: (value: boolean) => theme = { ...theme, forceDark: value }
}
}
}
},
{
type: 'colourPicker',
title: 'Default Theme Colour',
description: 'Set the default color for your theme',
direction: 'vertical',
props: {
customState: theme.defaultColour,
customOnChange: (color: string) => theme = { ...theme, defaultColour: color }
}
},
{
type: 'imageUpload',
title: 'Custom Images',
description: 'Add custom images to your theme',
direction: 'vertical',
},
{
type: 'codeEditor',
title: 'Custom CSS',
description: 'Add custom CSS to your theme',
direction: 'vertical',
props: {
value: theme.CustomCSS,
onChange: (value: string) => { theme = { ...theme, CustomCSS: value } }
}
}
] as SettingItem[] as setting}
{@render settingItem(setting)}
{/each}
<button
onclick={submitTheme}
class="w-full px-4 py-2 mt-3 text-[13px] dark:text-white transition rounded-xl bg-zinc-200 dark:bg-zinc-700/50">
Save Theme
</button>
</div>
</div>
-27
View File
@@ -1,27 +0,0 @@
export interface SettingsState {
notificationCollector: boolean;
selectedTheme: string;
lessonAlerts: boolean;
animatedBackground: boolean;
animatedBackgroundSpeed: string;
customThemeColor: string;
betterSEQTAPlus: boolean;
shortcuts: Shortcut[];
customshortcuts: CustomShortcut[];
transparencyEffects: boolean;
timeFormat?: string;
animations: boolean;
defaultPage: string;
devMode: boolean;
}
interface Shortcut {
enabled: boolean;
name: string;
}
export interface CustomShortcut {
name: string;
url: string;
icon: string;
}
-5
View File
@@ -1,5 +0,0 @@
export interface ColorPickerProps {
color: string;
onChange: (color: string) => void;
id: string;
}
+3 -7
View File
@@ -1,11 +1,7 @@
import type { SettingsState } from './AppProps';
export interface SettingsList { export interface SettingsList {
title: string; title: string;
id: number;
description: string; description: string;
modifyElement: JSX.Element; Component: any; /* TODO: Give this a type */
} props?: any;
export interface SettingsProps {
settingsState: SettingsState;
setSettingsState: React.Dispatch<React.SetStateAction<SettingsState>>;
} }
-7
View File
@@ -1,7 +0,0 @@
import React from 'react';
import "./Slider.css";
export interface Slider {
onValueChange: (value: number) => void;
}
declare const Slider: React.FC<Slider>;
export default Slider;
-6
View File
@@ -1,6 +0,0 @@
import "./Switch.css";
export interface SwitchProps {
onChange: (isOn: boolean) => void;
state: boolean;
}
@@ -1,11 +0,0 @@
import React, { JSX } from 'react';
export interface Tab {
title: string;
content: JSX.Element;
}
export interface TabbedContainerProps {
tabs: Tab[];
animations?: boolean;
}
declare const TabbedContainer: React.FC<TabbedContainerProps>;
export default TabbedContainer;
+7
View File
@@ -0,0 +1,7 @@
export type Theme = {
name: string;
description: string;
coverImage: string;
marqueeImage: string;
id: string;
};
-88
View File
@@ -1,88 +0,0 @@
/**
* This file was @generated using pocketbase-typegen
*/
import type PocketBase from 'pocketbase'
import type { RecordService } from 'pocketbase'
export enum Collections {
PublishedThemes = "publishedThemes",
Themes = "themes",
Users = "users",
}
// Alias types for improved usability
export type IsoDateString = string
export type RecordIdString = string
export type HTMLString = string
// System fields
export type BaseSystemFields<T = never> = {
id: RecordIdString
created: IsoDateString
updated: IsoDateString
collectionId: string
collectionName: Collections
expand?: T
}
export type AuthSystemFields<T = never> = {
email: string
emailVisibility: boolean
username: string
verified: boolean
} & BaseSystemFields<T>
// Record types for each collection
export type PublishedThemesRecord = {
coverImage?: string
description?: string
downloads?: string
marqueeImage?: string
name: string
themeURL?: string
}
export type ThemesRecord<Ttheme = unknown> = {
coverImage?: string
description?: string
downloads?: string
images?: string[]
name: string
submitted?: boolean
theme?: null | Ttheme
}
export type UsersRecord = {
avatar?: string
name?: string
}
// Response types include system fields and match responses from the PocketBase API
export type PublishedThemesResponse<Texpand = unknown> = Required<PublishedThemesRecord> & BaseSystemFields<Texpand>
export type ThemesResponse<Ttheme = unknown, Texpand = unknown> = Required<ThemesRecord<Ttheme>> & BaseSystemFields<Texpand>
export type UsersResponse<Texpand = unknown> = Required<UsersRecord> & AuthSystemFields<Texpand>
// Types containing all Records and Responses, useful for creating typing helper functions
export type CollectionRecords = {
publishedThemes: PublishedThemesRecord
themes: ThemesRecord
users: UsersRecord
}
export type CollectionResponses = {
publishedThemes: PublishedThemesResponse
themes: ThemesResponse
users: UsersResponse
}
// Type for usage with type asserted PocketBase instance
// https://github.com/pocketbase/js-sdk#specify-typescript-definitions
export type TypedPocketBase = PocketBase & {
collection(idOrName: 'publishedThemes'): RecordService<PublishedThemesResponse>
collection(idOrName: 'themes'): RecordService<ThemesResponse>
collection(idOrName: 'users'): RecordService<UsersResponse>
}
+36
View File
@@ -0,0 +1,36 @@
import type { Subscriber, Unsubscriber } from "svelte/store";
export class Standalone {
private static instance: Standalone;
private _standalone = $state(false);
private subscribers = new Set<Subscriber<boolean>>();
private constructor() {}
public static getInstance(): Standalone {
if (!Standalone.instance) {
Standalone.instance = new Standalone();
}
return Standalone.instance;
}
public setStandalone(value: boolean) {
this._standalone = value;
this.subscribers.forEach(subscriber => subscriber(value));
}
public get standalone() {
return this._standalone;
}
public subscribe(run: Subscriber<boolean>): Unsubscriber {
this.subscribers.add(run);
run(this._standalone);
return () => {
this.subscribers.delete(run);
};
}
}
export const standalone = Standalone.getInstance();
+60
View File
@@ -0,0 +1,60 @@
import type { LoadedCustomTheme } from '@/types/CustomThemes';
export function generateImageId(): string {
return Math.random().toString(36).substr(2, 9);
}
export function handleImageUpload(event: Event, theme: LoadedCustomTheme): Promise<LoadedCustomTheme> | LoadedCustomTheme {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = async () => {
const imageBlob = await fetch(reader.result as string).then(res => res.blob());
const imageId = generateImageId();
const variableName = `custom-image-${theme.CustomImages.length}`;
resolve({
...theme,
CustomImages: [...theme.CustomImages, { id: imageId, blob: imageBlob, variableName, url: URL.createObjectURL(imageBlob) }],
});
};
reader.readAsDataURL(file);
});
}
return theme;
}
export function handleRemoveImage(imageId: string, theme: LoadedCustomTheme): LoadedCustomTheme {
return {
...theme,
CustomImages: theme.CustomImages.filter((image) => image.id !== imageId),
} as LoadedCustomTheme;
}
export function handleImageVariableChange(imageId: string, variableName: string, theme: LoadedCustomTheme): LoadedCustomTheme {
return {
...theme,
CustomImages: theme.CustomImages.map((image) =>
image.id === imageId ? { ...image, variableName } : image
),
} as LoadedCustomTheme;
}
export function handleCoverImageUpload(event: Event, theme: LoadedCustomTheme): Promise<LoadedCustomTheme> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = async () => {
const imageBlob = await fetch(reader.result as string).then(res => res.blob());
resolve({ ...theme, coverImage: imageBlob, coverImageUrl: URL.createObjectURL(imageBlob) });
};
reader.readAsDataURL(file);
});
}
return Promise.resolve(theme);
}
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
+6 -10
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "BetterSEQTA+", "name": "BetterSEQTA+",
"version": "3.3.1", "version": "3.4.0",
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development add add heaps more features!", "description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development add add heaps more features!",
"icons": { "icons": {
"32": "resources/icons/icon-32.png", "32": "resources/icons/icon-32.png",
@@ -23,7 +23,7 @@
"service_worker": "background.ts" "service_worker": "background.ts"
}, },
"content_security_policy": { "content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'" "extension_pages": "script-src 'self'; object-src 'self'"
}, },
"content_scripts": [ "content_scripts": [
{ {
@@ -33,17 +33,13 @@
} }
], ],
"web_accessible_resources": [ "web_accessible_resources": [
{
"resources": ["interface/index.html"],
"matches": ["*://*/*"]
},
{
"resources": ["seqta/ui/background/background.html"],
"matches": ["*://*/*"]
},
{ {
"resources": ["*://*/*"], "resources": ["*://*/*"],
"matches": ["*://*/*"] "matches": ["*://*/*"]
},
{
"resources": ["resources/icons/*"],
"matches": ["*://*/*"]
} }
] ]
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

+152
View File
@@ -0,0 +1,152 @@
body {
padding: 0;
margin: 0;
font-family: sans-serif;
font-size: 1em;
line-height: 1.5;
color: #555;
background: #fff;
}
h1 {
font-size: 1.5em;
font-weight: normal;
}
small {
font-size: .66666667em;
}
a {
color: #e74c3c;
text-decoration: none;
}
a:hover, a:focus {
box-shadow: 0 1px #e74c3c;
}
.bshadow0, input {
box-shadow: inset 0 -2px #e7e7e7;
}
input:hover {
box-shadow: inset 0 -2px #ccc;
}
input, fieldset {
font-family: sans-serif;
font-size: 1em;
margin: 0;
padding: 0;
border: 0;
}
input {
color: inherit;
line-height: 1.5;
height: 1.5em;
padding: .25em 0;
}
input:focus {
outline: none;
box-shadow: inset 0 -2px #449fdb;
}
.glyph {
font-size: 16px;
width: 15em;
padding-bottom: 1em;
margin-right: 4em;
margin-bottom: 1em;
float: left;
overflow: hidden;
}
.liga {
width: 80%;
width: calc(100% - 2.5em);
}
.talign-right {
text-align: right;
}
.talign-center {
text-align: center;
}
.bgc1 {
background: #f1f1f1;
}
.fgc1 {
color: #999;
}
.fgc0 {
color: #000;
}
p {
margin-top: 1em;
margin-bottom: 1em;
}
.mvm {
margin-top: .75em;
margin-bottom: .75em;
}
.mtn {
margin-top: 0;
}
.mtl, .mal {
margin-top: 1.5em;
}
.mbl, .mal {
margin-bottom: 1.5em;
}
.mal, .mhl {
margin-left: 1.5em;
margin-right: 1.5em;
}
.mhmm {
margin-left: 1em;
margin-right: 1em;
}
.mls {
margin-left: .25em;
}
.ptl {
padding-top: 1.5em;
}
.pbs, .pvs {
padding-bottom: .25em;
}
.pvs, .pts {
padding-top: .25em;
}
.unit {
float: left;
}
.unitRight {
float: right;
}
.size1of2 {
width: 50%;
}
.size1of1 {
width: 100%;
}
.clearfix:before, .clearfix:after {
content: " ";
display: table;
}
.clearfix:after {
clear: both;
}
.hidden-true {
display: none;
}
.textbox0 {
width: 3em;
background: #f1f1f1;
padding: .25em .5em;
line-height: 1.5;
height: 1.5em;
}
#testDrive {
display: block;
padding-top: 24px;
line-height: 1.5;
}
.fs0 {
font-size: 16px;
}
.fs1 {
font-size: 16px;
}

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