diff --git a/Dockerfile b/Dockerfile
index 08a43441..c6c356b9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,6 +1,17 @@
# multi stage to build tube archivist
# build python wheel, download and extract ffmpeg, copy into final image
+FROM node:lts-alpine as node-builder
+
+# RUN npm config set registry https://registry.npmjs.org/
+
+COPY ./frontend /frontend
+
+WORKDIR /frontend
+RUN npm i
+RUN npm run build:deploy
+
+WORKDIR /
# First stage to build python wheel
FROM python:3.11.8-slim-bookworm AS builder
@@ -60,6 +71,8 @@ COPY ./backend /app
COPY ./docker_assets/run.sh /app
COPY ./docker_assets/uwsgi.ini /app
+COPY --from=node-builder ./frontend/dist /app/static
+
# volumes
VOLUME /cache
VOLUME /youtube
diff --git a/docker_assets/nginx.conf b/docker_assets/nginx.conf
index a9702490..c6b23e9a 100644
--- a/docker_assets/nginx.conf
+++ b/docker_assets/nginx.conf
@@ -24,10 +24,24 @@ server {
text/vtt vtt;
}
}
+
+ location /youtube/ {
+ auth_request /api/ping/;
+ alias /youtube/;
+ types {
+ video/mp4 mp4;
+ }
+ }
- location / {
+ location /api {
include uwsgi_params;
uwsgi_pass localhost:8080;
}
-
+
+ root /app/static;
+ index index.html;
+
+ location / {
+ try_files $uri $uri/ /index.html =404;
+ }
}
\ No newline at end of file
diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs
new file mode 100644
index 00000000..9fa26acb
--- /dev/null
+++ b/frontend/.eslintrc.cjs
@@ -0,0 +1,16 @@
+module.exports = {
+ root: true,
+ env: { browser: true, es2020: true },
+ extends: [
+ 'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:react-hooks/recommended',
+ 'prettier',
+ ],
+ ignorePatterns: ['dist', '.eslintrc.cjs'],
+ parser: '@typescript-eslint/parser',
+ plugins: ['react-refresh'],
+ rules: {
+ 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
+ },
+};
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 00000000..a547bf36
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/frontend/.prettierignore b/frontend/.prettierignore
new file mode 100644
index 00000000..501942fd
--- /dev/null
+++ b/frontend/.prettierignore
@@ -0,0 +1,5 @@
+# Ignore artifacts:
+build
+dist
+coverage
+node_modules
\ No newline at end of file
diff --git a/frontend/.prettierrc b/frontend/.prettierrc
new file mode 100644
index 00000000..3e4b9fa0
--- /dev/null
+++ b/frontend/.prettierrc
@@ -0,0 +1,5 @@
+{
+ "singleQuote": true,
+ "arrowParens": "avoid",
+ "printWidth": 100
+}
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 00000000..275fc390
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,17 @@
+# Tubearchivist Frontend React
+
+# Folder structure
+
+```
+src ┐
+ ├───api
+ │ ├───action // Functions that do write (POST,DELETE) calls to the backend
+ │ └───loader // Functions that do read-only (GET,HEAD) calls to the backend
+ ├───components // React components to be used in pages
+ ├───configuration // Application configuration.
+ │ ├───colours // Css loader for themes
+ │ ├───constants // global constants that have no good place
+ │ └───routes // Routes definitions used in Links and react-router-dom configuration
+ ├───functions // Useful functions
+ └───pages // React components that define a page/route
+```
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 00000000..6396d71a
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TubeArchivist
+
+
+
+
+
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 00000000..d8d9a818
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,2961 @@
+{
+ "name": "tubearchivist-frontend",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "tubearchivist-frontend",
+ "version": "0.1.0",
+ "dependencies": {
+ "dompurify": "^3.1.6",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-helmet": "^6.1.0",
+ "react-router-dom": "^6.25.1"
+ },
+ "devDependencies": {
+ "@types/dompurify": "^3.0.5",
+ "@types/react": "^18.3.3",
+ "@types/react-dom": "^18.3.0",
+ "@types/react-helmet": "^6.1.11",
+ "@typescript-eslint/eslint-plugin": "^7.16.1",
+ "@typescript-eslint/parser": "^7.16.1",
+ "@vitejs/plugin-react-swc": "^3.7.0",
+ "eslint": "^8.57.0",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-plugin-react-hooks": "^4.6.2",
+ "eslint-plugin-react-refresh": "^0.4.8",
+ "prettier": "3.3.3",
+ "typescript": "^5.5.3",
+ "vite": "^5.3.4"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
+ "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
+ "dev": true,
+ "dependencies": {
+ "eslint-visitor-keys": "^3.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.10.0",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz",
+ "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==",
+ "dev": true,
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+ "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
+ "dev": true,
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^9.6.0",
+ "globals": "^13.19.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "8.57.0",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
+ "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
+ "dev": true,
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@humanwhocodes/config-array": {
+ "version": "0.11.14",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
+ "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
+ "dev": true,
+ "dependencies": {
+ "@humanwhocodes/object-schema": "^2.0.2",
+ "debug": "^4.3.1",
+ "minimatch": "^3.0.5"
+ },
+ "engines": {
+ "node": ">=10.10.0"
+ }
+ },
+ "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/@humanwhocodes/config-array/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/object-schema": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
+ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
+ "dev": true
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@remix-run/router": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.18.0.tgz",
+ "integrity": "sha512-L3jkqmqoSVBVKHfpGZmLrex0lxR5SucGA0sUfFzGctehw+S/ggL9L/0NnC5mw6P8HUWpFZ3nQw3cRApjjWx9Sw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.17.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.17.2.tgz",
+ "integrity": "sha512-NM0jFxY8bB8QLkoKxIQeObCaDlJKewVlIEkuyYKm5An1tdVZ966w2+MPQ2l8LBZLjR+SgyV+nRkTIunzOYBMLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.17.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.17.2.tgz",
+ "integrity": "sha512-yeX/Usk7daNIVwkq2uGoq2BYJKZY1JfyLTaHO/jaiSwi/lsf8fTFoQW/n6IdAsx5tx+iotu2zCJwz8MxI6D/Bw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.17.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.17.2.tgz",
+ "integrity": "sha512-kcMLpE6uCwls023+kknm71ug7MZOrtXo+y5p/tsg6jltpDtgQY1Eq5sGfHcQfb+lfuKwhBmEURDga9N0ol4YPw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.17.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.17.2.tgz",
+ "integrity": "sha512-AtKwD0VEx0zWkL0ZjixEkp5tbNLzX+FCqGG1SvOu993HnSz4qDI6S4kGzubrEJAljpVkhRSlg5bzpV//E6ysTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.17.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.17.2.tgz",
+ "integrity": "sha512-3reX2fUHqN7sffBNqmEyMQVj/CKhIHZd4y631duy0hZqI8Qoqf6lTtmAKvJFYa6bhU95B1D0WgzHkmTg33In0A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.17.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.17.2.tgz",
+ "integrity": "sha512-uSqpsp91mheRgw96xtyAGP9FW5ChctTFEoXP0r5FAzj/3ZRv3Uxjtc7taRQSaQM/q85KEKjKsZuiZM3GyUivRg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.17.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.17.2.tgz",
+ "integrity": "sha512-EMMPHkiCRtE8Wdk3Qhtciq6BndLtstqZIroHiiGzB3C5LDJmIZcSzVtLRbwuXuUft1Cnv+9fxuDtDxz3k3EW2A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.17.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.17.2.tgz",
+ "integrity": "sha512-NMPylUUZ1i0z/xJUIx6VUhISZDRT+uTWpBcjdv0/zkp7b/bQDF+NfnfdzuTiB1G6HTodgoFa93hp0O1xl+/UbA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
+ "version": "4.17.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.17.2.tgz",
+ "integrity": "sha512-T19My13y8uYXPw/L/k0JYaX1fJKFT/PWdXiHr8mTbXWxjVF1t+8Xl31DgBBvEKclw+1b00Chg0hxE2O7bTG7GQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.17.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.17.2.tgz",
+ "integrity": "sha512-BOaNfthf3X3fOWAB+IJ9kxTgPmMqPPH5f5k2DcCsRrBIbWnaJCgX2ll77dV1TdSy9SaXTR5iDXRL8n7AnoP5cg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.17.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.17.2.tgz",
+ "integrity": "sha512-W0UP/x7bnn3xN2eYMql2T/+wpASLE5SjObXILTMPUBDB/Fg/FxC+gX4nvCfPBCbNhz51C+HcqQp2qQ4u25ok6g==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.17.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.17.2.tgz",
+ "integrity": "sha512-Hy7pLwByUOuyaFC6mAr7m+oMC+V7qyifzs/nW2OJfC8H4hbCzOX07Ov0VFk/zP3kBsELWNFi7rJtgbKYsav9QQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.17.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.17.2.tgz",
+ "integrity": "sha512-h1+yTWeYbRdAyJ/jMiVw0l6fOOm/0D1vNLui9iPuqgRGnXA0u21gAqOyB5iHjlM9MMfNOm9RHCQ7zLIzT0x11Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.17.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.17.2.tgz",
+ "integrity": "sha512-tmdtXMfKAjy5+IQsVtDiCfqbynAQE/TQRpWdVataHmhMb9DCoJxp9vLcCBjEQWMiUYxO1QprH/HbY9ragCEFLA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.17.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.17.2.tgz",
+ "integrity": "sha512-7II/QCSTAHuE5vdZaQEwJq2ZACkBpQDOmQsE6D6XUbnBHW8IAhm4eTufL6msLJorzrHDFv3CF8oCA/hSIRuZeQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.17.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.17.2.tgz",
+ "integrity": "sha512-TGGO7v7qOq4CYmSBVEYpI1Y5xDuCEnbVC5Vth8mOsW0gDSzxNrVERPc790IGHsrT2dQSimgMr9Ub3Y1Jci5/8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@swc/core": {
+ "version": "1.6.13",
+ "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.6.13.tgz",
+ "integrity": "sha512-eailUYex6fkfaQTev4Oa3mwn0/e3mQU4H8y1WPuImYQESOQDtVrowwUGDSc19evpBbHpKtwM+hw8nLlhIsF+Tw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3",
+ "@swc/types": "^0.1.9"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/swc"
+ },
+ "optionalDependencies": {
+ "@swc/core-darwin-arm64": "1.6.13",
+ "@swc/core-darwin-x64": "1.6.13",
+ "@swc/core-linux-arm-gnueabihf": "1.6.13",
+ "@swc/core-linux-arm64-gnu": "1.6.13",
+ "@swc/core-linux-arm64-musl": "1.6.13",
+ "@swc/core-linux-x64-gnu": "1.6.13",
+ "@swc/core-linux-x64-musl": "1.6.13",
+ "@swc/core-win32-arm64-msvc": "1.6.13",
+ "@swc/core-win32-ia32-msvc": "1.6.13",
+ "@swc/core-win32-x64-msvc": "1.6.13"
+ },
+ "peerDependencies": {
+ "@swc/helpers": "*"
+ },
+ "peerDependenciesMeta": {
+ "@swc/helpers": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@swc/core-darwin-arm64": {
+ "version": "1.6.13",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.6.13.tgz",
+ "integrity": "sha512-SOF4buAis72K22BGJ3N8y88mLNfxLNprTuJUpzikyMGrvkuBFNcxYtMhmomO0XHsgLDzOJ+hWzcgjRNzjMsUcQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-darwin-x64": {
+ "version": "1.6.13",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.6.13.tgz",
+ "integrity": "sha512-AW8akFSC+tmPE6YQQvK9S2A1B8pjnXEINg+gGgw0KRUUXunvu1/OEOeC5L2Co1wAwhD7bhnaefi06Qi9AiwOag==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm-gnueabihf": {
+ "version": "1.6.13",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.6.13.tgz",
+ "integrity": "sha512-f4gxxvDXVUm2HLYXRd311mSrmbpQF2MZ4Ja6XCQz1hWAxXdhRl1gpnZ+LH/xIfGSwQChrtLLVrkxdYUCVuIjFg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm64-gnu": {
+ "version": "1.6.13",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.6.13.tgz",
+ "integrity": "sha512-Nf/eoW2CbG8s+9JoLtjl9FByBXyQ5cjdBsA4efO7Zw4p+YSuXDgc8HRPC+E2+ns0praDpKNZtLvDtmF2lL+2Gg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm64-musl": {
+ "version": "1.6.13",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.6.13.tgz",
+ "integrity": "sha512-2OysYSYtdw79prJYuKIiux/Gj0iaGEbpS2QZWCIY4X9sGoETJ5iMg+lY+YCrIxdkkNYd7OhIbXdYFyGs/w5LDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-x64-gnu": {
+ "version": "1.6.13",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.6.13.tgz",
+ "integrity": "sha512-PkR4CZYJNk5hcd2+tMWBpnisnmYsUzazI1O5X7VkIGFcGePTqJ/bWlfUIVVExWxvAI33PQFzLbzmN5scyIUyGQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-x64-musl": {
+ "version": "1.6.13",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.6.13.tgz",
+ "integrity": "sha512-OdsY7wryTxCKwGQcwW9jwWg3cxaHBkTTHi91+5nm7hFPpmZMz1HivJrWAMwVE7iXFw+M4l6ugB/wCvpYrUAAjA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-arm64-msvc": {
+ "version": "1.6.13",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.6.13.tgz",
+ "integrity": "sha512-ap6uNmYjwk9M/+bFEuWRNl3hq4VqgQ/Lk+ID/F5WGqczNr0L7vEf+pOsRAn0F6EV+o/nyb3ePt8rLhE/wjHpPg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-ia32-msvc": {
+ "version": "1.6.13",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.6.13.tgz",
+ "integrity": "sha512-IJ8KH4yIUHTnS/U1jwQmtbfQals7zWPG0a9hbEfIr4zI0yKzjd83lmtS09lm2Q24QBWOCFGEEbuZxR4tIlvfzA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-x64-msvc": {
+ "version": "1.6.13",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.6.13.tgz",
+ "integrity": "sha512-f6/sx6LMuEnbuxtiSL/EkR0Y6qUHFw1XVrh6rwzKXptTipUdOY+nXpKoh+1UsBm/r7H0/5DtOdrn3q5ZHbFZjQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/counter": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
+ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@swc/types": {
+ "version": "0.1.9",
+ "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.9.tgz",
+ "integrity": "sha512-qKnCno++jzcJ4lM4NTfYifm1EFSCeIfKiAHAfkENZAV5Kl9PjJIyd2yeeVv6c/2CckuLyv2NmRC5pv6pm2WQBg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3"
+ }
+ },
+ "node_modules/@types/dompurify": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
+ "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
+ "dev": true,
+ "dependencies": {
+ "@types/trusted-types": "*"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
+ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
+ "dev": true
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.12",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
+ "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==",
+ "dev": true
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.3",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz",
+ "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz",
+ "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==",
+ "dev": true,
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
+ "node_modules/@types/react-helmet": {
+ "version": "6.1.11",
+ "resolved": "https://registry.npmjs.org/@types/react-helmet/-/react-helmet-6.1.11.tgz",
+ "integrity": "sha512-0QcdGLddTERotCXo3VFlUSWO3ztraw8nZ6e3zJSgG7apwV5xt+pJUS8ewPBqT4NYB1optGLprNQzFleIY84u/g==",
+ "dev": true,
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "dev": true
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "7.16.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.16.1.tgz",
+ "integrity": "sha512-SxdPak/5bO0EnGktV05+Hq8oatjAYVY3Zh2bye9pGZy6+jwyR3LG3YKkV4YatlsgqXP28BTeVm9pqwJM96vf2A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.10.0",
+ "@typescript-eslint/scope-manager": "7.16.1",
+ "@typescript-eslint/type-utils": "7.16.1",
+ "@typescript-eslint/utils": "7.16.1",
+ "@typescript-eslint/visitor-keys": "7.16.1",
+ "graphemer": "^1.4.0",
+ "ignore": "^5.3.1",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^7.0.0",
+ "eslint": "^8.56.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "7.16.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.16.1.tgz",
+ "integrity": "sha512-u+1Qx86jfGQ5i4JjK33/FnawZRpsLxRnKzGE6EABZ40KxVT/vWsiZFEBBHjFOljmmV3MBYOHEKi0Jm9hbAOClA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "7.16.1",
+ "@typescript-eslint/types": "7.16.1",
+ "@typescript-eslint/typescript-estree": "7.16.1",
+ "@typescript-eslint/visitor-keys": "7.16.1",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.56.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "7.16.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.16.1.tgz",
+ "integrity": "sha512-nYpyv6ALte18gbMz323RM+vpFpTjfNdyakbf3nsLvF43uF9KeNC289SUEW3QLZ1xPtyINJ1dIsZOuWuSRIWygw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "7.16.1",
+ "@typescript-eslint/visitor-keys": "7.16.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "7.16.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.16.1.tgz",
+ "integrity": "sha512-rbu/H2MWXN4SkjIIyWcmYBjlp55VT+1G3duFOIukTNFxr9PI35pLc2ydwAfejCEitCv4uztA07q0QWanOHC7dA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/typescript-estree": "7.16.1",
+ "@typescript-eslint/utils": "7.16.1",
+ "debug": "^4.3.4",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.56.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "7.16.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.16.1.tgz",
+ "integrity": "sha512-AQn9XqCzUXd4bAVEsAXM/Izk11Wx2u4H3BAfQVhSfzfDOm/wAON9nP7J5rpkCxts7E5TELmN845xTUCQrD1xIQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "7.16.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.16.1.tgz",
+ "integrity": "sha512-0vFPk8tMjj6apaAZ1HlwM8w7jbghC8jc1aRNJG5vN8Ym5miyhTQGMqU++kuBFDNKe9NcPeZ6x0zfSzV8xC1UlQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@typescript-eslint/types": "7.16.1",
+ "@typescript-eslint/visitor-keys": "7.16.1",
+ "debug": "^4.3.4",
+ "globby": "^11.1.0",
+ "is-glob": "^4.0.3",
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "7.16.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.16.1.tgz",
+ "integrity": "sha512-WrFM8nzCowV0he0RlkotGDujx78xudsxnGMBHI88l5J8wEhED6yBwaSLP99ygfrzAjsQvcYQ94quDwI0d7E1fA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.4.0",
+ "@typescript-eslint/scope-manager": "7.16.1",
+ "@typescript-eslint/types": "7.16.1",
+ "@typescript-eslint/typescript-estree": "7.16.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.56.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "7.16.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.16.1.tgz",
+ "integrity": "sha512-Qlzzx4sE4u3FsHTPQAAQFJFNOuqtuY0LFrZHwQ8IHK705XxBiWOFkfKRWu6niB7hwfgnwIpO4jTC75ozW1PHWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "7.16.1",
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
+ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
+ "dev": true
+ },
+ "node_modules/@vitejs/plugin-react-swc": {
+ "version": "3.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.7.0.tgz",
+ "integrity": "sha512-yrknSb3Dci6svCd/qhHqhFPDSw0QtjumcqdKMoNNzmOl5lMXTTiqzjWtG4Qask2HdvvzaNgSunbQGet8/GrKdA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@swc/core": "^1.5.7"
+ },
+ "peerDependencies": {
+ "vite": "^4 || ^5"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.11.3",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
+ "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
+ "dev": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true
+ },
+ "node_modules/array-union": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
+ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+ "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+ "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+ "dev": true,
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+ "dev": true
+ },
+ "node_modules/debug": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+ "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.1.2"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true
+ },
+ "node_modules/dir-glob": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
+ "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-type": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/doctrine": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+ "dev": true,
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/dompurify": {
+ "version": "3.1.6",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.6.tgz",
+ "integrity": "sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==",
+ "license": "(MPL-2.0 OR Apache-2.0)"
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "8.57.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
+ "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
+ "dev": true,
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.2.0",
+ "@eslint-community/regexpp": "^4.6.1",
+ "@eslint/eslintrc": "^2.1.4",
+ "@eslint/js": "8.57.0",
+ "@humanwhocodes/config-array": "^0.11.14",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@nodelib/fs.walk": "^1.2.8",
+ "@ungap/structured-clone": "^1.2.0",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.2",
+ "debug": "^4.3.2",
+ "doctrine": "^3.0.0",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^7.2.2",
+ "eslint-visitor-keys": "^3.4.3",
+ "espree": "^9.6.1",
+ "esquery": "^1.4.2",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^6.0.1",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "globals": "^13.19.0",
+ "graphemer": "^1.4.0",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "is-path-inside": "^3.0.3",
+ "js-yaml": "^4.1.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "levn": "^0.4.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3",
+ "strip-ansi": "^6.0.1",
+ "text-table": "^0.2.0"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-config-prettier": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
+ "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
+ "dev": true,
+ "bin": {
+ "eslint-config-prettier": "bin/cli.js"
+ },
+ "peerDependencies": {
+ "eslint": ">=7.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz",
+ "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0"
+ }
+ },
+ "node_modules/eslint-plugin-react-refresh": {
+ "version": "0.4.8",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.8.tgz",
+ "integrity": "sha512-MIKAclwaDFIiYtVBLzDdm16E+Ty4GwhB6wZlCAG1R3Ur+F9Qbo6PRxpA5DK7XtDgm+WlCoAY2WxAwqhmIDHg6Q==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "eslint": ">=7"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "7.2.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
+ "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
+ "dev": true,
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint/node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/eslint/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/espree": {
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
+ "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
+ "dev": true,
+ "dependencies": {
+ "acorn": "^8.9.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^3.4.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
+ "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==",
+ "dev": true,
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
+ "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true
+ },
+ "node_modules/fastq": {
+ "version": "1.17.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
+ "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==",
+ "dev": true,
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+ "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+ "dev": true,
+ "dependencies": {
+ "flat-cache": "^3.0.4"
+ },
+ "engines": {
+ "node": "^10.12.0 || >=12.0.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
+ "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
+ "dev": true,
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.3",
+ "rimraf": "^3.0.2"
+ },
+ "engines": {
+ "node": "^10.12.0 || >=12.0.0"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
+ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
+ "dev": true
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "dev": true
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "dev": true,
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/globals": {
+ "version": "13.24.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
+ "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
+ "dev": true,
+ "dependencies": {
+ "type-fest": "^0.20.2"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/globby": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
+ "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-union": "^2.1.0",
+ "dir-glob": "^3.0.1",
+ "fast-glob": "^3.2.9",
+ "ignore": "^5.2.0",
+ "merge2": "^1.4.1",
+ "slash": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/graphemer": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+ "dev": true
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
+ "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+ "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+ "dev": true,
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "dev": true,
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-path-inside": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+ "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "dev": true,
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz",
+ "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+ "dev": true
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.7",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
+ "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-type": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
+ "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.4.39",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz",
+ "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.7",
+ "picocolors": "^1.0.1",
+ "source-map-js": "^1.2.0"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
+ "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-fast-compare": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
+ "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
+ },
+ "node_modules/react-helmet": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz",
+ "integrity": "sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==",
+ "dependencies": {
+ "object-assign": "^4.1.1",
+ "prop-types": "^15.7.2",
+ "react-fast-compare": "^3.1.1",
+ "react-side-effect": "^2.1.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.3.0"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+ },
+ "node_modules/react-router": {
+ "version": "6.25.1",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.25.1.tgz",
+ "integrity": "sha512-u8ELFr5Z6g02nUtpPAggP73Jigj1mRePSwhS/2nkTrlPU5yEkH1vYzWNyvSnSzeeE2DNqWdH+P8OhIh9wuXhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.18.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.25.1",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.25.1.tgz",
+ "integrity": "sha512-0tUDpbFvk35iv+N89dWNrJp+afLgd+y4VtorJZuOCXK0kkCWjEvb3vTJM++SYvMEpbVwXKf3FjeVveVEb6JpDQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.18.0",
+ "react-router": "6.25.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/react-side-effect": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz",
+ "integrity": "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==",
+ "peerDependencies": {
+ "react": "^16.3.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+ "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+ "dev": true,
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.17.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.17.2.tgz",
+ "integrity": "sha512-/9ClTJPByC0U4zNLowV1tMBe8yMEAxewtR3cUNX5BoEpGH3dQEWpJLr6CLp0fPdYRF/fzVOgvDb1zXuakwF5kQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "1.0.5"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.17.2",
+ "@rollup/rollup-android-arm64": "4.17.2",
+ "@rollup/rollup-darwin-arm64": "4.17.2",
+ "@rollup/rollup-darwin-x64": "4.17.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.17.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.17.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.17.2",
+ "@rollup/rollup-linux-arm64-musl": "4.17.2",
+ "@rollup/rollup-linux-powerpc64le-gnu": "4.17.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.17.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.17.2",
+ "@rollup/rollup-linux-x64-gnu": "4.17.2",
+ "@rollup/rollup-linux-x64-musl": "4.17.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.17.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.17.2",
+ "@rollup/rollup-win32-x64-msvc": "4.17.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.6.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
+ "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/slash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
+ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
+ "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/text-table": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
+ "dev": true
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz",
+ "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.2.0"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.5.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz",
+ "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.4.tgz",
+ "integrity": "sha512-Cw+7zL3ZG9/NZBB8C+8QbQZmR54GwqIz+WMI4b3JgdYJvX+ny9AjJXqkGQlDXSXRP9rP0B4tbciRMOVEKulVOA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.39",
+ "rollup": "^4.13.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 00000000..73167b9f
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "tubearchivist-frontend",
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "build:deploy": "vite build",
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "dompurify": "^3.1.6",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-helmet": "^6.1.0",
+ "react-router-dom": "^6.25.1"
+ },
+ "devDependencies": {
+ "@types/dompurify": "^3.0.5",
+ "@types/react": "^18.3.3",
+ "@types/react-dom": "^18.3.0",
+ "@types/react-helmet": "^6.1.11",
+ "@typescript-eslint/eslint-plugin": "^7.16.1",
+ "@typescript-eslint/parser": "^7.16.1",
+ "@vitejs/plugin-react-swc": "^3.7.0",
+ "eslint": "^8.57.0",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-plugin-react-hooks": "^4.6.2",
+ "eslint-plugin-react-refresh": "^0.4.8",
+ "prettier": "3.3.3",
+ "typescript": "^5.5.3",
+ "vite": "^5.3.4"
+ }
+}
diff --git a/frontend/public/favicon/android-chrome-192x192.png b/frontend/public/favicon/android-chrome-192x192.png
new file mode 100644
index 00000000..ef0ebfe3
Binary files /dev/null and b/frontend/public/favicon/android-chrome-192x192.png differ
diff --git a/frontend/public/favicon/android-chrome-512x512.png b/frontend/public/favicon/android-chrome-512x512.png
new file mode 100644
index 00000000..4d8b11eb
Binary files /dev/null and b/frontend/public/favicon/android-chrome-512x512.png differ
diff --git a/frontend/public/favicon/apple-touch-icon-114x114-precomposed.png b/frontend/public/favicon/apple-touch-icon-114x114-precomposed.png
new file mode 100644
index 00000000..04090832
Binary files /dev/null and b/frontend/public/favicon/apple-touch-icon-114x114-precomposed.png differ
diff --git a/frontend/public/favicon/apple-touch-icon-114x114.png b/frontend/public/favicon/apple-touch-icon-114x114.png
new file mode 100644
index 00000000..d4ff60af
Binary files /dev/null and b/frontend/public/favicon/apple-touch-icon-114x114.png differ
diff --git a/frontend/public/favicon/apple-touch-icon-120x120-precomposed.png b/frontend/public/favicon/apple-touch-icon-120x120-precomposed.png
new file mode 100644
index 00000000..249857c0
Binary files /dev/null and b/frontend/public/favicon/apple-touch-icon-120x120-precomposed.png differ
diff --git a/frontend/public/favicon/apple-touch-icon-120x120.png b/frontend/public/favicon/apple-touch-icon-120x120.png
new file mode 100644
index 00000000..569d7c5a
Binary files /dev/null and b/frontend/public/favicon/apple-touch-icon-120x120.png differ
diff --git a/frontend/public/favicon/apple-touch-icon-144x144-precomposed.png b/frontend/public/favicon/apple-touch-icon-144x144-precomposed.png
new file mode 100644
index 00000000..8d0571d3
Binary files /dev/null and b/frontend/public/favicon/apple-touch-icon-144x144-precomposed.png differ
diff --git a/frontend/public/favicon/apple-touch-icon-144x144.png b/frontend/public/favicon/apple-touch-icon-144x144.png
new file mode 100644
index 00000000..50d0937f
Binary files /dev/null and b/frontend/public/favicon/apple-touch-icon-144x144.png differ
diff --git a/frontend/public/favicon/apple-touch-icon-152x152-precomposed.png b/frontend/public/favicon/apple-touch-icon-152x152-precomposed.png
new file mode 100644
index 00000000..5ac04bdf
Binary files /dev/null and b/frontend/public/favicon/apple-touch-icon-152x152-precomposed.png differ
diff --git a/frontend/public/favicon/apple-touch-icon-152x152.png b/frontend/public/favicon/apple-touch-icon-152x152.png
new file mode 100644
index 00000000..0692048a
Binary files /dev/null and b/frontend/public/favicon/apple-touch-icon-152x152.png differ
diff --git a/frontend/public/favicon/apple-touch-icon-180x180-precomposed.png b/frontend/public/favicon/apple-touch-icon-180x180-precomposed.png
new file mode 100644
index 00000000..cbf3158e
Binary files /dev/null and b/frontend/public/favicon/apple-touch-icon-180x180-precomposed.png differ
diff --git a/frontend/public/favicon/apple-touch-icon-180x180.png b/frontend/public/favicon/apple-touch-icon-180x180.png
new file mode 100644
index 00000000..d7158782
Binary files /dev/null and b/frontend/public/favicon/apple-touch-icon-180x180.png differ
diff --git a/frontend/public/favicon/apple-touch-icon-57x57-precomposed.png b/frontend/public/favicon/apple-touch-icon-57x57-precomposed.png
new file mode 100644
index 00000000..5785c2d4
Binary files /dev/null and b/frontend/public/favicon/apple-touch-icon-57x57-precomposed.png differ
diff --git a/frontend/public/favicon/apple-touch-icon-57x57.png b/frontend/public/favicon/apple-touch-icon-57x57.png
new file mode 100644
index 00000000..0b7823f3
Binary files /dev/null and b/frontend/public/favicon/apple-touch-icon-57x57.png differ
diff --git a/frontend/public/favicon/apple-touch-icon-60x60-precomposed.png b/frontend/public/favicon/apple-touch-icon-60x60-precomposed.png
new file mode 100644
index 00000000..a85f028b
Binary files /dev/null and b/frontend/public/favicon/apple-touch-icon-60x60-precomposed.png differ
diff --git a/frontend/public/favicon/apple-touch-icon-60x60.png b/frontend/public/favicon/apple-touch-icon-60x60.png
new file mode 100644
index 00000000..6ed808ea
Binary files /dev/null and b/frontend/public/favicon/apple-touch-icon-60x60.png differ
diff --git a/frontend/public/favicon/apple-touch-icon-72x72-precomposed.png b/frontend/public/favicon/apple-touch-icon-72x72-precomposed.png
new file mode 100644
index 00000000..594c277e
Binary files /dev/null and b/frontend/public/favicon/apple-touch-icon-72x72-precomposed.png differ
diff --git a/frontend/public/favicon/apple-touch-icon-72x72.png b/frontend/public/favicon/apple-touch-icon-72x72.png
new file mode 100644
index 00000000..bc1ae8ec
Binary files /dev/null and b/frontend/public/favicon/apple-touch-icon-72x72.png differ
diff --git a/frontend/public/favicon/apple-touch-icon-76x76-precomposed.png b/frontend/public/favicon/apple-touch-icon-76x76-precomposed.png
new file mode 100644
index 00000000..631de948
Binary files /dev/null and b/frontend/public/favicon/apple-touch-icon-76x76-precomposed.png differ
diff --git a/frontend/public/favicon/apple-touch-icon-76x76.png b/frontend/public/favicon/apple-touch-icon-76x76.png
new file mode 100644
index 00000000..9c00af94
Binary files /dev/null and b/frontend/public/favicon/apple-touch-icon-76x76.png differ
diff --git a/frontend/public/favicon/apple-touch-icon-precomposed.png b/frontend/public/favicon/apple-touch-icon-precomposed.png
new file mode 100644
index 00000000..cbf3158e
Binary files /dev/null and b/frontend/public/favicon/apple-touch-icon-precomposed.png differ
diff --git a/frontend/public/favicon/apple-touch-icon.png b/frontend/public/favicon/apple-touch-icon.png
new file mode 100644
index 00000000..d7158782
Binary files /dev/null and b/frontend/public/favicon/apple-touch-icon.png differ
diff --git a/frontend/public/favicon/browserconfig.xml b/frontend/public/favicon/browserconfig.xml
new file mode 100644
index 00000000..e465c1bc
--- /dev/null
+++ b/frontend/public/favicon/browserconfig.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+ #01202e
+
+
+
diff --git a/frontend/public/favicon/favicon-16x16.png b/frontend/public/favicon/favicon-16x16.png
new file mode 100644
index 00000000..20362724
Binary files /dev/null and b/frontend/public/favicon/favicon-16x16.png differ
diff --git a/frontend/public/favicon/favicon-32x32.png b/frontend/public/favicon/favicon-32x32.png
new file mode 100644
index 00000000..678a2ff8
Binary files /dev/null and b/frontend/public/favicon/favicon-32x32.png differ
diff --git a/frontend/public/favicon/favicon.ico b/frontend/public/favicon/favicon.ico
new file mode 100644
index 00000000..0b639a44
Binary files /dev/null and b/frontend/public/favicon/favicon.ico differ
diff --git a/frontend/public/favicon/mstile-150x150.png b/frontend/public/favicon/mstile-150x150.png
new file mode 100644
index 00000000..24a0b256
Binary files /dev/null and b/frontend/public/favicon/mstile-150x150.png differ
diff --git a/frontend/public/favicon/safari-pinned-tab.svg b/frontend/public/favicon/safari-pinned-tab.svg
new file mode 100644
index 00000000..f9f2ea03
--- /dev/null
+++ b/frontend/public/favicon/safari-pinned-tab.svg
@@ -0,0 +1,100 @@
+
+
+
+
+Created by potrace 1.14, written by Peter Selinger 2001-2017
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/favicon/site.webmanifest b/frontend/public/favicon/site.webmanifest
new file mode 100644
index 00000000..19fd1381
--- /dev/null
+++ b/frontend/public/favicon/site.webmanifest
@@ -0,0 +1,19 @@
+{
+ "name": "TubeArchivist",
+ "short_name": "TubeArchivist",
+ "icons": [
+ {
+ "src": "/static/favicon/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/static/favicon/android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ],
+ "theme_color": "#01202e",
+ "background_color": "#01202e",
+ "display": "standalone"
+}
diff --git a/frontend/public/font/OFL_License.txt b/frontend/public/font/OFL_License.txt
new file mode 100644
index 00000000..f01dfdf7
--- /dev/null
+++ b/frontend/public/font/OFL_License.txt
@@ -0,0 +1,94 @@
+Copyright (c) 2015, Kosal Sen, Philatype (),
+with Reserved Font Name Sen.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/frontend/public/font/Sen-Bold.woff b/frontend/public/font/Sen-Bold.woff
new file mode 100644
index 00000000..fb6c66f5
Binary files /dev/null and b/frontend/public/font/Sen-Bold.woff differ
diff --git a/frontend/public/font/Sen-Regular.woff b/frontend/public/font/Sen-Regular.woff
new file mode 100644
index 00000000..eb7471e0
Binary files /dev/null and b/frontend/public/font/Sen-Regular.woff differ
diff --git a/frontend/public/img/banner-tube-archivist-dark.png b/frontend/public/img/banner-tube-archivist-dark.png
new file mode 100644
index 00000000..ad2cd5ed
Binary files /dev/null and b/frontend/public/img/banner-tube-archivist-dark.png differ
diff --git a/frontend/public/img/banner-tube-archivist-light.png b/frontend/public/img/banner-tube-archivist-light.png
new file mode 100644
index 00000000..69f70277
Binary files /dev/null and b/frontend/public/img/banner-tube-archivist-light.png differ
diff --git a/frontend/public/img/default-channel-art.jpg b/frontend/public/img/default-channel-art.jpg
new file mode 100644
index 00000000..45a4b000
Binary files /dev/null and b/frontend/public/img/default-channel-art.jpg differ
diff --git a/frontend/public/img/default-channel-banner.jpg b/frontend/public/img/default-channel-banner.jpg
new file mode 100644
index 00000000..578db6bc
Binary files /dev/null and b/frontend/public/img/default-channel-banner.jpg differ
diff --git a/frontend/public/img/default-channel-icon.jpg b/frontend/public/img/default-channel-icon.jpg
new file mode 100644
index 00000000..33577a27
Binary files /dev/null and b/frontend/public/img/default-channel-icon.jpg differ
diff --git a/frontend/public/img/default-playlist-thumb.jpg b/frontend/public/img/default-playlist-thumb.jpg
new file mode 100644
index 00000000..6bbaa227
Binary files /dev/null and b/frontend/public/img/default-playlist-thumb.jpg differ
diff --git a/frontend/public/img/default-video-thumb.jpg b/frontend/public/img/default-video-thumb.jpg
new file mode 100644
index 00000000..2325bc9c
Binary files /dev/null and b/frontend/public/img/default-video-thumb.jpg differ
diff --git a/frontend/public/img/icon-add.svg b/frontend/public/img/icon-add.svg
new file mode 100644
index 00000000..5c7be5fe
--- /dev/null
+++ b/frontend/public/img/icon-add.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
diff --git a/frontend/public/img/icon-arrow-bottom.svg b/frontend/public/img/icon-arrow-bottom.svg
new file mode 100644
index 00000000..e829250d
--- /dev/null
+++ b/frontend/public/img/icon-arrow-bottom.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
diff --git a/frontend/public/img/icon-arrow-down.svg b/frontend/public/img/icon-arrow-down.svg
new file mode 100644
index 00000000..0d7adba6
--- /dev/null
+++ b/frontend/public/img/icon-arrow-down.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/public/img/icon-arrow-top.svg b/frontend/public/img/icon-arrow-top.svg
new file mode 100644
index 00000000..bb4b80ca
--- /dev/null
+++ b/frontend/public/img/icon-arrow-top.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/public/img/icon-arrow-up.svg b/frontend/public/img/icon-arrow-up.svg
new file mode 100644
index 00000000..71c96522
--- /dev/null
+++ b/frontend/public/img/icon-arrow-up.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/public/img/icon-close.svg b/frontend/public/img/icon-close.svg
new file mode 100644
index 00000000..63bf8d40
--- /dev/null
+++ b/frontend/public/img/icon-close.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/frontend/public/img/icon-dot-menu.svg b/frontend/public/img/icon-dot-menu.svg
new file mode 100644
index 00000000..9aa4411b
--- /dev/null
+++ b/frontend/public/img/icon-dot-menu.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/frontend/public/img/icon-download.svg b/frontend/public/img/icon-download.svg
new file mode 100644
index 00000000..e829250d
--- /dev/null
+++ b/frontend/public/img/icon-download.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
diff --git a/frontend/public/img/icon-exit.svg b/frontend/public/img/icon-exit.svg
new file mode 100644
index 00000000..3aff8e70
--- /dev/null
+++ b/frontend/public/img/icon-exit.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
diff --git a/frontend/public/img/icon-eye.svg b/frontend/public/img/icon-eye.svg
new file mode 100644
index 00000000..d4153d18
--- /dev/null
+++ b/frontend/public/img/icon-eye.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
diff --git a/frontend/public/img/icon-gear.svg b/frontend/public/img/icon-gear.svg
new file mode 100644
index 00000000..5d8e9f01
--- /dev/null
+++ b/frontend/public/img/icon-gear.svg
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/img/icon-gridview.svg b/frontend/public/img/icon-gridview.svg
new file mode 100644
index 00000000..570696bc
--- /dev/null
+++ b/frontend/public/img/icon-gridview.svg
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/img/icon-heart.svg b/frontend/public/img/icon-heart.svg
new file mode 100644
index 00000000..803e2805
--- /dev/null
+++ b/frontend/public/img/icon-heart.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/frontend/public/img/icon-listview.svg b/frontend/public/img/icon-listview.svg
new file mode 100644
index 00000000..d56b8dfe
--- /dev/null
+++ b/frontend/public/img/icon-listview.svg
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/img/icon-play.svg b/frontend/public/img/icon-play.svg
new file mode 100644
index 00000000..3992440a
--- /dev/null
+++ b/frontend/public/img/icon-play.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
diff --git a/frontend/public/img/icon-remove.svg b/frontend/public/img/icon-remove.svg
new file mode 100644
index 00000000..f73c5a61
--- /dev/null
+++ b/frontend/public/img/icon-remove.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/frontend/public/img/icon-rescan.svg b/frontend/public/img/icon-rescan.svg
new file mode 100644
index 00000000..f8047ef6
--- /dev/null
+++ b/frontend/public/img/icon-rescan.svg
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/img/icon-search.svg b/frontend/public/img/icon-search.svg
new file mode 100644
index 00000000..8a38b1cc
--- /dev/null
+++ b/frontend/public/img/icon-search.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/frontend/public/img/icon-seen.svg b/frontend/public/img/icon-seen.svg
new file mode 100644
index 00000000..153a7b55
--- /dev/null
+++ b/frontend/public/img/icon-seen.svg
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/img/icon-sort.svg b/frontend/public/img/icon-sort.svg
new file mode 100644
index 00000000..ee257a87
--- /dev/null
+++ b/frontend/public/img/icon-sort.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/img/icon-star-empty.svg b/frontend/public/img/icon-star-empty.svg
new file mode 100644
index 00000000..8246a230
--- /dev/null
+++ b/frontend/public/img/icon-star-empty.svg
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/frontend/public/img/icon-star-full.svg b/frontend/public/img/icon-star-full.svg
new file mode 100644
index 00000000..82761e11
--- /dev/null
+++ b/frontend/public/img/icon-star-full.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/img/icon-star-half.svg b/frontend/public/img/icon-star-half.svg
new file mode 100644
index 00000000..73de5e98
--- /dev/null
+++ b/frontend/public/img/icon-star-half.svg
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/frontend/public/img/icon-stop.svg b/frontend/public/img/icon-stop.svg
new file mode 100644
index 00000000..b806c20f
--- /dev/null
+++ b/frontend/public/img/icon-stop.svg
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/img/icon-substract.svg b/frontend/public/img/icon-substract.svg
new file mode 100644
index 00000000..1571fdda
--- /dev/null
+++ b/frontend/public/img/icon-substract.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/frontend/public/img/icon-thumb.svg b/frontend/public/img/icon-thumb.svg
new file mode 100644
index 00000000..91867bcf
--- /dev/null
+++ b/frontend/public/img/icon-thumb.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/img/icon-unseen.svg b/frontend/public/img/icon-unseen.svg
new file mode 100644
index 00000000..43b96960
--- /dev/null
+++ b/frontend/public/img/icon-unseen.svg
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/img/logo-tube-archivist-dark.png b/frontend/public/img/logo-tube-archivist-dark.png
new file mode 100644
index 00000000..08e51852
Binary files /dev/null and b/frontend/public/img/logo-tube-archivist-dark.png differ
diff --git a/frontend/public/img/logo-tube-archivist-light.png b/frontend/public/img/logo-tube-archivist-light.png
new file mode 100644
index 00000000..61b0672a
Binary files /dev/null and b/frontend/public/img/logo-tube-archivist-light.png differ
diff --git a/frontend/src/api/actions/createCustomPlaylist.ts b/frontend/src/api/actions/createCustomPlaylist.ts
new file mode 100644
index 00000000..f2d0db1d
--- /dev/null
+++ b/frontend/src/api/actions/createCustomPlaylist.ts
@@ -0,0 +1,30 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const createCustomPlaylist = async (playlistId: string) => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/playlist/`, {
+ method: 'POST',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+
+ body: JSON.stringify({ data: { create: playlistId } }),
+ });
+
+ const customPlaylist = await response.json();
+ if (isDevEnvironment()) {
+ console.log('createCustomPlaylist', customPlaylist);
+ }
+
+ return customPlaylist;
+};
+
+export default createCustomPlaylist;
diff --git a/frontend/src/api/actions/deleteApiToken.ts b/frontend/src/api/actions/deleteApiToken.ts
new file mode 100644
index 00000000..be2a6906
--- /dev/null
+++ b/frontend/src/api/actions/deleteApiToken.ts
@@ -0,0 +1,28 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const deleteApiToken = async () => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/appsettings/token/`, {
+ method: 'DELETE',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+ });
+
+ const resetToken = await response.json();
+ if (isDevEnvironment()) {
+ console.log('deleteApiToken', resetToken);
+ }
+
+ return resetToken;
+};
+
+export default deleteApiToken;
diff --git a/frontend/src/api/actions/deleteChannel.ts b/frontend/src/api/actions/deleteChannel.ts
new file mode 100644
index 00000000..fad7ddb8
--- /dev/null
+++ b/frontend/src/api/actions/deleteChannel.ts
@@ -0,0 +1,29 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const deleteChannel = async (channelId: string) => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/channel/${channelId}/`, {
+ method: 'DELETE',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+ });
+
+ const channelDeleted = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('deleteChannel', channelDeleted);
+ }
+
+ return channelDeleted;
+};
+
+export default deleteChannel;
diff --git a/frontend/src/api/actions/deleteDownloadById.ts b/frontend/src/api/actions/deleteDownloadById.ts
new file mode 100644
index 00000000..e46d1844
--- /dev/null
+++ b/frontend/src/api/actions/deleteDownloadById.ts
@@ -0,0 +1,29 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const deleteDownloadById = async (youtubeId: string) => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/download/${youtubeId}/`, {
+ method: 'DELETE',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+ });
+
+ const downloadState = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('deleteDownloadById', downloadState);
+ }
+
+ return downloadState;
+};
+
+export default deleteDownloadById;
diff --git a/frontend/src/api/actions/deleteDownloadQueueByFilter.ts b/frontend/src/api/actions/deleteDownloadQueueByFilter.ts
new file mode 100644
index 00000000..51d5b949
--- /dev/null
+++ b/frontend/src/api/actions/deleteDownloadQueueByFilter.ts
@@ -0,0 +1,37 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+type FilterType = 'ignore' | 'pending';
+
+const deleteDownloadQueueByFilter = async (filter: FilterType) => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const searchParams = new URLSearchParams();
+
+ if (filter) {
+ searchParams.append('filter', filter);
+ }
+
+ const response = await fetch(`${apiUrl}/api/download/?${searchParams.toString()}`, {
+ method: 'DELETE',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+ });
+
+ const downloadState = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('deleteDownloadQueueByFilter', downloadState);
+ }
+
+ return downloadState;
+};
+
+export default deleteDownloadQueueByFilter;
diff --git a/frontend/src/api/actions/deletePlaylist.ts b/frontend/src/api/actions/deletePlaylist.ts
new file mode 100644
index 00000000..af5a7951
--- /dev/null
+++ b/frontend/src/api/actions/deletePlaylist.ts
@@ -0,0 +1,34 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const deletePlaylist = async (playlistId: string, allVideos = false) => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ let params = '';
+ if (allVideos) {
+ params = '?delete-videos=true';
+ }
+
+ const response = await fetch(`${apiUrl}/api/playlist/${playlistId}/${params}`, {
+ method: 'DELETE',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+ });
+
+ const playlistDeleted = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('deletePlaylist', playlistDeleted);
+ }
+
+ return playlistDeleted;
+};
+
+export default deletePlaylist;
diff --git a/frontend/src/api/actions/deleteVideo.ts b/frontend/src/api/actions/deleteVideo.ts
new file mode 100644
index 00000000..76aa5b6a
--- /dev/null
+++ b/frontend/src/api/actions/deleteVideo.ts
@@ -0,0 +1,29 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const deleteVideo = async (videoId: string) => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/video/${videoId}/`, {
+ method: 'DELETE',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+ });
+
+ const videoDeleted = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('deleteVideo', videoDeleted);
+ }
+
+ return videoDeleted;
+};
+
+export default deleteVideo;
diff --git a/frontend/src/api/actions/deleteVideoProgressById.ts b/frontend/src/api/actions/deleteVideoProgressById.ts
new file mode 100644
index 00000000..bf497e0f
--- /dev/null
+++ b/frontend/src/api/actions/deleteVideoProgressById.ts
@@ -0,0 +1,29 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const deleteVideoProgressById = async (youtubeId: string) => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/video/${youtubeId}/progress/`, {
+ method: 'DELETE',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+ });
+
+ const watchedState = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('deleteVideoProgressById', watchedState);
+ }
+
+ return watchedState;
+};
+
+export default deleteVideoProgressById;
diff --git a/frontend/src/api/actions/queueBackup.ts b/frontend/src/api/actions/queueBackup.ts
new file mode 100644
index 00000000..0ab231a5
--- /dev/null
+++ b/frontend/src/api/actions/queueBackup.ts
@@ -0,0 +1,29 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const queueBackup = async () => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/appsettings/backup/`, {
+ method: 'POST',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+ });
+
+ const backupQueued = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('queueBackup', backupQueued);
+ }
+
+ return backupQueued;
+};
+
+export default queueBackup;
diff --git a/frontend/src/api/actions/queueReindex.ts b/frontend/src/api/actions/queueReindex.ts
new file mode 100644
index 00000000..4b185a38
--- /dev/null
+++ b/frontend/src/api/actions/queueReindex.ts
@@ -0,0 +1,48 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+export type ReindexType = 'channel' | 'video' | 'playlist';
+
+export const ReindexTypeEnum = {
+ channel: 'channel',
+ video: 'video',
+ playlist: 'playlist',
+};
+
+const queueReindex = async (id: string, type: ReindexType, reindexVideos = false) => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ let params = '';
+ if (reindexVideos) {
+ params = '?extract_videos=true';
+ }
+
+ const body = JSON.stringify({
+ [type]: id,
+ });
+
+ const response = await fetch(`${apiUrl}/api/refresh/${params}`, {
+ method: 'POST',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+
+ body,
+ });
+
+ const channelDeleted = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('queueReindex', channelDeleted);
+ }
+
+ return channelDeleted;
+};
+
+export default queueReindex;
diff --git a/frontend/src/api/actions/queueSnapshot.ts b/frontend/src/api/actions/queueSnapshot.ts
new file mode 100644
index 00000000..6d55c47e
--- /dev/null
+++ b/frontend/src/api/actions/queueSnapshot.ts
@@ -0,0 +1,29 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const queueSnapshot = async () => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/appsettings/snapshot/`, {
+ method: 'POST',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+ });
+
+ const snapshotQueued = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('queueSnapshot', snapshotQueued);
+ }
+
+ return snapshotQueued;
+};
+
+export default queueSnapshot;
diff --git a/frontend/src/api/actions/restoreBackup.ts b/frontend/src/api/actions/restoreBackup.ts
new file mode 100644
index 00000000..996f4313
--- /dev/null
+++ b/frontend/src/api/actions/restoreBackup.ts
@@ -0,0 +1,29 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const restoreBackup = async (fileName: string) => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/appsettings/backup/${fileName}/`, {
+ method: 'POST',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+ });
+
+ const backupRestored = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('restoreBackup', backupRestored);
+ }
+
+ return backupRestored;
+};
+
+export default restoreBackup;
diff --git a/frontend/src/api/actions/restoreSnapshot.ts b/frontend/src/api/actions/restoreSnapshot.ts
new file mode 100644
index 00000000..6d77021f
--- /dev/null
+++ b/frontend/src/api/actions/restoreSnapshot.ts
@@ -0,0 +1,29 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const restoreSnapshot = async (snapshotId: string) => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/appsettings/snapshot/${snapshotId}/`, {
+ method: 'POST',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+ });
+
+ const backupRestored = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('restoreSnapshot', backupRestored);
+ }
+
+ return backupRestored;
+};
+
+export default restoreSnapshot;
diff --git a/frontend/src/api/actions/signIn.ts b/frontend/src/api/actions/signIn.ts
new file mode 100644
index 00000000..41cd32a9
--- /dev/null
+++ b/frontend/src/api/actions/signIn.ts
@@ -0,0 +1,39 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+
+export type LoginResponseType = {
+ token?: string;
+ user_id: number;
+ is_superuser: boolean;
+ is_staff: boolean;
+ user_groups: [];
+};
+
+const signIn = async (username: string, password: string, saveLogin: boolean) => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/user/login/`, {
+ method: 'POST',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+ body: JSON.stringify({
+ username,
+ password,
+ remember_me: saveLogin ? 'on' : 'off',
+ }),
+ });
+
+ if (response.status === 403) {
+ console.log('Might be already logged in.', await response.json());
+ }
+
+ return response;
+};
+
+export default signIn;
diff --git a/frontend/src/api/actions/stopTaskByName.ts b/frontend/src/api/actions/stopTaskByName.ts
new file mode 100644
index 00000000..77f599bf
--- /dev/null
+++ b/frontend/src/api/actions/stopTaskByName.ts
@@ -0,0 +1,27 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+
+const stopTaskByName = async (taskId: string) => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/task/by-id/${taskId}/`, {
+ method: 'POST',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+
+ body: JSON.stringify({ command: 'stop' }),
+ });
+
+ const downloadQueueState = await response.json();
+ console.log('stopTaskByName', downloadQueueState);
+
+ return downloadQueueState;
+};
+
+export default stopTaskByName;
diff --git a/frontend/src/api/actions/updateAppsettingsConfig.ts b/frontend/src/api/actions/updateAppsettingsConfig.ts
new file mode 100644
index 00000000..f1254705
--- /dev/null
+++ b/frontend/src/api/actions/updateAppsettingsConfig.ts
@@ -0,0 +1,27 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+import { AppSettingsConfigType } from '../loader/loadAppsettingsConfig';
+
+const updateAppsettingsConfig = async (config: AppSettingsConfigType) => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/appsettings/config/`, {
+ method: 'POST',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+ body: JSON.stringify(config),
+ });
+
+ const appSettingsConfig = await response.json();
+ console.log('updateAppsettingsConfig', appSettingsConfig);
+
+ return appSettingsConfig;
+};
+
+export default updateAppsettingsConfig;
diff --git a/frontend/src/api/actions/updateChannelSubscription.ts b/frontend/src/api/actions/updateChannelSubscription.ts
new file mode 100644
index 00000000..5fc87c30
--- /dev/null
+++ b/frontend/src/api/actions/updateChannelSubscription.ts
@@ -0,0 +1,29 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+
+const updateChannelSubscription = async (channelId: string, status: boolean) => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/channel/`, {
+ method: 'POST',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+
+ body: JSON.stringify({
+ data: [{ channel_id: channelId, channel_subscribed: status }],
+ }),
+ });
+
+ const channelSubscription = await response.json();
+ console.log('updateChannelSubscription', channelSubscription);
+
+ return channelSubscription;
+};
+
+export default updateChannelSubscription;
diff --git a/frontend/src/api/actions/updateCookie.ts b/frontend/src/api/actions/updateCookie.ts
new file mode 100644
index 00000000..d02ba71e
--- /dev/null
+++ b/frontend/src/api/actions/updateCookie.ts
@@ -0,0 +1,32 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+
+export type ValidatedCookieType = {
+ cookie_enabled: boolean;
+ status: boolean;
+ validated: number;
+ validated_str: string;
+};
+
+const updateCookie = async (): Promise => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/appsettings/cookie/`, {
+ method: 'POST',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+ });
+
+ const validatedCookie = await response.json();
+ console.log('updateCookie', validatedCookie);
+
+ return validatedCookie;
+};
+
+export default updateCookie;
diff --git a/frontend/src/api/actions/updateCustomPlaylist.ts b/frontend/src/api/actions/updateCustomPlaylist.ts
new file mode 100644
index 00000000..67daa62b
--- /dev/null
+++ b/frontend/src/api/actions/updateCustomPlaylist.ts
@@ -0,0 +1,33 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+
+type CustomPlaylistActionType = 'create' | 'up' | 'down' | 'top' | 'bottom' | 'remove';
+
+const updateCustomPlaylist = async (
+ action: CustomPlaylistActionType,
+ playlistId: string,
+ videoId: string,
+) => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/playlist/${playlistId}/`, {
+ method: 'POST',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+
+ body: JSON.stringify({ action, video_id: videoId }),
+ });
+
+ const customPlaylist = await response.json();
+ console.log('updateCustomPlaylist', action, customPlaylist);
+
+ return customPlaylist;
+};
+
+export default updateCustomPlaylist;
diff --git a/frontend/src/api/actions/updateDownloadQueue.ts b/frontend/src/api/actions/updateDownloadQueue.ts
new file mode 100644
index 00000000..5992dd8e
--- /dev/null
+++ b/frontend/src/api/actions/updateDownloadQueue.ts
@@ -0,0 +1,34 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+
+const updateDownloadQueue = async (download: string, autostart: boolean) => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ let params = '';
+ if (autostart) {
+ params = '?autostart=true';
+ }
+
+ const response = await fetch(`${apiUrl}/api/download/${params}`, {
+ method: 'POST',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+
+ body: JSON.stringify({
+ data: [{ youtube_id: download, status: 'pending' }],
+ }),
+ });
+
+ const downloadState = await response.json();
+ console.log('updateDownloadQueue', downloadState);
+
+ return downloadState;
+};
+
+export default updateDownloadQueue;
diff --git a/frontend/src/api/actions/updateDownloadQueueStatusById.ts b/frontend/src/api/actions/updateDownloadQueueStatusById.ts
new file mode 100644
index 00000000..fafcca8f
--- /dev/null
+++ b/frontend/src/api/actions/updateDownloadQueueStatusById.ts
@@ -0,0 +1,31 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+
+export type DownloadQueueStatus = 'ignore' | 'pending' | 'priority';
+
+const updateDownloadQueueStatusById = async (youtubeId: string, status: DownloadQueueStatus) => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/download/${youtubeId}/`, {
+ method: 'POST',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+
+ body: JSON.stringify({
+ status,
+ }),
+ });
+
+ const downloadQueueState = await response.json();
+ console.log('updateDownloadQueueStatusById', downloadQueueState);
+
+ return downloadQueueState;
+};
+
+export default updateDownloadQueueStatusById;
diff --git a/frontend/src/api/actions/updatePlaylistSubscription.ts b/frontend/src/api/actions/updatePlaylistSubscription.ts
new file mode 100644
index 00000000..f781a353
--- /dev/null
+++ b/frontend/src/api/actions/updatePlaylistSubscription.ts
@@ -0,0 +1,29 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+
+const updatePlaylistSubscription = async (playlistId: string, status: boolean) => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/playlist/`, {
+ method: 'POST',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+
+ body: JSON.stringify({
+ data: [{ playlist_id: playlistId, playlist_subscribed: status }],
+ }),
+ });
+
+ const playlistSubscription = await response.json();
+ console.log('updatePlaylistSubscription', playlistSubscription);
+
+ return playlistSubscription;
+};
+
+export default updatePlaylistSubscription;
diff --git a/frontend/src/api/actions/updateTaskByName.ts b/frontend/src/api/actions/updateTaskByName.ts
new file mode 100644
index 00000000..8dc77ccb
--- /dev/null
+++ b/frontend/src/api/actions/updateTaskByName.ts
@@ -0,0 +1,32 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+
+type TaskNamesType =
+ | 'download_pending'
+ | 'update_subscribed'
+ | 'manual_import'
+ | 'resync_thumbs'
+ | 'rescan_filesystem';
+
+const updateTaskByName = async (taskName: TaskNamesType) => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/task/by-name/${taskName}/`, {
+ method: 'POST',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+ });
+
+ const downloadQueueState = await response.json();
+ console.log('updateTaskByName', downloadQueueState);
+
+ return downloadQueueState;
+};
+
+export default updateTaskByName;
diff --git a/frontend/src/api/actions/updateUserConfig.ts b/frontend/src/api/actions/updateUserConfig.ts
new file mode 100644
index 00000000..4697c118
--- /dev/null
+++ b/frontend/src/api/actions/updateUserConfig.ts
@@ -0,0 +1,56 @@
+import { ColourVariants } from '../../configuration/colours/getColours';
+import { SortByType, SortOrderType, ViewLayoutType } from '../../pages/Home';
+import getApiUrl from '../../configuration/getApiUrl';
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getCookie from '../../functions/getCookie';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+
+export type UserMeType = {
+ id: number;
+ name: string;
+ is_superuser: boolean;
+ is_staff: boolean;
+ groups: [];
+ user_permissions: [];
+ last_login: string;
+ config: UserConfigType;
+};
+
+export type UserConfigType = {
+ stylesheet?: ColourVariants;
+ page_size?: number;
+ sort_by?: SortByType;
+ sort_order?: SortOrderType;
+ view_style_home?: ViewLayoutType;
+ view_style_channel?: ViewLayoutType;
+ view_style_downloads?: ViewLayoutType;
+ view_style_playlist?: ViewLayoutType;
+ grid_items?: number;
+ hide_watched?: boolean;
+ show_ignored_only?: boolean;
+ show_subed_only?: boolean;
+ sponsorblock_id?: number;
+};
+
+const updateUserConfig = async (config: UserConfigType): Promise => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/user/me/`, {
+ method: 'POST',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+
+ body: JSON.stringify({ config }),
+ });
+
+ const userConfig = await response.json();
+ console.log('updateUserConfig', userConfig);
+
+ return userConfig;
+};
+
+export default updateUserConfig;
diff --git a/frontend/src/api/actions/updateVideoProgressById.ts b/frontend/src/api/actions/updateVideoProgressById.ts
new file mode 100644
index 00000000..bbb8b40a
--- /dev/null
+++ b/frontend/src/api/actions/updateVideoProgressById.ts
@@ -0,0 +1,34 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+
+type VideoProgressProp = {
+ youtubeId: string;
+ currentProgress: number;
+};
+
+const updateVideoProgressById = async ({ youtubeId, currentProgress }: VideoProgressProp) => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/video/${youtubeId}/progress/`, {
+ method: 'POST',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+
+ body: JSON.stringify({
+ position: currentProgress,
+ }),
+ });
+
+ const userConfig = await response.json();
+ console.log('updateVideoProgressById', userConfig);
+
+ return userConfig;
+};
+
+export default updateVideoProgressById;
diff --git a/frontend/src/api/actions/updateWatchedState.ts b/frontend/src/api/actions/updateWatchedState.ts
new file mode 100644
index 00000000..449a1191
--- /dev/null
+++ b/frontend/src/api/actions/updateWatchedState.ts
@@ -0,0 +1,37 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+import deleteVideoProgressById from './deleteVideoProgressById';
+
+export type Watched = {
+ id: string;
+ is_watched: boolean;
+};
+
+const updateWatchedState = async (watched: Watched) => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ if (watched.is_watched) {
+ await deleteVideoProgressById(watched.id);
+ }
+
+ const response = await fetch(`${apiUrl}/api/watched/`, {
+ method: 'POST',
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+
+ body: JSON.stringify(watched),
+ });
+
+ const watchedState = await response.json();
+ console.log('updateWatchedState', watchedState);
+
+ return watchedState;
+};
+
+export default updateWatchedState;
diff --git a/frontend/src/api/loader/loadApiToken.ts b/frontend/src/api/loader/loadApiToken.ts
new file mode 100644
index 00000000..965433ff
--- /dev/null
+++ b/frontend/src/api/loader/loadApiToken.ts
@@ -0,0 +1,32 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+type ApiTokenResponse = {
+ token: string;
+};
+
+const loadApiToken = async (): Promise => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/appsettings/token/`, {
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+ });
+
+ const apiToken = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadApiToken', apiToken);
+ }
+
+ return apiToken;
+};
+
+export default loadApiToken;
diff --git a/frontend/src/api/loader/loadAppsettingsConfig.ts b/frontend/src/api/loader/loadAppsettingsConfig.ts
new file mode 100644
index 00000000..90b50b76
--- /dev/null
+++ b/frontend/src/api/loader/loadAppsettingsConfig.ts
@@ -0,0 +1,54 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+export type AppSettingsConfigType = {
+ subscriptions: {
+ channel_size: number;
+ live_channel_size: number;
+ shorts_channel_size: number;
+ auto_start: boolean;
+ };
+ downloads: {
+ limit_speed: boolean | number;
+ sleep_interval: number;
+ autodelete_days: boolean | number;
+ format: boolean | string;
+ format_sort: boolean | string;
+ add_metadata: boolean;
+ add_thumbnail: boolean;
+ subtitle: boolean | string;
+ subtitle_source: boolean | string;
+ subtitle_index: boolean;
+ comment_max: boolean | number;
+ comment_sort: string;
+ cookie_import: boolean;
+ throttledratelimit: boolean | number;
+ extractor_lang: boolean | string;
+ integrate_ryd: boolean;
+ integrate_sponsorblock: boolean;
+ };
+ application: {
+ enable_snapshot: boolean;
+ };
+};
+
+const loadAppsettingsConfig = async (): Promise => {
+ const apiUrl = getApiUrl();
+
+ const response = await fetch(`${apiUrl}/api/appsettings/config/`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const appSettingsConfig = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadApplicationConfig', appSettingsConfig);
+ }
+
+ return appSettingsConfig;
+};
+
+export default loadAppsettingsConfig;
diff --git a/frontend/src/api/loader/loadAuth.ts b/frontend/src/api/loader/loadAuth.ts
new file mode 100644
index 00000000..31848cf0
--- /dev/null
+++ b/frontend/src/api/loader/loadAuth.ts
@@ -0,0 +1,21 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import getCookie from '../../functions/getCookie';
+
+const loadAuth = async () => {
+ const apiUrl = getApiUrl();
+ const csrfCookie = getCookie('csrftoken');
+
+ const response = await fetch(`${apiUrl}/api/ping/`, {
+ headers: {
+ ...defaultHeaders,
+ 'X-CSRFToken': csrfCookie || '',
+ },
+ credentials: getFetchCredentials(),
+ });
+
+ return response;
+};
+
+export default loadAuth;
diff --git a/frontend/src/api/loader/loadBackupList.ts b/frontend/src/api/loader/loadBackupList.ts
new file mode 100644
index 00000000..252ce4ef
--- /dev/null
+++ b/frontend/src/api/loader/loadBackupList.ts
@@ -0,0 +1,23 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const loadBackupList = async () => {
+ const apiUrl = getApiUrl();
+
+ const response = await fetch(`${apiUrl}/api/appsettings/backup/`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const backupList = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadBackupList', backupList);
+ }
+
+ return backupList;
+};
+
+export default loadBackupList;
diff --git a/frontend/src/api/loader/loadChannelById.ts b/frontend/src/api/loader/loadChannelById.ts
new file mode 100644
index 00000000..de4e581c
--- /dev/null
+++ b/frontend/src/api/loader/loadChannelById.ts
@@ -0,0 +1,23 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const loadChannelById = async (youtubeChannelId: string) => {
+ const apiUrl = getApiUrl();
+
+ const response = await fetch(`${apiUrl}/api/channel/${youtubeChannelId}/`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const channel = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadChannelById', channel);
+ }
+
+ return channel;
+};
+
+export default loadChannelById;
diff --git a/frontend/src/api/loader/loadChannelList.ts b/frontend/src/api/loader/loadChannelList.ts
new file mode 100644
index 00000000..bff7006f
--- /dev/null
+++ b/frontend/src/api/loader/loadChannelList.ts
@@ -0,0 +1,33 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const loadChannelList = async (page: number, showSubscribed: boolean) => {
+ const apiUrl = getApiUrl();
+
+ const searchParams = new URLSearchParams();
+
+ if (page) {
+ searchParams.append('page', page.toString());
+ }
+
+ if (showSubscribed) {
+ searchParams.append('filter', 'subscribed');
+ }
+
+ const response = await fetch(`${apiUrl}/api/channel/?${searchParams.toString()}`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const channels = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadChannelList', channels);
+ }
+
+ return channels;
+};
+
+export default loadChannelList;
diff --git a/frontend/src/api/loader/loadChannelNav.ts b/frontend/src/api/loader/loadChannelNav.ts
new file mode 100644
index 00000000..79486afe
--- /dev/null
+++ b/frontend/src/api/loader/loadChannelNav.ts
@@ -0,0 +1,30 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+export type ChannelNavResponseType = {
+ has_streams: boolean;
+ has_shorts: boolean;
+ has_playlists: boolean;
+ has_pending: boolean;
+};
+
+const loadChannelNav = async (youtubeChannelId: string): Promise => {
+ const apiUrl = getApiUrl();
+
+ const response = await fetch(`${apiUrl}/api/channel/${youtubeChannelId}/nav/`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const channel = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadChannelNav', channel);
+ }
+
+ return channel;
+};
+
+export default loadChannelNav;
diff --git a/frontend/src/api/loader/loadCommentsbyVideoId.ts b/frontend/src/api/loader/loadCommentsbyVideoId.ts
new file mode 100644
index 00000000..294a13b6
--- /dev/null
+++ b/frontend/src/api/loader/loadCommentsbyVideoId.ts
@@ -0,0 +1,23 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const loadCommentsbyVideoId = async (youtubeId: string) => {
+ const apiUrl = getApiUrl();
+
+ const response = await fetch(`${apiUrl}/api/video/${youtubeId}/comment/`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const comments = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadCommentsbyVideoId', comments);
+ }
+
+ return comments;
+};
+
+export default loadCommentsbyVideoId;
diff --git a/frontend/src/api/loader/loadDownloadAggs.ts b/frontend/src/api/loader/loadDownloadAggs.ts
new file mode 100644
index 00000000..56a21bc8
--- /dev/null
+++ b/frontend/src/api/loader/loadDownloadAggs.ts
@@ -0,0 +1,37 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+type DownloadAggsBucket = {
+ key: string[];
+ key_as_string: string;
+ doc_count: number;
+};
+
+export type DownloadAggsType = {
+ channel_downloads: {
+ doc_count_error_upper_bound: number;
+ sum_other_doc_count: number;
+ buckets: DownloadAggsBucket[];
+ };
+};
+
+const loadDownloadAggs = async (): Promise => {
+ const apiUrl = getApiUrl();
+
+ const response = await fetch(`${apiUrl}/api/download/aggs/`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const downloadAggs = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadDownloadAggs', downloadAggs);
+ }
+
+ return downloadAggs;
+};
+
+export default loadDownloadAggs;
diff --git a/frontend/src/api/loader/loadDownloadQueue.ts b/frontend/src/api/loader/loadDownloadQueue.ts
new file mode 100644
index 00000000..5e2f11a3
--- /dev/null
+++ b/frontend/src/api/loader/loadDownloadQueue.ts
@@ -0,0 +1,35 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const loadDownloadQueue = async (page: number, channelId: string | null, showIgnored: boolean) => {
+ const apiUrl = getApiUrl();
+
+ const searchParams = new URLSearchParams();
+
+ if (page) {
+ searchParams.append('page', page.toString());
+ }
+
+ if (channelId) {
+ searchParams.append('channel', channelId);
+ }
+
+ searchParams.append('filter', showIgnored ? 'ignore' : 'pending');
+
+ const response = await fetch(`${apiUrl}/api/download/?${searchParams.toString()}`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const playlist = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadDownloadQueue', playlist);
+ }
+
+ return playlist;
+};
+
+export default loadDownloadQueue;
diff --git a/frontend/src/api/loader/loadNotifications.ts b/frontend/src/api/loader/loadNotifications.ts
new file mode 100644
index 00000000..1bd3f08a
--- /dev/null
+++ b/frontend/src/api/loader/loadNotifications.ts
@@ -0,0 +1,30 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+export type NotificationPages = 'download' | 'settings' | 'channel' | 'all';
+
+const loadNotifications = async (pageName: NotificationPages, includeReindex = false) => {
+ const apiUrl = getApiUrl();
+
+ let params = '';
+ if (!includeReindex && pageName !== 'all') {
+ params = `?filter=${pageName}`;
+ }
+
+ const response = await fetch(`${apiUrl}/api/notification/${params}`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const notifications = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadNotifications', notifications);
+ }
+
+ return notifications;
+};
+
+export default loadNotifications;
diff --git a/frontend/src/api/loader/loadPlaylistById.ts b/frontend/src/api/loader/loadPlaylistById.ts
new file mode 100644
index 00000000..d0ad78e6
--- /dev/null
+++ b/frontend/src/api/loader/loadPlaylistById.ts
@@ -0,0 +1,23 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const loadPlaylistById = async (playlistId: string | undefined) => {
+ const apiUrl = getApiUrl();
+
+ const response = await fetch(`${apiUrl}/api/playlist/${playlistId}/`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const videos = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadPlaylistById', videos);
+ }
+
+ return videos;
+};
+
+export default loadPlaylistById;
diff --git a/frontend/src/api/loader/loadPlaylistList.ts b/frontend/src/api/loader/loadPlaylistList.ts
new file mode 100644
index 00000000..fd42ff3b
--- /dev/null
+++ b/frontend/src/api/loader/loadPlaylistList.ts
@@ -0,0 +1,50 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+type PlaylistType = 'regular' | 'custom';
+
+type LoadPlaylistListProps = {
+ channel?: string;
+ page?: number | undefined;
+ subscribed?: boolean;
+ type?: PlaylistType;
+};
+
+const loadPlaylistList = async ({ channel, page, subscribed, type }: LoadPlaylistListProps) => {
+ const apiUrl = getApiUrl();
+
+ const searchParams = new URLSearchParams();
+
+ if (channel) {
+ searchParams.append('channel', channel);
+ }
+
+ if (page) {
+ searchParams.append('page', page.toString());
+ }
+
+ if (subscribed) {
+ searchParams.append('subscribed', subscribed.toString());
+ }
+
+ if (type) {
+ searchParams.append('type', type);
+ }
+
+ const response = await fetch(`${apiUrl}/api/playlist/?${searchParams.toString()}`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const playlist = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadPlaylistList', playlist);
+ }
+
+ return playlist;
+};
+
+export default loadPlaylistList;
diff --git a/frontend/src/api/loader/loadSearch.ts b/frontend/src/api/loader/loadSearch.ts
new file mode 100644
index 00000000..40d2c6a1
--- /dev/null
+++ b/frontend/src/api/loader/loadSearch.ts
@@ -0,0 +1,23 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const loadSearch = async (query: string) => {
+ const apiUrl = getApiUrl();
+
+ const response = await fetch(`${apiUrl}/api/search/?query=${query}`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const searchResults = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadSearch', searchResults);
+ }
+
+ return searchResults;
+};
+
+export default loadSearch;
diff --git a/frontend/src/api/loader/loadSimmilarVideosById.ts b/frontend/src/api/loader/loadSimmilarVideosById.ts
new file mode 100644
index 00000000..221b454c
--- /dev/null
+++ b/frontend/src/api/loader/loadSimmilarVideosById.ts
@@ -0,0 +1,23 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const loadSimmilarVideosById = async (youtubeId: string) => {
+ const apiUrl = getApiUrl();
+
+ const response = await fetch(`${apiUrl}/api/video/${youtubeId}/similar/`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const videos = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadSimmilarVideosById', videos);
+ }
+
+ return videos;
+};
+
+export default loadSimmilarVideosById;
diff --git a/frontend/src/api/loader/loadSnapshots.ts b/frontend/src/api/loader/loadSnapshots.ts
new file mode 100644
index 00000000..a6560016
--- /dev/null
+++ b/frontend/src/api/loader/loadSnapshots.ts
@@ -0,0 +1,23 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const loadSnapshots = async () => {
+ const apiUrl = getApiUrl();
+
+ const response = await fetch(`${apiUrl}/api/appsettings/snapshot/`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const backupList = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadSnapshots', backupList);
+ }
+
+ return backupList;
+};
+
+export default loadSnapshots;
diff --git a/frontend/src/api/loader/loadSponsorblockByVideoId.ts b/frontend/src/api/loader/loadSponsorblockByVideoId.ts
new file mode 100644
index 00000000..66f58d51
--- /dev/null
+++ b/frontend/src/api/loader/loadSponsorblockByVideoId.ts
@@ -0,0 +1,23 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const loadSponsorblockByVideoId = async (youtubeId: string) => {
+ const apiUrl = getApiUrl();
+
+ const response = await fetch(`${apiUrl}/api/video/${youtubeId}/sponsor/`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const videos = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadSponsorblockByVideoId', videos);
+ }
+
+ return videos;
+};
+
+export default loadSponsorblockByVideoId;
diff --git a/frontend/src/api/loader/loadStatsBiggestChannels.ts b/frontend/src/api/loader/loadStatsBiggestChannels.ts
new file mode 100644
index 00000000..14a66438
--- /dev/null
+++ b/frontend/src/api/loader/loadStatsBiggestChannels.ts
@@ -0,0 +1,28 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+type BiggestChannelsOrderType = 'doc_count' | 'duration' | 'media_size';
+
+const loadStatsBiggestChannels = async (order: BiggestChannelsOrderType) => {
+ const apiUrl = getApiUrl();
+
+ const searchParams = new URLSearchParams();
+ searchParams.append('order', order);
+
+ const response = await fetch(`${apiUrl}/api/stats/biggestchannels/?${searchParams.toString()}`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const notifications = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadStatsBiggestChannels', notifications);
+ }
+
+ return notifications;
+};
+
+export default loadStatsBiggestChannels;
diff --git a/frontend/src/api/loader/loadStatsChannel.ts b/frontend/src/api/loader/loadStatsChannel.ts
new file mode 100644
index 00000000..6e8fd1f3
--- /dev/null
+++ b/frontend/src/api/loader/loadStatsChannel.ts
@@ -0,0 +1,23 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const loadStatsChannel = async () => {
+ const apiUrl = getApiUrl();
+
+ const response = await fetch(`${apiUrl}/api/stats/channel/`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const notifications = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadStatsChannel', notifications);
+ }
+
+ return notifications;
+};
+
+export default loadStatsChannel;
diff --git a/frontend/src/api/loader/loadStatsDownload.ts b/frontend/src/api/loader/loadStatsDownload.ts
new file mode 100644
index 00000000..65152ff2
--- /dev/null
+++ b/frontend/src/api/loader/loadStatsDownload.ts
@@ -0,0 +1,23 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const loadStatsDownload = async () => {
+ const apiUrl = getApiUrl();
+
+ const response = await fetch(`${apiUrl}/api/stats/download/`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const notifications = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadStatsDownload', notifications);
+ }
+
+ return notifications;
+};
+
+export default loadStatsDownload;
diff --git a/frontend/src/api/loader/loadStatsDownloadHistory.ts b/frontend/src/api/loader/loadStatsDownloadHistory.ts
new file mode 100644
index 00000000..45a1ca40
--- /dev/null
+++ b/frontend/src/api/loader/loadStatsDownloadHistory.ts
@@ -0,0 +1,23 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const loadStatsDownloadHistory = async () => {
+ const apiUrl = getApiUrl();
+
+ const response = await fetch(`${apiUrl}/api/stats/downloadhist/`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const notifications = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadStatsDownloadHistory', notifications);
+ }
+
+ return notifications;
+};
+
+export default loadStatsDownloadHistory;
diff --git a/frontend/src/api/loader/loadStatsPlaylist.ts b/frontend/src/api/loader/loadStatsPlaylist.ts
new file mode 100644
index 00000000..c40bba47
--- /dev/null
+++ b/frontend/src/api/loader/loadStatsPlaylist.ts
@@ -0,0 +1,23 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const loadStatsPlaylist = async () => {
+ const apiUrl = getApiUrl();
+
+ const response = await fetch(`${apiUrl}/api/stats/playlist/`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const notifications = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadStatsPlaylist', notifications);
+ }
+
+ return notifications;
+};
+
+export default loadStatsPlaylist;
diff --git a/frontend/src/api/loader/loadStatsVideo.ts b/frontend/src/api/loader/loadStatsVideo.ts
new file mode 100644
index 00000000..c214435d
--- /dev/null
+++ b/frontend/src/api/loader/loadStatsVideo.ts
@@ -0,0 +1,23 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const loadStatsVideo = async () => {
+ const apiUrl = getApiUrl();
+
+ const response = await fetch(`${apiUrl}/api/stats/video/`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const notifications = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadStatsVideo', notifications);
+ }
+
+ return notifications;
+};
+
+export default loadStatsVideo;
diff --git a/frontend/src/api/loader/loadStatsWatchProgress.ts b/frontend/src/api/loader/loadStatsWatchProgress.ts
new file mode 100644
index 00000000..395fd8ea
--- /dev/null
+++ b/frontend/src/api/loader/loadStatsWatchProgress.ts
@@ -0,0 +1,23 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const loadStatsWatchProgress = async () => {
+ const apiUrl = getApiUrl();
+
+ const response = await fetch(`${apiUrl}/api/stats/watch/`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const notifications = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadStatsWatchProgress', notifications);
+ }
+
+ return notifications;
+};
+
+export default loadStatsWatchProgress;
diff --git a/frontend/src/api/loader/loadUserConfig.ts b/frontend/src/api/loader/loadUserConfig.ts
new file mode 100644
index 00000000..2fa00996
--- /dev/null
+++ b/frontend/src/api/loader/loadUserConfig.ts
@@ -0,0 +1,24 @@
+import { UserMeType } from '../actions/updateUserConfig';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+import getApiUrl from '../../configuration/getApiUrl';
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+
+const loadUserMeConfig = async (): Promise => {
+ const apiUrl = getApiUrl();
+
+ const response = await fetch(`${apiUrl}/api/user/me/`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const userConfig = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadUserMeConfig', userConfig);
+ }
+
+ return userConfig;
+};
+
+export default loadUserMeConfig;
diff --git a/frontend/src/api/loader/loadVideoById.ts b/frontend/src/api/loader/loadVideoById.ts
new file mode 100644
index 00000000..dcc904a9
--- /dev/null
+++ b/frontend/src/api/loader/loadVideoById.ts
@@ -0,0 +1,23 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const loadVideoById = async (youtubeId: string) => {
+ const apiUrl = getApiUrl();
+
+ const response = await fetch(`${apiUrl}/api/video/${youtubeId}/`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const videos = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadVideoById', videos);
+ }
+
+ return videos;
+};
+
+export default loadVideoById;
diff --git a/frontend/src/api/loader/loadVideoListByPage.ts b/frontend/src/api/loader/loadVideoListByPage.ts
new file mode 100644
index 00000000..40509dbd
--- /dev/null
+++ b/frontend/src/api/loader/loadVideoListByPage.ts
@@ -0,0 +1,65 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+import { SortByType, SortOrderType } from '../../pages/Home';
+
+type WatchTypes = 'watched' | 'unwatched' | 'continue';
+type VideoTypes = 'videos' | 'streams' | 'shorts';
+
+type FilterType = {
+ page?: number;
+ playlist?: string;
+ channel?: string;
+ watch?: WatchTypes;
+ sort?: SortByType;
+ order?: SortOrderType;
+ type?: VideoTypes;
+};
+
+const loadVideoListByFilter = async (filter: FilterType) => {
+ const apiUrl = getApiUrl();
+
+ const searchParams = new URLSearchParams();
+
+ if (filter.page) {
+ searchParams.append('page', filter.page.toString());
+ }
+
+ if (filter.playlist) {
+ searchParams.append('playlist', filter.playlist);
+ } else if (filter.channel) {
+ searchParams.append('channel', filter.channel);
+ }
+
+ if (filter.watch) {
+ searchParams.append('watch', filter.watch);
+ }
+
+ if (filter.sort) {
+ searchParams.append('sort', filter.sort);
+ }
+
+ if (filter.order) {
+ searchParams.append('order', filter.order);
+ }
+
+ if (filter.type) {
+ searchParams.append('type', filter.type);
+ }
+
+ const response = await fetch(`${apiUrl}/api/video/?${searchParams.toString()}`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const videos = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadVideoListByFilter', filter, videos);
+ }
+
+ return videos;
+};
+
+export default loadVideoListByFilter;
diff --git a/frontend/src/api/loader/loadVideoNav.ts b/frontend/src/api/loader/loadVideoNav.ts
new file mode 100644
index 00000000..d65694a7
--- /dev/null
+++ b/frontend/src/api/loader/loadVideoNav.ts
@@ -0,0 +1,48 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+export type VideoNavResponseType = {
+ playlist_meta: {
+ current_idx: number;
+ playlist_id: string;
+ playlist_name: string;
+ playlist_channel: string;
+ };
+ playlist_previous: {
+ youtube_id: string;
+ title: string;
+ uploader: string;
+ idx: number;
+ downloaded: boolean;
+ vid_thumb: string;
+ };
+ playlist_next: {
+ youtube_id: string;
+ title: string;
+ uploader: string;
+ idx: number;
+ downloaded: boolean;
+ vid_thumb: string;
+ };
+};
+
+const loadVideoNav = async (youtubeVideoId: string): Promise => {
+ const apiUrl = getApiUrl();
+
+ const response = await fetch(`${apiUrl}/api/video/${youtubeVideoId}/nav/`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const videoNav = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadVideoNav', videoNav);
+ }
+
+ return videoNav;
+};
+
+export default loadVideoNav;
diff --git a/frontend/src/api/loader/loadVideoProgressById.ts b/frontend/src/api/loader/loadVideoProgressById.ts
new file mode 100644
index 00000000..a6774cc9
--- /dev/null
+++ b/frontend/src/api/loader/loadVideoProgressById.ts
@@ -0,0 +1,23 @@
+import defaultHeaders from '../../configuration/defaultHeaders';
+import getApiUrl from '../../configuration/getApiUrl';
+import getFetchCredentials from '../../configuration/getFetchCredentials';
+import isDevEnvironment from '../../functions/isDevEnvironment';
+
+const loadVideoProgressById = async (youtubeId: string) => {
+ const apiUrl = getApiUrl();
+
+ const response = await fetch(`${apiUrl}/api/video/${youtubeId}/progress/`, {
+ headers: defaultHeaders,
+ credentials: getFetchCredentials(),
+ });
+
+ const videoProgress = await response.json();
+
+ if (isDevEnvironment()) {
+ console.log('loadVideoProgressById', videoProgress);
+ }
+
+ return videoProgress;
+};
+
+export default loadVideoProgressById;
diff --git a/frontend/src/components/ApplicationStats.tsx b/frontend/src/components/ApplicationStats.tsx
new file mode 100644
index 00000000..eaddfbd9
--- /dev/null
+++ b/frontend/src/components/ApplicationStats.tsx
@@ -0,0 +1,57 @@
+import { Fragment } from 'react';
+import StatsInfoBoxItem from './StatsInfoBoxItem';
+import formatNumbers from '../functions/formatNumbers';
+import { ChannelStatsType, PlaylistStatsType, DownloadStatsType } from '../pages/SettingsDashboard';
+
+type ApplicationStatsProps = {
+ channelStats?: ChannelStatsType;
+ playlistStats?: PlaylistStatsType;
+ downloadStats?: DownloadStatsType;
+};
+
+const ApplicationStats = ({
+ channelStats,
+ playlistStats,
+ downloadStats,
+}: ApplicationStatsProps) => {
+ if (!channelStats || !playlistStats || !downloadStats) {
+ return Loading...
;
+ }
+
+ const cards = [
+ {
+ title: 'Channels: ',
+ data: {
+ Subscribed: formatNumbers(channelStats.subscribed_true || 0),
+ Active: formatNumbers(channelStats.active_true || 0),
+ Total: formatNumbers(channelStats.doc_count || 0),
+ },
+ },
+ {
+ title: 'Playlists: ',
+ data: {
+ Subscribed: formatNumbers(playlistStats.subscribed_true || 0),
+ Active: formatNumbers(playlistStats.active_true || 0),
+ Total: formatNumbers(playlistStats.doc_count || 0),
+ },
+ },
+ {
+ title: `Downloads Pending: ${downloadStats.pending || 0}`,
+ data: {
+ Videos: formatNumbers(downloadStats.pending_videos || 0),
+ Shorts: formatNumbers(downloadStats.pending_shorts || 0),
+ Streams: formatNumbers(downloadStats.pending_streams || 0),
+ },
+ },
+ ];
+
+ return cards.map(card => {
+ return (
+
+
+
+ );
+ });
+};
+
+export default ApplicationStats;
diff --git a/frontend/src/components/BiggestChannelsStats.tsx b/frontend/src/components/BiggestChannelsStats.tsx
new file mode 100644
index 00000000..6358ede5
--- /dev/null
+++ b/frontend/src/components/BiggestChannelsStats.tsx
@@ -0,0 +1,108 @@
+import humanFileSize from '../functions/humanFileSize';
+import formatNumbers from '../functions/formatNumbers';
+import { Link } from 'react-router-dom';
+import Routes from '../configuration/routes/RouteList';
+import { BiggestChannelsStatsType } from '../pages/SettingsDashboard';
+
+type BiggestChannelsStatsProps = {
+ biggestChannelsStatsByCount?: BiggestChannelsStatsType;
+ biggestChannelsStatsByDuration?: BiggestChannelsStatsType;
+ biggestChannelsStatsByMediaSize?: BiggestChannelsStatsType;
+ useSI: boolean;
+};
+
+const BiggestChannelsStats = ({
+ biggestChannelsStatsByCount,
+ biggestChannelsStatsByDuration,
+ biggestChannelsStatsByMediaSize,
+ useSI,
+}: BiggestChannelsStatsProps) => {
+ if (
+ !biggestChannelsStatsByCount &&
+ !biggestChannelsStatsByDuration &&
+ !biggestChannelsStatsByMediaSize
+ ) {
+ return Loading...
;
+ }
+
+ return (
+ <>
+
+
+
+
+ Name
+ Videos
+
+
+
+
+ {biggestChannelsStatsByCount &&
+ biggestChannelsStatsByCount.map(({ id, name, doc_count }) => {
+ return (
+
+
+ {name}
+
+ {formatNumbers(doc_count)}
+
+ );
+ })}
+
+
+
+
+
+
+
+
+ Name
+ Duration
+
+
+
+
+ {biggestChannelsStatsByDuration &&
+ biggestChannelsStatsByDuration.map(({ id, name, duration_str }) => {
+ return (
+
+
+ {name}
+
+ {duration_str}
+
+ );
+ })}
+
+
+
+
+
+
+
+
+ Name
+ Media Size
+
+
+
+
+ {biggestChannelsStatsByMediaSize &&
+ biggestChannelsStatsByMediaSize.map(({ id, name, media_size }) => {
+ return (
+
+
+ {name}
+
+ {humanFileSize(media_size, useSI)}
+
+ );
+ })}
+
+
+
+ >
+ );
+};
+
+export default BiggestChannelsStats;
diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx
new file mode 100644
index 00000000..3ba74841
--- /dev/null
+++ b/frontend/src/components/Button.tsx
@@ -0,0 +1,40 @@
+export interface ButtonProps {
+ id?: string;
+ name?: string;
+ className?: string;
+ type?: 'submit' | 'reset' | 'button' | undefined;
+ label?: string | JSX.Element | JSX.Element[];
+ children?: string | JSX.Element | JSX.Element[];
+ value?: string;
+ title?: string;
+ onClick?: () => void;
+}
+
+const Button = ({
+ id,
+ name,
+ className,
+ type,
+ label,
+ children,
+ value,
+ title,
+ onClick,
+}: ButtonProps) => {
+ return (
+
+ {label}
+ {children}
+
+ );
+};
+
+export default Button;
diff --git a/frontend/src/components/ChannelBanner.tsx b/frontend/src/components/ChannelBanner.tsx
new file mode 100644
index 00000000..49a09442
--- /dev/null
+++ b/frontend/src/components/ChannelBanner.tsx
@@ -0,0 +1,21 @@
+import getApiUrl from '../configuration/getApiUrl';
+import defaultChannelImage from '/img/default-channel-banner.jpg';
+
+type ChannelIconProps = {
+ channel_id: string;
+};
+
+const ChannelBanner = ({ channel_id }: ChannelIconProps) => {
+ return (
+ {
+ currentTarget.onerror = null; // prevents looping
+ currentTarget.src = defaultChannelImage;
+ }}
+ />
+ );
+};
+
+export default ChannelBanner;
diff --git a/frontend/src/components/ChannelIcon.tsx b/frontend/src/components/ChannelIcon.tsx
new file mode 100644
index 00000000..383cc5bb
--- /dev/null
+++ b/frontend/src/components/ChannelIcon.tsx
@@ -0,0 +1,21 @@
+import getApiUrl from '../configuration/getApiUrl';
+import defaultChannelIcon from '/img/default-channel-icon.jpg';
+
+type ChannelIconProps = {
+ channel_id: string;
+};
+
+const ChannelIcon = ({ channel_id }: ChannelIconProps) => {
+ return (
+ {
+ currentTarget.onerror = null; // prevents looping
+ currentTarget.src = defaultChannelIcon;
+ }}
+ />
+ );
+};
+
+export default ChannelIcon;
diff --git a/frontend/src/components/ChannelList.tsx b/frontend/src/components/ChannelList.tsx
new file mode 100644
index 00000000..b1a2ad07
--- /dev/null
+++ b/frontend/src/components/ChannelList.tsx
@@ -0,0 +1,83 @@
+import { Link } from 'react-router-dom';
+import { ChannelType } from '../pages/Channels';
+import { ViewLayoutType } from '../pages/Home';
+import Routes from '../configuration/routes/RouteList';
+import updateChannelSubscription from '../api/actions/updateChannelSubscription';
+import formatDate from '../functions/formatDates';
+import FormattedNumber from './FormattedNumber';
+import Button from './Button';
+import ChannelIcon from './ChannelIcon';
+import ChannelBanner from './ChannelBanner';
+
+type ChannelListProps = {
+ channelList: ChannelType[] | undefined;
+ viewLayout: ViewLayoutType;
+ refreshChannelList: (refresh: boolean) => void;
+};
+
+const ChannelList = ({ channelList, viewLayout, refreshChannelList }: ChannelListProps) => {
+ if (!channelList || channelList.length === 0) {
+ return No channels found.
;
+ }
+
+ return (
+ <>
+ {channelList.map(channel => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {channel.channel_name}
+
+
+
+
+
+
+
Last refreshed: {formatDate(channel.channel_last_refresh)}
+ {channel.channel_subscribed && (
+
{
+ await updateChannelSubscription(channel.channel_id, false);
+ refreshChannelList(true);
+ }}
+ />
+ )}
+ {!channel.channel_subscribed && (
+ {
+ await updateChannelSubscription(channel.channel_id, true);
+ refreshChannelList(true);
+ }}
+ />
+ )}
+
+
+
+
+ );
+ })}
+ >
+ );
+};
+
+export default ChannelList;
diff --git a/frontend/src/components/ChannelOverview.tsx b/frontend/src/components/ChannelOverview.tsx
new file mode 100644
index 00000000..c4c181dd
--- /dev/null
+++ b/frontend/src/components/ChannelOverview.tsx
@@ -0,0 +1,76 @@
+import { Link } from 'react-router-dom';
+import Routes from '../configuration/routes/RouteList';
+import updateChannelSubscription from '../api/actions/updateChannelSubscription';
+import FormattedNumber from './FormattedNumber';
+import Button from './Button';
+import ChannelIcon from './ChannelIcon';
+
+type ChannelOverviewProps = {
+ channelId: string;
+ channelname: string;
+ channelSubs: number;
+ channelSubscribed: boolean;
+ showSubscribeButton?: boolean;
+ isUserAdmin?: boolean;
+ setRefresh: (status: boolean) => void;
+};
+
+const ChannelOverview = ({
+ channelId,
+ channelSubs,
+ channelSubscribed,
+ channelname,
+ showSubscribeButton = false,
+ isUserAdmin,
+ setRefresh,
+}: ChannelOverviewProps) => {
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {channelname}
+
+
+
+
+ {showSubscribeButton && (
+ <>
+ {channelSubscribed && isUserAdmin && (
+ {
+ await updateChannelSubscription(channelId, false);
+ setRefresh(true);
+ }}
+ />
+ )}
+
+ {!channelSubscribed && (
+ {
+ await updateChannelSubscription(channelId, true);
+ setRefresh(true);
+ }}
+ />
+ )}
+ >
+ )}
+
+
+ >
+ );
+};
+
+export default ChannelOverview;
diff --git a/frontend/src/components/CommentBox.tsx b/frontend/src/components/CommentBox.tsx
new file mode 100644
index 00000000..2b0c61a7
--- /dev/null
+++ b/frontend/src/components/CommentBox.tsx
@@ -0,0 +1,106 @@
+import iconThumb from '/img/icon-thumb.svg';
+import iconHeart from '/img/icon-heart.svg';
+import formatDate from '../functions/formatDates';
+import { Fragment, useState } from 'react';
+import Linkify from './Linkify';
+import formatNumbers from '../functions/formatNumbers';
+import Button from './Button';
+
+export type CommentReplyType = {
+ comment_id: string;
+ comment_text: string;
+ comment_timestamp: number;
+ comment_time_text: string;
+ comment_likecount: number;
+ comment_is_favorited: false;
+ comment_author: string;
+ comment_author_id: string;
+ comment_author_thumbnail: string;
+ comment_author_is_uploader: boolean;
+ comment_parent: string;
+};
+
+export type CommentsType = {
+ comment_id: string;
+ comment_text: string;
+ comment_timestamp: number;
+ comment_time_text: string;
+ comment_likecount: number;
+ comment_is_favorited: boolean;
+ comment_author: string;
+ comment_author_id: string;
+ comment_author_thumbnail: string;
+ comment_author_is_uploader: boolean;
+ comment_parent: string;
+ comment_replies?: CommentReplyType[];
+};
+
+type CommentBoxProps = {
+ comment: CommentsType;
+};
+
+const CommentBox = ({ comment }: CommentBoxProps) => {
+ const [showSubComments, setShowSubComments] = useState(false);
+
+ const hasSubComments =
+ comment.comment_replies !== undefined && comment.comment_replies.length > 0;
+
+ return (
+
+
+ {comment.comment_author}
+
+
+ {comment.comment_text}
+
+
+
+
{formatDate(comment.comment_timestamp * 1000)}
+
+
|
+
+
+ {' '}
+ {formatNumbers(comment.comment_likecount, { notation: 'compact' })}
+
+
+ {comment.comment_is_favorited && (
+ <>
+
|
+
+
+
+ >
+ )}
+
+
+ {hasSubComments && (
+ <>
+
{
+ setShowSubComments(!showSubComments);
+ }}
+ >
+ <>
+ {showSubComments ? '▲' : '▼'} {' '}
+ {comment.comment_replies?.length} replies
+ >
+
+
+
+ {showSubComments &&
+ comment.comment_replies?.map(comment => {
+ return (
+
+
+
+ );
+ })}
+
+ >
+ )}
+
+ );
+};
+
+export default CommentBox;
diff --git a/frontend/src/components/DownloadHistoryStats.tsx b/frontend/src/components/DownloadHistoryStats.tsx
new file mode 100644
index 00000000..935fa314
--- /dev/null
+++ b/frontend/src/components/DownloadHistoryStats.tsx
@@ -0,0 +1,41 @@
+import humanFileSize from '../functions/humanFileSize';
+import formatDate from '../functions/formatDates';
+import formatNumbers from '../functions/formatNumbers';
+import { DownloadHistoryStatsType } from '../pages/SettingsDashboard';
+
+type DownloadHistoryStatsProps = {
+ downloadHistoryStats?: DownloadHistoryStatsType;
+ useSI: boolean;
+};
+
+const DownloadHistoryStats = ({ downloadHistoryStats, useSI }: DownloadHistoryStatsProps) => {
+ if (!downloadHistoryStats) {
+ return Loading...
;
+ }
+
+ if (downloadHistoryStats.length === 0) {
+ return (
+
+
No recent downloads
+
+ );
+ }
+
+ return downloadHistoryStats.map(({ date, count, media_size }) => {
+ const videoText = count === 1 ? 'Video' : 'Videos';
+ const intlDate = formatDate(date);
+
+ return (
+
+
{intlDate}
+
+ +{formatNumbers(count)} {videoText}
+
+ {humanFileSize(media_size, useSI)}
+
+
+ );
+ });
+};
+
+export default DownloadHistoryStats;
diff --git a/frontend/src/components/DownloadListItem.tsx b/frontend/src/components/DownloadListItem.tsx
new file mode 100644
index 00000000..16708923
--- /dev/null
+++ b/frontend/src/components/DownloadListItem.tsx
@@ -0,0 +1,108 @@
+import { Link } from 'react-router-dom';
+import Download from '../pages/Download';
+import Routes from '../configuration/routes/RouteList';
+import formatDate from '../functions/formatDates';
+import Button from './Button';
+import deleteDownloadById from '../api/actions/deleteDownloadById';
+import updateDownloadQueueStatusById from '../api/actions/updateDownloadQueueStatusById';
+import { useState } from 'react';
+import getApiUrl from '../configuration/getApiUrl';
+
+type DownloadListItemProps = {
+ view: string;
+ download: Download;
+ showIgnored: boolean;
+ setRefresh: (status: boolean) => void;
+};
+
+const DownloadListItem = ({ view, download, showIgnored, setRefresh }: DownloadListItemProps) => {
+ const [hideDownload, setHideDownload] = useState(false);
+
+ return (
+
+
+
+
+
+ {showIgnored && ignored }
+ {!showIgnored && queued }
+ {download.vid_type}
+ {download.auto_start && auto }
+
+
+
+
+
+ {download.channel_indexed && (
+
{download.channel_name}
+ )}
+ {!download.channel_indexed &&
{download.channel_name} }
+
+ {download.title}
+
+
+
+ Published: {formatDate(download.published)} | Duration: {download.duration} |{' '}
+ {download.youtube_id}
+
+ {download.message &&
{download.message}
}
+
+ {showIgnored && (
+ <>
+ {
+ await deleteDownloadById(download.youtube_id);
+ setRefresh(true);
+ }}
+ />{' '}
+ {
+ await updateDownloadQueueStatusById(download.youtube_id, 'pending');
+ setRefresh(true);
+ }}
+ />
+ >
+ )}
+ {!showIgnored && (
+ <>
+ {
+ await updateDownloadQueueStatusById(download.youtube_id, 'ignore');
+
+ setRefresh(true);
+ }}
+ />{' '}
+ {!hideDownload && (
+ {
+ setHideDownload(true);
+
+ await updateDownloadQueueStatusById(download.youtube_id, 'priority');
+
+ setRefresh(true);
+ }}
+ />
+ )}
+ >
+ )}
+ {download.message && (
+ {
+ await deleteDownloadById(download.youtube_id);
+ setRefresh(true);
+ }}
+ />
+ )}
+
+
+
+ );
+};
+
+export default DownloadListItem;
diff --git a/frontend/src/components/EmbeddableVideoPlayer.tsx b/frontend/src/components/EmbeddableVideoPlayer.tsx
new file mode 100644
index 00000000..e2c02140
--- /dev/null
+++ b/frontend/src/components/EmbeddableVideoPlayer.tsx
@@ -0,0 +1,178 @@
+import { useEffect, useState } from 'react';
+import { SponsorBlockType, VideoResponseType } from '../pages/Video';
+import VideoPlayer, { VideoProgressType } from './VideoPlayer';
+import loadVideoById from '../api/loader/loadVideoById';
+import loadVideoProgressById from '../api/loader/loadVideoProgressById';
+import loadSponsorblockByVideoId from '../api/loader/loadSponsorblockByVideoId';
+import iconClose from '/img/icon-close.svg';
+import iconEye from '/img/icon-eye.svg';
+import iconThumb from '/img/icon-thumb.svg';
+import WatchedCheckBox from './WatchedCheckBox';
+import GoogleCast from './GoogleCast';
+import updateWatchedState from '../api/actions/updateWatchedState';
+import formatNumbers from '../functions/formatNumbers';
+import { Link, useSearchParams } from 'react-router-dom';
+import Routes from '../configuration/routes/RouteList';
+import loadPlaylistById from '../api/loader/loadPlaylistById';
+
+type Playlist = {
+ id: string;
+ name: string;
+};
+type PlaylistList = Playlist[];
+
+type EmbeddableVideoPlayerProps = {
+ videoId: string;
+};
+
+const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => {
+ const [, setSearchParams] = useSearchParams();
+
+ const [refresh, setRefresh] = useState(false);
+
+ const [videoResponse, setVideoResponse] = useState();
+ const [videoProgress, setVideoProgress] = useState();
+ const [playlists, setPlaylists] = useState();
+ const [sponsorblockResponse, setSponsorblockResponse] = useState();
+
+ useEffect(() => {
+ (async () => {
+ const videoResponse = await loadVideoById(videoId);
+ const videoProgress = await loadVideoProgressById(videoId);
+ const sponsorblockReponse = await loadSponsorblockByVideoId(videoId);
+
+ const playlistIds = videoResponse.data.playlist;
+ if (playlistIds !== undefined) {
+ const playlists = await Promise.all(
+ playlistIds.map(async (playlistid: string) => {
+ const playlistResponse = await loadPlaylistById(playlistid);
+
+ return playlistResponse.data;
+ }),
+ );
+
+ const playlistsFiltered = playlists
+ .filter(playlist => {
+ return playlist.playlist_subscribed;
+ })
+ .map(playlist => {
+ return {
+ id: playlist.playlist_id,
+ name: playlist.playlist_name,
+ };
+ });
+
+ setPlaylists(playlistsFiltered);
+ }
+
+ setVideoResponse(videoResponse);
+ setVideoProgress(videoProgress);
+ setSponsorblockResponse(sponsorblockReponse);
+
+ setRefresh(false);
+ })();
+ }, [videoId, refresh]);
+
+ if (videoResponse === undefined) {
+ return [];
+ }
+
+ const video = videoResponse.data;
+ const name = video.title;
+ const channelId = video.channel.channel_id;
+ const channelName = video.channel.channel_name;
+ const watched = video.player.watched;
+ const views = formatNumbers(video.stats.view_count);
+ const hasLikes = video.stats.like_count;
+ const likes = formatNumbers(video.stats.like_count);
+ const hasDislikes = video.stats.dislike_count > 0 && videoResponse.config.downloads.integrate_ryd;
+ const dislikes = formatNumbers(video.stats.dislike_count);
+ const config = videoResponse.config;
+ const cast = config.enable_cast;
+
+ return (
+ <>
+
+
+
+
+
+
{
+ setSearchParams({});
+ }}
+ />
+
{
+ await updateWatchedState({
+ id: videoId,
+ is_watched: status,
+ });
+
+ setRefresh(true);
+ }}
+ />
+ {cast && (
+ {
+ setRefresh(true);
+ }}
+ />
+ )}
+
+
+
+
{views}
+ {hasLikes && (
+ <>
+
|
+
+
{likes}
+ >
+ )}
+ {hasDislikes && (
+ <>
+
|
+
+
{dislikes}
+ >
+ )}
+
+
+
+
+ {channelName}
+
+
+ {playlists?.map(({ id, name }) => {
+ return (
+
+ {name}
+
+ );
+ })}
+
+
+
+ {name}
+
+
+
+
+ >
+ );
+};
+
+export default EmbeddableVideoPlayer;
diff --git a/frontend/src/components/Filterbar.tsx b/frontend/src/components/Filterbar.tsx
new file mode 100644
index 00000000..fc40537e
--- /dev/null
+++ b/frontend/src/components/Filterbar.tsx
@@ -0,0 +1,186 @@
+import { useEffect } from 'react';
+import iconSort from '/img/icon-sort.svg';
+import iconAdd from '/img/icon-add.svg';
+import iconSubstract from '/img/icon-substract.svg';
+import iconGridView from '/img/icon-gridview.svg';
+import iconListView from '/img/icon-listview.svg';
+import { SortByType, SortOrderType, ViewLayoutType } from '../pages/Home';
+import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig';
+
+type FilterbarProps = {
+ hideToggleText: string;
+ showHidden?: boolean;
+ hideWatched?: boolean;
+ isGridView?: boolean;
+ view: ViewLayoutType;
+ viewStyleName: string;
+ gridItems: number;
+ sortBy?: SortByType;
+ sortOrder?: SortOrderType;
+ userMeConfig: UserConfigType;
+ setShowHidden?: (showHidden: boolean) => void;
+ setHideWatched?: (hideWatched: boolean) => void;
+ setView: (view: ViewLayoutType) => void;
+ setSortBy?: (sortBy: SortByType) => void;
+ setSortOrder?: (sortOrder: SortOrderType) => void;
+ setGridItems: (gridItems: number) => void;
+ setRefresh?: (status: boolean) => void;
+};
+
+const Filterbar = ({
+ hideToggleText,
+ showHidden,
+ hideWatched,
+ isGridView,
+ view,
+ viewStyleName,
+ gridItems,
+ sortBy,
+ sortOrder,
+ userMeConfig,
+ setShowHidden,
+ setHideWatched,
+ setView,
+ setSortBy,
+ setSortOrder,
+ setGridItems,
+ setRefresh,
+}: FilterbarProps) => {
+ useEffect(() => {
+ (async () => {
+ if (
+ userMeConfig.hide_watched !== hideWatched ||
+ userMeConfig[viewStyleName.toString() as keyof typeof userMeConfig] !== view ||
+ userMeConfig.grid_items !== gridItems ||
+ userMeConfig.sort_by !== sortBy ||
+ userMeConfig.sort_order !== sortOrder
+ ) {
+ const userConfig: UserConfigType = {
+ hide_watched: hideWatched,
+ [viewStyleName.toString()]: view,
+ grid_items: gridItems,
+ sort_by: sortBy,
+ sort_order: sortOrder,
+ };
+
+ await updateUserConfig(userConfig);
+ setRefresh?.(true);
+ }
+ })();
+ }, [hideWatched, view, gridItems, sortBy, sortOrder, viewStyleName, setRefresh, userMeConfig]);
+
+ return (
+
+
+
{hideToggleText}
+
+ {
+ setHideWatched?.(!hideWatched);
+ }}
+ />
+
+ {!hideWatched && (
+
+ Off
+
+ )}
+ {hideWatched && (
+
+ On
+
+ )}
+
+
+
+ {showHidden && (
+
+
+ Sort by:
+ {
+ setSortBy?.(event.target.value as SortByType);
+ }}
+ >
+ date published
+ date downloaded
+ views
+ likes
+ duration
+ file size
+
+ {
+ setSortOrder?.(event.target.value as SortOrderType);
+ }}
+ >
+ asc
+ desc
+
+
+
+ )}
+
+
+ {setShowHidden && (
+
{
+ setShowHidden?.(!showHidden);
+ }}
+ id="animate-icon"
+ />
+ )}
+
+ {isGridView && (
+
+ {gridItems < 7 && (
+
{
+ setGridItems(gridItems + 1);
+ }}
+ alt="grid plus row"
+ />
+ )}
+ {gridItems > 3 && (
+
{
+ setGridItems(gridItems - 1);
+ }}
+ alt="grid minus row"
+ />
+ )}
+
+ )}
+
{
+ setView('grid');
+ }}
+ alt="grid view"
+ />
+
{
+ setView('list');
+ }}
+ alt="list view"
+ />
+
+
+ );
+};
+
+export default Filterbar;
diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx
new file mode 100644
index 00000000..54a24c62
--- /dev/null
+++ b/frontend/src/components/Footer.tsx
@@ -0,0 +1,60 @@
+import { Link } from 'react-router-dom';
+import Routes from '../configuration/routes/RouteList';
+
+export type TaUpdateType = {
+ version?: string;
+ is_breaking?: boolean;
+};
+
+interface Props {
+ version: string;
+ taUpdate?: TaUpdateType;
+}
+
+const Footer = ({ version, taUpdate }: Props) => {
+ const currentYear = new Date().getFullYear();
+
+ return (
+
+
+
© 2021 - {currentYear}
+
TubeArchivist
+
{version}
+ {taUpdate?.version && (
+ <>
+
+ {taUpdate.version} available
+ {taUpdate.is_breaking && Breaking Changes! }
+ {' '}
+
+
+ Release Page
+ {' '}
+ |{' '}
+
+ >
+ )}
+
+ About |{' '}
+
+ GitHub
+ {' '}
+ |{' '}
+
+ Docker Hub
+ {' '}
+ |{' '}
+
+ Discord
+ {' '}
+ | Reddit
+
+
+
+ );
+};
+
+export default Footer;
diff --git a/frontend/src/components/FormattedNumber.tsx b/frontend/src/components/FormattedNumber.tsx
new file mode 100644
index 00000000..92d437d5
--- /dev/null
+++ b/frontend/src/components/FormattedNumber.tsx
@@ -0,0 +1,27 @@
+import formatNumbers from '../functions/formatNumbers';
+
+type FormattedNumberProps = {
+ text: string;
+ number: number;
+};
+
+const FormattedNumber = ({ text, number }: FormattedNumberProps) => {
+ let options = {};
+
+ if (number >= 1000000) {
+ options = {
+ notation: 'compact',
+ compactDisplay: 'long',
+ };
+ }
+
+ return (
+ <>
+
+ {text} {formatNumbers(number, options)}
+
+ >
+ );
+};
+
+export default FormattedNumber;
diff --git a/frontend/src/components/GoogleCast.tsx b/frontend/src/components/GoogleCast.tsx
new file mode 100644
index 00000000..55a0680b
--- /dev/null
+++ b/frontend/src/components/GoogleCast.tsx
@@ -0,0 +1,230 @@
+import { useCallback, useEffect, useState } from 'react';
+import { Helmet } from 'react-helmet';
+import { VideoType } from '../pages/Home';
+import updateWatchedState from '../api/actions/updateWatchedState';
+import updateVideoProgressById from '../api/actions/updateVideoProgressById';
+import watchedThreshold from '../functions/watchedThreshold';
+import { VideoProgressType } from './VideoPlayer';
+
+const getURL = () => {
+ return window.location.origin;
+};
+
+function shiftCurrentTime(contentCurrentTime: number | undefined) {
+ console.log(contentCurrentTime);
+ if (contentCurrentTime === undefined) {
+ return 0;
+ }
+
+ // Shift media back 3 seconds to prevent missing some of the content
+ if (contentCurrentTime > 5) {
+ return contentCurrentTime - 3;
+ } else {
+ return 0;
+ }
+}
+
+async function castVideoProgress(
+ player: {
+ mediaInfo: { contentId: string | string[] };
+ currentTime: number;
+ duration: number;
+ },
+ video: VideoType | undefined,
+) {
+ if (!video) {
+ console.log('castVideoProgress: Video to cast not found...');
+ return;
+ }
+ const videoId = video.youtube_id;
+
+ if (player.mediaInfo.contentId.includes(videoId)) {
+ const currentTime = player.currentTime;
+ const duration = player.duration;
+
+ if (currentTime % 10 <= 1.0 && currentTime !== 0 && duration !== 0) {
+ // Check progress every 10 seconds or else progress is checked a few times a second
+ await updateVideoProgressById({
+ youtubeId: videoId,
+ currentProgress: currentTime,
+ });
+
+ if (!video.player.watched) {
+ // Check if video is already marked as watched
+ if (watchedThreshold(currentTime, duration)) {
+ await updateWatchedState({
+ id: videoId,
+ is_watched: true,
+ });
+ }
+ }
+ }
+ }
+}
+
+async function castVideoPaused(
+ player: {
+ currentTime: number;
+ duration: number;
+ mediaInfo: { contentId: string | string[] } | null;
+ },
+ video: VideoType | undefined,
+) {
+ if (!video) {
+ console.log('castVideoPaused: Video to cast not found...');
+ return;
+ }
+
+ const videoId = video?.youtube_id;
+
+ const currentTime = player.currentTime;
+ const duration = player.duration;
+
+ if (player.mediaInfo != null) {
+ if (player.mediaInfo.contentId.includes(videoId)) {
+ if (currentTime !== 0 && duration !== 0) {
+ await updateVideoProgressById({
+ youtubeId: videoId,
+ currentProgress: currentTime,
+ });
+ }
+ }
+ }
+}
+
+type GoogleCastProps = {
+ video?: VideoType;
+ videoProgress?: VideoProgressType;
+ setRefresh?: () => void;
+};
+
+const GoogleCast = ({ video, videoProgress, setRefresh }: GoogleCastProps) => {
+ const [isConnected, setIsConnected] = useState(false);
+
+ const setup = useCallback(() => {
+ const cast = globalThis.cast;
+ const chrome = globalThis.chrome;
+
+ cast.framework.CastContext.getInstance().setOptions({
+ receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, // Use built in receiver app on cast device, see https://developers.google.com/cast/docs/styled_receiver if you want to be able to add a theme, splash screen or watermark. Has a $5 one time fee.
+ autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
+ });
+
+ const player = new cast.framework.RemotePlayer();
+
+ const playerController = new cast.framework.RemotePlayerController(player);
+
+ // Add event listerner to check if a connection to a cast device is initiated
+ playerController.addEventListener(
+ cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
+ function () {
+ setIsConnected(player.isConnected);
+ },
+ );
+ playerController.addEventListener(
+ cast.framework.RemotePlayerEventType.CURRENT_TIME_CHANGED,
+ function () {
+ castVideoProgress(player, video);
+ },
+ );
+ playerController.addEventListener(
+ cast.framework.RemotePlayerEventType.IS_PAUSED_CHANGED,
+ function () {
+ castVideoPaused(player, video);
+ setRefresh?.();
+ },
+ );
+ }, [setRefresh, video]);
+
+ const startPlayback = useCallback(() => {
+ const chrome = globalThis.chrome;
+ const cast = globalThis.cast;
+ const castSession = cast.framework.CastContext.getInstance().getCurrentSession();
+
+ const mediaUrl = video?.media_url;
+ const vidThumbUrl = video?.vid_thumb_url;
+ const contentTitle = video?.title;
+ const contentId = `${getURL()}${mediaUrl}`;
+ const contentImage = `${getURL()}${vidThumbUrl}`;
+ const contentType = 'video/mp4'; // Set content type, only videos right now so it is hard coded
+
+ const contentSubtitles = [];
+ const videoSubtitles = video?.subtitles; // Array of subtitles
+ if (typeof videoSubtitles !== 'undefined') {
+ for (let i = 0; i < videoSubtitles.length; i++) {
+ const subtitle = new chrome.cast.media.Track(i, chrome.cast.media.TrackType.TEXT);
+
+ subtitle.trackContentId = videoSubtitles[i].media_url;
+ subtitle.trackContentType = 'text/vtt';
+ subtitle.subtype = chrome.cast.media.TextTrackType.SUBTITLES;
+ subtitle.name = videoSubtitles[i].name;
+ subtitle.language = videoSubtitles[i].lang;
+ subtitle.customData = null;
+
+ contentSubtitles.push(subtitle);
+ }
+ }
+
+ const mediaInfo = new chrome.cast.media.MediaInfo(contentId, contentType); // Create MediaInfo var that contains url and content type
+ // mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED; // Set type of stream, BUFFERED, LIVE, OTHER
+ mediaInfo.metadata = new chrome.cast.media.GenericMediaMetadata(); // Create metadata var and add it to MediaInfo
+ mediaInfo.metadata.title = contentTitle?.replace('&', '&'); // Set the video title
+ mediaInfo.metadata.images = [new chrome.cast.Image(contentImage)]; // Set the video thumbnail
+ // mediaInfo.textTrackStyle = new chrome.cast.media.TextTrackStyle();
+ mediaInfo.tracks = contentSubtitles;
+
+ const request = new chrome.cast.media.LoadRequest(mediaInfo); // Create request with the previously set MediaInfo.
+ // request.queueData = new chrome.cast.media.QueueData(); // See https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.media.QueueData for playlist support.
+ request.currentTime = shiftCurrentTime(videoProgress?.position); // Set video start position based on the browser video position
+ // request.activeTrackIds = contentActiveSubtitle; // Set active subtitle based on video player
+
+ castSession.loadMedia(request).then(
+ function () {
+ console.log('media loaded');
+ },
+ function (error: { code: string }) {
+ console.log('Error', error, 'Error code: ' + error.code);
+ },
+ ); // Send request to cast device
+
+ // Do not add videoProgress?.position, this will cause loops!
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [video?.media_url, video?.subtitles, video?.title, video?.vid_thumb_url]);
+
+ useEffect(() => {
+ // @ts-expect-error __onGCastApiAvailable is the google cast window hook ( source: https://developers.google.com/cast/docs/web_sender/integrate )
+ window['__onGCastApiAvailable'] = function (isAvailable: boolean) {
+ if (isAvailable) {
+ setup();
+ }
+ };
+ }, [setup]);
+
+ useEffect(() => {
+ console.log('isConnected', isConnected);
+ if (isConnected) {
+ startPlayback();
+ }
+ }, [isConnected, startPlayback]);
+
+ if (!video) {
+ return Video for cast not found...
;
+ }
+
+ return (
+ <>
+ <>
+
+
+
+ {/* @ts-expect-error React does not know what to do with the google-cast-launcher, but it works. */}
+
+ >
+ >
+ );
+};
+
+export default GoogleCast;
diff --git a/frontend/src/components/Linkify.tsx b/frontend/src/components/Linkify.tsx
new file mode 100644
index 00000000..a9d1d839
--- /dev/null
+++ b/frontend/src/components/Linkify.tsx
@@ -0,0 +1,34 @@
+import DOMPurify from 'dompurify';
+
+type LinkifyProps = {
+ children: string;
+ ignoreLineBreak?: boolean;
+};
+
+// source: https://www.js-craft.io/blog/react-detect-url-text-convert-link/
+const Linkify = ({ children, ignoreLineBreak = false }: LinkifyProps) => {
+ const isUrl = (word: string) => {
+ const urlPattern = /(https?:\/\/[^\s]+)/g;
+ return word.match(urlPattern);
+ };
+
+ const addMarkup = (word: string) => {
+ return isUrl(word) ? `${word} ` : word;
+ };
+
+ let workingText = children;
+
+ if (!ignoreLineBreak) {
+ workingText = workingText.replaceAll('\n', ' ');
+ }
+
+ const words = workingText.split(' ');
+
+ const formatedWords = words.map(w => addMarkup(w));
+
+ const html = DOMPurify.sanitize(formatedWords.join(' '));
+
+ return ;
+};
+
+export default Linkify;
diff --git a/frontend/src/components/MoveVideoMenu.tsx b/frontend/src/components/MoveVideoMenu.tsx
new file mode 100644
index 00000000..4853cc1b
--- /dev/null
+++ b/frontend/src/components/MoveVideoMenu.tsx
@@ -0,0 +1,94 @@
+import iconClose from '/img/icon-close.svg';
+import iconArrowTop from '/img/icon-arrow-top.svg';
+import iconArrowUp from '/img/icon-arrow-up.svg';
+import iconArrowDown from '/img/icon-arrow-down.svg';
+import iconArrowBottom from '/img/icon-arrow-bottom.svg';
+import iconRemove from '/img/icon-remove.svg';
+import updateCustomPlaylist from '../api/actions/updateCustomPlaylist';
+
+type MoveVideoMenuProps = {
+ playlistId?: string;
+ videoId: string;
+ setCloseMenu: (status: boolean) => void;
+ setRefresh: (status: boolean) => void;
+};
+
+const MoveVideoMenu = ({ playlistId, videoId, setCloseMenu, setRefresh }: MoveVideoMenuProps) => {
+ if (playlistId === undefined) {
+ return [];
+ }
+
+ return (
+ <>
+
+
setCloseMenu(true)}
+ />
+
Move Video
+
+
{
+ await updateCustomPlaylist('top', playlistId, videoId);
+
+ setRefresh(true);
+ }}
+ src={iconArrowTop}
+ title="Move to top"
+ />
+
{
+ await updateCustomPlaylist('up', playlistId, videoId);
+
+ setRefresh(true);
+ }}
+ src={iconArrowUp}
+ title="Move up"
+ />
+
{
+ await updateCustomPlaylist('down', playlistId, videoId);
+
+ setRefresh(true);
+ }}
+ src={iconArrowDown}
+ title="Move down"
+ />
+
+
{
+ await updateCustomPlaylist('bottom', playlistId, videoId);
+
+ setRefresh(true);
+ }}
+ src={iconArrowBottom}
+ title="Move to bottom"
+ />
+
+
{
+ await updateCustomPlaylist('remove', playlistId, videoId);
+
+ setRefresh(true);
+ }}
+ src={iconRemove}
+ title="Remove from playlist"
+ />
+
+ >
+ );
+};
+
+export default MoveVideoMenu;
diff --git a/frontend/src/components/Navigation.tsx b/frontend/src/components/Navigation.tsx
new file mode 100644
index 00000000..91a0a4b0
--- /dev/null
+++ b/frontend/src/components/Navigation.tsx
@@ -0,0 +1,42 @@
+import { Link } from 'react-router-dom';
+import iconSearch from '/img/icon-search.svg';
+import iconGear from '/img/icon-gear.svg';
+import iconExit from '/img/icon-exit.svg';
+import Routes from '../configuration/routes/RouteList';
+import NavigationItem from './NavigationItem';
+
+interface NavigationProps {
+ isAdmin: boolean;
+}
+
+const Navigation = ({ isAdmin }: NavigationProps) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ {isAdmin && }
+
+
+
+
+ );
+};
+
+export default Navigation;
diff --git a/frontend/src/components/NavigationItem.tsx b/frontend/src/components/NavigationItem.tsx
new file mode 100644
index 00000000..8f8e8ceb
--- /dev/null
+++ b/frontend/src/components/NavigationItem.tsx
@@ -0,0 +1,16 @@
+import { Link } from 'react-router-dom';
+
+interface NavigationItemProps {
+ navigateTo: string;
+ label: string;
+}
+
+const NavigationItem = ({ label, navigateTo }: NavigationItemProps) => {
+ return (
+
+ {label}
+
+ );
+};
+
+export default NavigationItem;
diff --git a/frontend/src/components/Notifications.tsx b/frontend/src/components/Notifications.tsx
new file mode 100644
index 00000000..e05037f8
--- /dev/null
+++ b/frontend/src/components/Notifications.tsx
@@ -0,0 +1,101 @@
+import { Fragment, useEffect, useState } from 'react';
+import loadNotifications, { NotificationPages } from '../api/loader/loadNotifications';
+import iconStop from '/img/icon-stop.svg';
+import stopTaskByName from '../api/actions/stopTaskByName';
+
+type NotificationType = {
+ title: string;
+ group: string;
+ api_stop: boolean;
+ level: string;
+ id: string;
+ command: boolean | string;
+ messages: string[];
+ progress: number;
+};
+
+type NotificationResponseType = NotificationType[];
+
+type NotificationsProps = {
+ pageName: NotificationPages;
+ includeReindex?: boolean;
+ update?: boolean;
+ setShouldRefresh?: (isDone: boolean) => void;
+};
+
+const Notifications = ({
+ pageName,
+ includeReindex = false,
+ update,
+ setShouldRefresh,
+}: NotificationsProps) => {
+ const [notificationResponse, setNotificationResponse] = useState([]);
+
+ useEffect(() => {
+ const intervalId = setInterval(async () => {
+ const notifications = await loadNotifications(pageName, includeReindex);
+
+ if (notifications.length === 0) {
+ setNotificationResponse(notifications);
+ clearInterval(intervalId);
+ setShouldRefresh?.(true);
+ return;
+ } else {
+ setShouldRefresh?.(false);
+ }
+
+ setNotificationResponse(notifications);
+ }, 500);
+
+ return () => {
+ clearInterval(intervalId);
+ };
+ }, [pageName, update, setShouldRefresh, includeReindex]);
+
+ if (notificationResponse.length === 0) {
+ return [];
+ }
+
+ return (
+ <>
+ {notificationResponse.map(notification => (
+
+
{notification.title}
+
+ {notification.messages.map(message => {
+ return (
+
+ {message}
+
+
+ );
+ })}
+
+
+ {notification['api_stop'] && notification.command !== 'STOP' && (
+
{
+ await stopTaskByName(notification.id);
+ }}
+ />
+ )}
+
+
+
+ ))}
+ >
+ );
+};
+
+export default Notifications;
diff --git a/frontend/src/components/OverviewStats.tsx b/frontend/src/components/OverviewStats.tsx
new file mode 100644
index 00000000..deced848
--- /dev/null
+++ b/frontend/src/components/OverviewStats.tsx
@@ -0,0 +1,53 @@
+import { Fragment } from 'react';
+import humanFileSize from '../functions/humanFileSize';
+import StatsInfoBoxItem from './StatsInfoBoxItem';
+import formatNumbers from '../functions/formatNumbers';
+import { VideoStatsType } from '../pages/SettingsDashboard';
+
+type OverviewStatsProps = {
+ videoStats?: VideoStatsType;
+ useSI: boolean;
+};
+
+const OverviewStats = ({ videoStats, useSI }: OverviewStatsProps) => {
+ if (!videoStats) {
+ return Loading...
;
+ }
+
+ const cards = [
+ {
+ title: 'All: ',
+ data: {
+ Videos: formatNumbers(videoStats?.doc_count || 0),
+ ['Media Size']: humanFileSize(videoStats?.media_size || 0, useSI),
+ Duration: videoStats?.duration_str,
+ },
+ },
+ {
+ title: 'Active: ',
+ data: {
+ Videos: formatNumbers(videoStats?.active_true?.doc_count || 0),
+ ['Media Size']: humanFileSize(videoStats?.active_true?.media_size || 0, useSI),
+ Duration: videoStats?.active_true?.duration_str || 'NA',
+ },
+ },
+ {
+ title: 'Inactive: ',
+ data: {
+ Videos: formatNumbers(videoStats?.active_false?.doc_count || 0),
+ ['Media Size']: humanFileSize(videoStats?.active_false?.media_size || 0, useSI),
+ Duration: videoStats?.active_false?.duration_str || 'NA',
+ },
+ },
+ ];
+
+ return cards.map(card => {
+ return (
+
+
+
+ );
+ });
+};
+
+export default OverviewStats;
diff --git a/frontend/src/components/Pagination.tsx b/frontend/src/components/Pagination.tsx
new file mode 100644
index 00000000..979a92cd
--- /dev/null
+++ b/frontend/src/components/Pagination.tsx
@@ -0,0 +1,213 @@
+import { Link } from 'react-router-dom';
+import { Fragment } from 'react/jsx-runtime';
+import Routes from '../configuration/routes/RouteList';
+import { useCallback, useEffect } from 'react';
+
+export type PaginationType = {
+ page_size?: number;
+ page_from?: number;
+ prev_pages?: false | number[];
+ current_page: number;
+ max_hits?: boolean;
+ params?: string;
+ last_page?: number;
+ next_pages?: [];
+ total_hits?: number;
+};
+
+interface Props {
+ pagination: PaginationType;
+ setPage: (page: number) => void;
+}
+
+const Pagination = ({ pagination, setPage }: Props) => {
+ const { total_hits, params, prev_pages, current_page, next_pages, last_page, max_hits } =
+ pagination;
+
+ const totalHits = Number(total_hits);
+ const currentPage = Number(current_page);
+ const hasMaxHits = Number(max_hits) > 0;
+ const lastPage = Number(last_page);
+
+ let hasParams = false;
+
+ if (params) {
+ hasParams = params.length > 0;
+ }
+
+ const handleKeyEvent = useCallback(
+ (event: KeyboardEvent) => {
+ const { code } = event;
+
+ if (code === 'ArrowRight') {
+ if (currentPage === 0 && totalHits > 1) {
+ setPage(2);
+ return;
+ }
+
+ if (currentPage > lastPage) {
+ return;
+ }
+
+ setPage(currentPage + 1);
+ }
+
+ if (code === 'ArrowLeft') {
+ if (currentPage === 0) {
+ return;
+ }
+
+ if (currentPage === 2) {
+ setPage(0);
+ return;
+ }
+
+ setPage(currentPage - 1);
+ }
+ },
+ [currentPage, lastPage, setPage, totalHits],
+ );
+
+ useEffect(() => {
+ window.addEventListener('keydown', handleKeyEvent);
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyEvent);
+ };
+ }, [handleKeyEvent]);
+
+ return (
+
+ );
+};
+
+export default Pagination;
diff --git a/frontend/src/components/PaginationDummy.tsx b/frontend/src/components/PaginationDummy.tsx
new file mode 100644
index 00000000..e6336e19
--- /dev/null
+++ b/frontend/src/components/PaginationDummy.tsx
@@ -0,0 +1,9 @@
+const PaginationDummy = () => {
+ return (
+
+
{/** dummy pagination for padding */}
+
+ );
+};
+
+export default PaginationDummy;
diff --git a/frontend/src/components/PlaylistList.tsx b/frontend/src/components/PlaylistList.tsx
new file mode 100644
index 00000000..04fe4db1
--- /dev/null
+++ b/frontend/src/components/PlaylistList.tsx
@@ -0,0 +1,85 @@
+import { Link } from 'react-router-dom';
+import Routes from '../configuration/routes/RouteList';
+import { ViewLayoutType } from '../pages/Home';
+import { PlaylistType } from '../pages/Playlist';
+import updatePlaylistSubscription from '../api/actions/updatePlaylistSubscription';
+import formatDate from '../functions/formatDates';
+import Button from './Button';
+import getApiUrl from '../configuration/getApiUrl';
+
+type PlaylistListProps = {
+ playlistList: PlaylistType[] | undefined;
+ viewLayout: ViewLayoutType;
+ setRefresh: (status: boolean) => void;
+};
+
+const PlaylistList = ({ playlistList, viewLayout, setRefresh }: PlaylistListProps) => {
+ if (!playlistList || playlistList.length === 0) {
+ return No playlists found.
;
+ }
+
+ return (
+ <>
+ {playlistList.map((playlist: PlaylistType) => {
+ return (
+
+
+
+
+
+
+
+ {playlist.playlist_type != 'custom' && (
+
+
{playlist.playlist_channel}
+
+ )}
+
+
+
{playlist.playlist_name}
+
+
+
Last refreshed: {formatDate(playlist.playlist_last_refresh)}
+
+ {playlist.playlist_type != 'custom' && (
+ <>
+ {playlist.playlist_subscribed && (
+
{
+ await updatePlaylistSubscription(playlist.playlist_id, false);
+
+ setRefresh(true);
+ }}
+ />
+ )}
+
+ {!playlist.playlist_subscribed && (
+ {
+ await updatePlaylistSubscription(playlist.playlist_id, true);
+
+ setRefresh(true);
+ }}
+ />
+ )}
+ >
+ )}
+
+
+ );
+ })}
+ >
+ );
+};
+
+export default PlaylistList;
diff --git a/frontend/src/components/ScrollToTop.tsx b/frontend/src/components/ScrollToTop.tsx
new file mode 100644
index 00000000..91c71768
--- /dev/null
+++ b/frontend/src/components/ScrollToTop.tsx
@@ -0,0 +1,17 @@
+import { useEffect } from 'react';
+import { useLocation, useSearchParams } from 'react-router-dom';
+
+const ScrollToTopOnNavigate = () => {
+ const { pathname } = useLocation();
+ const [searchParams] = useSearchParams();
+
+ const page = searchParams.get('page');
+
+ useEffect(() => {
+ window.scrollTo(0, 0);
+ }, [pathname, page]);
+
+ return null;
+};
+
+export default ScrollToTopOnNavigate;
diff --git a/frontend/src/components/SearchExampleQueries.tsx b/frontend/src/components/SearchExampleQueries.tsx
new file mode 100644
index 00000000..2e17792e
--- /dev/null
+++ b/frontend/src/components/SearchExampleQueries.tsx
@@ -0,0 +1,116 @@
+const SearchExampleQueries = () => {
+ return (
+
+
+
Example queries
+
+
+ music video — basic search
+
+
+ video: active:
+ no — all videos deleted from YouTube
+
+
+ video:
+ learn javascript
+ channel:
+ corey schafer
+ active:
+ yes
+
+
+ channel:
+ linux
+ subscribed:
+ yes
+
+
+ playlist:
+ backend engineering
+ active:
+ yes
+ subscribed:
+ yes
+
+
+
+
+
Keywords cheatsheet
+
+ For detailed usage check{' '}
+
+ wiki
+
+ .
+
+
+
+
+ simple: (implied) — search in video titles, channel names and playlist
+ titles
+
+
+ video: — search in video titles, tags and category field
+
+
+ channel: — channel name
+
+
+ active:
+ yes/no — whether the video is still active on
+ YouTube
+
+
+
+
+ channel: — search in channel name and channel description
+
+
+ subscribed:
+ yes/no — whether you are subscribed to the channel
+
+
+ active:
+ yes/no — whether the video is still active on
+ YouTube
+
+
+
+
+ playlist: — search in channel name and channel description
+
+
+ subscribed:
+ yes/no — whether you are subscribed to the channel
+
+
+ active:
+ yes/no — whether the video is still active on
+ YouTube
+
+
+
+
+ full: — search in video subtitles
+
+
+ lang: — subtitles language (use two-letter ISO country code, same as
+ the one from settings page)
+
+
+ source:
+ auto/user — auto to search though
+ auto-generated subtitles only, or user to search through user-uploaded
+ subtitles only
+
+
+
+
+
+
+
+ );
+};
+
+export default SearchExampleQueries;
diff --git a/frontend/src/components/SettingsNavigation.tsx b/frontend/src/components/SettingsNavigation.tsx
new file mode 100644
index 00000000..6541b4ce
--- /dev/null
+++ b/frontend/src/components/SettingsNavigation.tsx
@@ -0,0 +1,36 @@
+import { Link, useOutletContext } from 'react-router-dom';
+import Routes from '../configuration/routes/RouteList';
+import { OutletContextType } from '../pages/Base';
+
+const SettingsNavigation = () => {
+ const { isAdmin } = useOutletContext() as OutletContextType;
+
+ return (
+ <>
+
+
+
Dashboard
+
+
+ User
+
+
+ {isAdmin && (
+ <>
+
+ Application
+
+
+ Scheduling
+
+
+ Actions
+
+ >
+ )}
+
+ >
+ );
+};
+
+export default SettingsNavigation;
diff --git a/frontend/src/components/StatsInfoBoxItem.tsx b/frontend/src/components/StatsInfoBoxItem.tsx
new file mode 100644
index 00000000..d25e15b4
--- /dev/null
+++ b/frontend/src/components/StatsInfoBoxItem.tsx
@@ -0,0 +1,26 @@
+type StatsInfoBoxItemType = {
+ title: string;
+ card: Record;
+};
+
+const StatsInfoBoxItem = ({ title, card }: StatsInfoBoxItemType) => {
+ return (
+
+
{title}
+
+
+ {Object.entries(card).map(([key, value]) => {
+ return (
+
+ {key}:
+ {value}
+
+ );
+ })}
+
+
+
+ );
+};
+
+export default StatsInfoBoxItem;
diff --git a/frontend/src/components/SubtitleList.tsx b/frontend/src/components/SubtitleList.tsx
new file mode 100644
index 00000000..bee073ff
--- /dev/null
+++ b/frontend/src/components/SubtitleList.tsx
@@ -0,0 +1,92 @@
+import { Link, useSearchParams } from 'react-router-dom';
+import Routes from '../configuration/routes/RouteList';
+import iconPlay from '/img/icon-play.svg';
+import Linkify from './Linkify';
+import getApiUrl from '../configuration/getApiUrl';
+
+type SubtitleListType = {
+ subtitle_index: number;
+ subtitle_line: string;
+ subtitle_start: string;
+ subtitle_fragment_id: string;
+ subtitle_end: string;
+ youtube_id: string;
+ title: string;
+ subtitle_channel: string;
+ subtitle_channel_id: string;
+ subtitle_last_refresh: number;
+ subtitle_lang: string;
+ subtitle_source: string;
+ vid_thumb_url: string;
+ _index: string;
+ _score: number;
+};
+
+type SubtitleListProps = {
+ subtitleList: SubtitleListType[] | undefined;
+};
+
+const stripNanoSecs = (time: string) => {
+ return time.split('.').shift();
+};
+
+const SubtitleList = ({ subtitleList }: SubtitleListProps) => {
+ const [, setSearchParams] = useSearchParams();
+
+ if (!subtitleList || subtitleList.length === 0) {
+ return No fulltext results found.
;
+ }
+
+ return (
+ <>
+ {subtitleList.map(subtitle => {
+ return (
+
+ );
+ })}
+ >
+ );
+};
+
+export default SubtitleList;
diff --git a/frontend/src/components/VideoList.tsx b/frontend/src/components/VideoList.tsx
new file mode 100644
index 00000000..440ed6bc
--- /dev/null
+++ b/frontend/src/components/VideoList.tsx
@@ -0,0 +1,41 @@
+import { VideoType, ViewLayoutType } from '../pages/Home';
+import VideoListItem from './VideoListItem';
+
+type VideoListProps = {
+ videoList: VideoType[] | undefined;
+ viewLayout: ViewLayoutType;
+ playlistId?: string;
+ showReorderButton?: boolean;
+ refreshVideoList: (refresh: boolean) => void;
+};
+
+const VideoList = ({
+ videoList,
+ viewLayout,
+ playlistId,
+ showReorderButton = false,
+ refreshVideoList,
+}: VideoListProps) => {
+ if (!videoList || videoList.length === 0) {
+ return No videos found.
;
+ }
+
+ return (
+ <>
+ {videoList.map(video => {
+ return (
+
+ );
+ })}
+ >
+ );
+};
+
+export default VideoList;
diff --git a/frontend/src/components/VideoListItem.tsx b/frontend/src/components/VideoListItem.tsx
new file mode 100644
index 00000000..9f880bc4
--- /dev/null
+++ b/frontend/src/components/VideoListItem.tsx
@@ -0,0 +1,126 @@
+import { Link, useSearchParams } from 'react-router-dom';
+import Routes from '../configuration/routes/RouteList';
+import { VideoType, ViewLayoutType } from '../pages/Home';
+import iconPlay from '/img/icon-play.svg';
+import iconDotMenu from '/img/icon-dot-menu.svg';
+import defaultVideoThumb from '/img/default-video-thumb.jpg';
+import updateWatchedState from '../api/actions/updateWatchedState';
+import formatDate from '../functions/formatDates';
+import WatchedCheckBox from './WatchedCheckBox';
+import MoveVideoMenu from './MoveVideoMenu';
+import { useState } from 'react';
+import getApiUrl from '../configuration/getApiUrl';
+
+type VideoListItemProps = {
+ video: VideoType;
+ viewLayout: ViewLayoutType;
+ playlistId?: string;
+ showReorderButton?: boolean;
+ refreshVideoList: (refresh: boolean) => void;
+};
+
+const VideoListItem = ({
+ video,
+ viewLayout,
+ playlistId,
+ showReorderButton = false,
+ refreshVideoList,
+}: VideoListItemProps) => {
+ const [, setSearchParams] = useSearchParams();
+
+ const [showReorderMenu, setShowReorderMenu] = useState(false);
+
+ if (!video) {
+ return No video found.
;
+ }
+
+ return (
+
+ );
+};
+
+export default VideoListItem;
diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx
new file mode 100644
index 00000000..3f92120a
--- /dev/null
+++ b/frontend/src/components/VideoPlayer.tsx
@@ -0,0 +1,254 @@
+import updateVideoProgressById from '../api/actions/updateVideoProgressById';
+import updateWatchedState from '../api/actions/updateWatchedState';
+import { SponsorBlockSegmentType, SponsorBlockType, VideoResponseType } from '../pages/Video';
+import watchedThreshold from '../functions/watchedThreshold';
+import Notifications from './Notifications';
+import { Dispatch, SetStateAction, SyntheticEvent, useState } from 'react';
+import formatTime from '../functions/formatTime';
+import { useSearchParams } from 'react-router-dom';
+import getApiUrl from '../configuration/getApiUrl';
+
+type VideoTag = SyntheticEvent;
+
+export type SkippedSegmentType = {
+ from: number;
+ to: number;
+};
+
+export type SponsorSegmentsSkippedType = Record;
+
+type Subtitle = {
+ name: string;
+ source: string;
+ lang: string;
+ media_url: string;
+};
+
+type SubtitlesProp = {
+ subtitles: Subtitle[];
+};
+
+const Subtitles = ({ subtitles }: SubtitlesProp) => {
+ return subtitles.map((subtitle: Subtitle) => {
+ let label = subtitle.name;
+
+ if (subtitle.source === 'auto') {
+ label += ' - auto';
+ }
+
+ return (
+
+ );
+ });
+};
+
+const handleTimeUpdate =
+ (
+ youtubeId: string,
+ duration: number,
+ watched: boolean,
+ sponsorBlock?: SponsorBlockType,
+ setSponsorSegmentSkipped?: Dispatch>,
+ ) =>
+ async (videoTag: VideoTag) => {
+ const currentTime = Number(videoTag.currentTarget.currentTime);
+
+ if (sponsorBlock && sponsorBlock.segments) {
+ sponsorBlock.segments.forEach((segment: SponsorBlockSegmentType) => {
+ const [from, to] = segment.segment;
+
+ if (currentTime >= from && currentTime <= from + 0.3) {
+ videoTag.currentTarget.currentTime = to;
+
+ setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => {
+ return { ...segments, [segment.UUID]: { from, to } };
+ });
+ }
+
+ if (currentTime > to + 10) {
+ setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => {
+ return { ...segments, [segment.UUID]: { from: 0, to: 0 } };
+ });
+ }
+ });
+ }
+
+ if (currentTime < 10) return;
+ if (Number((currentTime % 10).toFixed(1)) <= 0.2) {
+ // Check progress every 10 seconds or else progress is checked a few times a second
+ await updateVideoProgressById({
+ youtubeId,
+ currentProgress: currentTime,
+ });
+
+ if (!watched) {
+ // Check if video is already marked as watched
+ if (watchedThreshold(currentTime, duration)) {
+ await updateWatchedState({
+ id: youtubeId,
+ is_watched: true,
+ });
+ }
+ }
+ }
+ };
+
+const handleVideoEnd =
+ (
+ youtubeId: string,
+ watched: boolean,
+ setSponsorSegmentSkipped?: Dispatch>,
+ ) =>
+ async () => {
+ if (!watched) {
+ // Check if video is already marked as watched
+ await updateWatchedState({ id: youtubeId, is_watched: true });
+ }
+
+ setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => {
+ const keys = Object.keys(segments);
+
+ keys.forEach(uuid => {
+ segments[uuid] = { from: 0, to: 0 };
+ });
+
+ return segments;
+ });
+ };
+
+export type VideoProgressType = {
+ youtube_id: string;
+ user_id: number;
+ position: number;
+};
+
+type VideoPlayerProps = {
+ video: VideoResponseType;
+ videoProgress?: VideoProgressType;
+ sponsorBlock?: SponsorBlockType;
+ embed?: boolean;
+};
+
+const VideoPlayer = ({ video, videoProgress, sponsorBlock, embed }: VideoPlayerProps) => {
+ const [searchParams] = useSearchParams();
+ const searchParamVideoProgress = searchParams.get('t');
+
+ const [skippedSegments, setSkippedSegments] = useState({});
+
+ const videoId = video.data.youtube_id;
+ const videoUrl = video.data.media_url;
+ const videoThumbUrl = video.data.vid_thumb_url;
+ const watched = video.data.player.watched;
+ const duration = video.data.player.duration;
+ const videoSubtitles = video.data.subtitles;
+
+ let videoSrcProgress = Number(videoProgress?.position) > 0 ? Number(videoProgress?.position) : '';
+
+ if (searchParamVideoProgress !== null) {
+ videoSrcProgress = searchParamVideoProgress;
+ }
+
+ const autoplay = false;
+
+ return (
+ <>
+
+
+ {
+ localStorage.setItem('playerVolume', videoTag.currentTarget.volume.toString());
+ }}
+ onLoadStart={(videoTag: VideoTag) => {
+ videoTag.currentTarget.volume = Number(localStorage.getItem('playerVolume')) ?? 1;
+ }}
+ onTimeUpdate={handleTimeUpdate(
+ videoId,
+ duration,
+ watched,
+ sponsorBlock,
+ setSkippedSegments,
+ )}
+ onPause={async (videoTag: VideoTag) => {
+ const currentTime = Number(videoTag.currentTarget.currentTime);
+
+ if (currentTime < 10) return;
+
+ await updateVideoProgressById({
+ youtubeId: videoId,
+ currentProgress: currentTime,
+ });
+ }}
+ onEnded={handleVideoEnd(videoId, watched)}
+ autoPlay={autoplay}
+ controls
+ width="100%"
+ playsInline
+ id="video-item"
+ >
+
+ {videoSubtitles && }
+
+
+
+
+
+
+ >
+ );
+};
+
+export default VideoPlayer;
diff --git a/frontend/src/components/VideoTypeStats.tsx b/frontend/src/components/VideoTypeStats.tsx
new file mode 100644
index 00000000..1aacfecf
--- /dev/null
+++ b/frontend/src/components/VideoTypeStats.tsx
@@ -0,0 +1,53 @@
+import { Fragment } from 'react';
+import humanFileSize from '../functions/humanFileSize';
+import StatsInfoBoxItem from './StatsInfoBoxItem';
+import formatNumbers from '../functions/formatNumbers';
+import { VideoStatsType } from '../pages/SettingsDashboard';
+
+type VideoTypeStatsProps = {
+ videoStats?: VideoStatsType;
+ useSI: boolean;
+};
+
+const VideoTypeStats = ({ videoStats, useSI }: VideoTypeStatsProps) => {
+ if (!videoStats) {
+ return Loading...
;
+ }
+
+ const cards = [
+ {
+ title: 'Regular Videos: ',
+ data: {
+ Videos: formatNumbers(videoStats?.type_videos?.doc_count || 0),
+ ['Media Size']: humanFileSize(videoStats?.type_videos?.media_size || 0, useSI),
+ Duration: videoStats?.type_videos?.duration_str || 'NA',
+ },
+ },
+ {
+ title: 'Shorts: ',
+ data: {
+ Videos: formatNumbers(videoStats?.type_shorts?.doc_count || 0),
+ ['Media Size']: humanFileSize(videoStats?.type_shorts?.media_size || 0, useSI),
+ Duration: videoStats?.type_shorts?.duration_str || 'NA',
+ },
+ },
+ {
+ title: 'Streams: ',
+ data: {
+ Videos: formatNumbers(videoStats?.type_streams?.doc_count || 0),
+ ['Media Size']: humanFileSize(videoStats?.type_streams?.media_size || 0, useSI),
+ Duration: videoStats?.type_streams?.duration_str || 'NA',
+ },
+ },
+ ];
+
+ return cards.map(card => {
+ return (
+
+
+
+ );
+ });
+};
+
+export default VideoTypeStats;
diff --git a/frontend/src/components/WatchProgressStats.tsx b/frontend/src/components/WatchProgressStats.tsx
new file mode 100644
index 00000000..afa43752
--- /dev/null
+++ b/frontend/src/components/WatchProgressStats.tsx
@@ -0,0 +1,65 @@
+import { Fragment } from 'react';
+import StatsInfoBoxItem from './StatsInfoBoxItem';
+import formatNumbers from '../functions/formatNumbers';
+import { WatchProgressStatsType } from '../pages/SettingsDashboard';
+
+const formatProgress = (progress: number) => {
+ return (Number(progress) * 100).toFixed(2) ?? '0';
+};
+
+const formatTitle = (title: string, progress: number, progressFormatted: string) => {
+ const hasProgess = !!progress;
+
+ return hasProgess ? `${progressFormatted}% ${title}` : title;
+};
+
+type WatchProgressStatsProps = {
+ watchProgressStats?: WatchProgressStatsType;
+};
+
+const WatchProgressStats = ({ watchProgressStats }: WatchProgressStatsProps) => {
+ if (!watchProgressStats) {
+ return Loading...
;
+ }
+
+ const titleWatched = formatTitle(
+ 'Watched',
+ watchProgressStats?.watched?.progress,
+ formatProgress(watchProgressStats?.watched?.progress),
+ );
+
+ const titleUnwatched = formatTitle(
+ 'Unwatched',
+ watchProgressStats?.unwatched?.progress,
+ formatProgress(watchProgressStats?.unwatched?.progress),
+ );
+
+ const cards = [
+ {
+ title: titleWatched,
+ data: {
+ Videos: formatNumbers(watchProgressStats?.watched?.items ?? 0),
+ Seconds: formatNumbers(watchProgressStats?.watched?.duration ?? 0),
+ Duration: watchProgressStats?.watched?.duration_str ?? '0s',
+ },
+ },
+ {
+ title: titleUnwatched,
+ data: {
+ Videos: formatNumbers(watchProgressStats?.unwatched?.items ?? 0),
+ Seconds: formatNumbers(watchProgressStats?.unwatched?.duration ?? 0),
+ Duration: watchProgressStats?.unwatched?.duration_str ?? '0s',
+ },
+ },
+ ];
+
+ return cards.map(card => {
+ return (
+
+
+
+ );
+ });
+};
+
+export default WatchProgressStats;
diff --git a/frontend/src/components/WatchedCheckBox.tsx b/frontend/src/components/WatchedCheckBox.tsx
new file mode 100644
index 00000000..c5a5d012
--- /dev/null
+++ b/frontend/src/components/WatchedCheckBox.tsx
@@ -0,0 +1,38 @@
+import iconUnseen from '/img/icon-unseen.svg';
+import iconSeen from '/img/icon-seen.svg';
+
+type WatchedCheckBoxProps = {
+ watched: boolean;
+ onClick?: (status: boolean) => void;
+};
+
+const WatchedCheckBox = ({ watched, onClick }: WatchedCheckBoxProps) => {
+ return (
+ <>
+ {watched && (
+ {
+ onClick?.(false);
+ }}
+ />
+ )}
+ {!watched && (
+ {
+ onClick?.(true);
+ }}
+ />
+ )}
+ >
+ );
+};
+
+export default WatchedCheckBox;
diff --git a/frontend/src/configuration/colours/components/Dark.tsx b/frontend/src/configuration/colours/components/Dark.tsx
new file mode 100644
index 00000000..126f0f7d
--- /dev/null
+++ b/frontend/src/configuration/colours/components/Dark.tsx
@@ -0,0 +1,7 @@
+import './css/dark.css';
+
+const DarkStylesheet = () => {
+ return <>>;
+};
+
+export default DarkStylesheet;
diff --git a/frontend/src/configuration/colours/components/Light.tsx b/frontend/src/configuration/colours/components/Light.tsx
new file mode 100644
index 00000000..cdbeeda2
--- /dev/null
+++ b/frontend/src/configuration/colours/components/Light.tsx
@@ -0,0 +1,7 @@
+import './css/light.css';
+
+const LightStylesheet = () => {
+ return <>>;
+};
+
+export default LightStylesheet;
diff --git a/frontend/src/configuration/colours/components/Matrix.tsx b/frontend/src/configuration/colours/components/Matrix.tsx
new file mode 100644
index 00000000..50db1d95
--- /dev/null
+++ b/frontend/src/configuration/colours/components/Matrix.tsx
@@ -0,0 +1,7 @@
+import './css/matrix.css';
+
+const MatrixStylesheet = () => {
+ return <>>;
+};
+
+export default MatrixStylesheet;
diff --git a/frontend/src/configuration/colours/components/Midnight.tsx b/frontend/src/configuration/colours/components/Midnight.tsx
new file mode 100644
index 00000000..cf777736
--- /dev/null
+++ b/frontend/src/configuration/colours/components/Midnight.tsx
@@ -0,0 +1,7 @@
+import './css/midnight.css';
+
+const MidnightStylesheet = () => {
+ return <>>;
+};
+
+export default MidnightStylesheet;
diff --git a/frontend/src/configuration/colours/components/css/dark.css b/frontend/src/configuration/colours/components/css/dark.css
new file mode 100644
index 00000000..1ac16ca6
--- /dev/null
+++ b/frontend/src/configuration/colours/components/css/dark.css
@@ -0,0 +1,16 @@
+:root {
+ --main-bg: #00202f;
+ --highlight-bg: #00293b;
+ --highlight-error: #990202;
+ --highlight-error-light: #c44343;
+ --highlight-bg-transparent: #00293baf;
+ --main-font: #eeeeee;
+ --accent-font-dark: #259485;
+ --accent-font-light: #97d4c8;
+ --img-filter: invert(50%) sepia(9%) saturate(2940%) hue-rotate(122deg) brightness(94%)
+ contrast(90%);
+ --img-filter-error: invert(16%) sepia(60%) saturate(3717%) hue-rotate(349deg) brightness(86%)
+ contrast(120%);
+ --banner: url('/img/banner-tube-archivist-dark.png');
+ --logo: url('/img/logo-tube-archivist-dark.png');
+}
diff --git a/frontend/src/configuration/colours/components/css/light.css b/frontend/src/configuration/colours/components/css/light.css
new file mode 100644
index 00000000..d5834c93
--- /dev/null
+++ b/frontend/src/configuration/colours/components/css/light.css
@@ -0,0 +1,16 @@
+:root {
+ --main-bg: #eeeeee;
+ --highlight-bg: #d9e0d9;
+ --highlight-error: #990202;
+ --highlight-error-light: #c44343;
+ --highlight-bg-transparent: #00293baf;
+ --main-font: #00202f;
+ --accent-font-dark: #259485;
+ --accent-font-light: #35b399;
+ --img-filter: invert(50%) sepia(9%) saturate(2940%) hue-rotate(122deg) brightness(94%)
+ contrast(90%);
+ --img-filter-error: invert(16%) sepia(60%) saturate(3717%) hue-rotate(349deg) brightness(86%)
+ contrast(120%);
+ --banner: url('/img/banner-tube-archivist-light.png');
+ --logo: url('/img/logo-tube-archivist-light.png');
+}
diff --git a/frontend/src/configuration/colours/components/css/matrix.css b/frontend/src/configuration/colours/components/css/matrix.css
new file mode 100644
index 00000000..9a8c47b4
--- /dev/null
+++ b/frontend/src/configuration/colours/components/css/matrix.css
@@ -0,0 +1,69 @@
+:root {
+ --main-bg: #000000;
+ --highlight-bg: #080808;
+ --highlight-error: #880000;
+ --highlight-error-light: #aa0000;
+ --highlight-bg-transparent: #0c0c0caf;
+ --main-font: #00aa00;
+ --accent-font-dark: #007700;
+ --accent-font-light: #00aa00;
+ --img-filter: brightness(0) saturate(100%) invert(45%) sepia(100%) saturate(3710%)
+ hue-rotate(96deg) brightness(100%) contrast(102%);
+ --img-filter-error: invert(16%) sepia(60%) saturate(3717%) hue-rotate(349deg) brightness(86%)
+ contrast(120%);
+ --banner: url('/img/banner-tube-archivist-dark.png');
+ --logo: url('/img/logo-tube-archivist-dark.png');
+ --outline: 1px solid green;
+ --filter: hue-rotate(310deg);
+}
+
+.settings-group {
+ outline: var(--outline);
+}
+
+.info-box-item {
+ outline: var(--outline);
+}
+
+.footer {
+ outline: var(--outline);
+}
+
+.top-banner img {
+ filter: var(--filter);
+}
+
+.icon-text {
+ outline: var(--outline);
+}
+
+.video-item {
+ outline: var(--outline);
+}
+
+.channel-banner {
+ outline: var(--outline);
+}
+
+.description-box {
+ outline: var(--outline);
+}
+
+.video-player {
+ outline: var(--outline);
+}
+
+#notification {
+ outline: var(--outline);
+}
+
+textarea {
+ background-color: var(--highlight-bg);
+ outline: var(--outline);
+ color: var(--main-font);
+}
+
+input {
+ background-color: var(--highlight-bg);
+ color: var(--main-font);
+}
diff --git a/frontend/src/configuration/colours/components/css/midnight.css b/frontend/src/configuration/colours/components/css/midnight.css
new file mode 100644
index 00000000..47f67f08
--- /dev/null
+++ b/frontend/src/configuration/colours/components/css/midnight.css
@@ -0,0 +1,16 @@
+:root {
+ --main-bg: #000000;
+ --highlight-bg: #0c0c0c;
+ --highlight-error: #220000;
+ --highlight-error-light: #330000;
+ --highlight-bg-transparent: #0c0c0caf;
+ --main-font: #888888;
+ --accent-font-dark: #555555;
+ --accent-font-light: #999999;
+ --img-filter: invert(50%) sepia(9%) saturate(2940%) hue-rotate(122deg) brightness(94%)
+ contrast(90%);
+ --img-filter-error: invert(16%) sepia(60%) saturate(3717%) hue-rotate(349deg) brightness(86%)
+ contrast(120%);
+ --banner: url('/img/banner-tube-archivist-dark.png');
+ --logo: url('/img/logo-tube-archivist-dark.png');
+}
diff --git a/frontend/src/configuration/colours/getColours.ts b/frontend/src/configuration/colours/getColours.ts
new file mode 100644
index 00000000..93e97ebc
--- /dev/null
+++ b/frontend/src/configuration/colours/getColours.ts
@@ -0,0 +1,29 @@
+export const ColourConstant = {
+ Dark: 'dark.css',
+ Light: 'light.css',
+ Matrix: 'matrix.css',
+ Midnight: 'midnight.css',
+};
+
+export type ColourVariants = 'dark.css' | 'light.css' | 'matrix.css' | 'midnight.css';
+
+const importColours = (stylesheet: ColourVariants | undefined) => {
+ switch (stylesheet) {
+ case ColourConstant.Dark:
+ return import('./components/Dark');
+
+ case ColourConstant.Matrix:
+ return import('./components/Matrix');
+
+ case ColourConstant.Midnight:
+ return import('./components/Midnight');
+
+ case ColourConstant.Light:
+ return import('./components/Light');
+
+ default:
+ return import('./components/Dark');
+ }
+};
+
+export default importColours;
diff --git a/frontend/src/configuration/constants/ViewStyle.ts b/frontend/src/configuration/constants/ViewStyle.ts
new file mode 100644
index 00000000..cafe55cc
--- /dev/null
+++ b/frontend/src/configuration/constants/ViewStyle.ts
@@ -0,0 +1,11 @@
+export const ViewStyleNames = {
+ home: 'view_style_home',
+ channel: 'view_style_channel',
+ downloads: 'view_style_downloads',
+ playlist: 'view_style_playlist',
+};
+
+export const ViewStyles = {
+ grid: 'grid',
+ list: 'list',
+};
diff --git a/frontend/src/configuration/defaultHeaders.ts b/frontend/src/configuration/defaultHeaders.ts
new file mode 100644
index 00000000..169dc17e
--- /dev/null
+++ b/frontend/src/configuration/defaultHeaders.ts
@@ -0,0 +1 @@
+export default { 'Content-Type': 'application/json' };
diff --git a/frontend/src/configuration/getApiUrl.ts b/frontend/src/configuration/getApiUrl.ts
new file mode 100644
index 00000000..09ceffce
--- /dev/null
+++ b/frontend/src/configuration/getApiUrl.ts
@@ -0,0 +1,14 @@
+const DEV_API_URL = 'http://localhost:8000';
+const PROD_API_URL = window.location.origin;
+
+const getApiUrl = () => {
+ let url = PROD_API_URL;
+
+ if (import.meta.env.DEV) {
+ url = DEV_API_URL;
+ }
+
+ return url;
+};
+
+export default getApiUrl;
diff --git a/frontend/src/configuration/getFetchCredentials.ts b/frontend/src/configuration/getFetchCredentials.ts
new file mode 100644
index 00000000..6f4346d4
--- /dev/null
+++ b/frontend/src/configuration/getFetchCredentials.ts
@@ -0,0 +1,9 @@
+import isDevEnvironment from '../functions/isDevEnvironment';
+
+const getFetchCredentials = () => {
+ const isDevEnv = isDevEnvironment();
+
+ return isDevEnv ? 'include' : 'same-origin';
+};
+
+export default getFetchCredentials;
diff --git a/frontend/src/configuration/routes/RouteList.ts b/frontend/src/configuration/routes/RouteList.ts
new file mode 100644
index 00000000..cd9e82ce
--- /dev/null
+++ b/frontend/src/configuration/routes/RouteList.ts
@@ -0,0 +1,27 @@
+const Routes = {
+ Home: '/',
+ Channels: '/channel/',
+ Channel: (id: string) => `/channel/${id}`,
+ ChannelVideo: (id: string) => `/channel/${id}`,
+ ChannelStream: (id: string) => `/channel/${id}/streams/`,
+ ChannelShorts: (id: string) => `/channel/${id}/shorts/`,
+ ChannelPlaylist: (id: string) => `/channel/${id}/playlist/`,
+ ChannelAbout: (id: string) => `/channel/${id}/about/`,
+ Playlists: '/playlist/',
+ Playlist: (id: string) => `/playlist/${id}`,
+ Downloads: '/downloads/',
+ DownloadsByChannelId: (channelId: string) => `/downloads/?channel=${channelId}`,
+ Search: '/search/',
+ SettingsDashboard: '/settings/',
+ SettingsUser: '/settings/user/',
+ SettingsApplication: '/settings/application/',
+ SettingsScheduling: '/settings/scheduling/',
+ SettingsActions: '/settings/actions/',
+ Login: '/Login/',
+ Logout: '/logout/',
+ Video: (id: string) => `/video/${id}`,
+ VideoAtTimestamp: (id: string, timestamp: string) => `/video/${id}/?t=${timestamp}`,
+ About: '/about/',
+};
+
+export default Routes;
diff --git a/frontend/src/functions/capitalizeFirstLetter.ts b/frontend/src/functions/capitalizeFirstLetter.ts
new file mode 100644
index 00000000..cc4123df
--- /dev/null
+++ b/frontend/src/functions/capitalizeFirstLetter.ts
@@ -0,0 +1,6 @@
+function capitalizeFirstLetter(word: string) {
+ // source: https://stackoverflow.com/a/1026087
+ return word.charAt(0).toUpperCase() + word.slice(1);
+}
+
+export default capitalizeFirstLetter;
diff --git a/frontend/src/functions/convertStarRating.ts b/frontend/src/functions/convertStarRating.ts
new file mode 100644
index 00000000..ba9b3985
--- /dev/null
+++ b/frontend/src/functions/convertStarRating.ts
@@ -0,0 +1,24 @@
+const convertStarRating = (averageRating: number | undefined) => {
+ if (!averageRating) {
+ return [];
+ }
+
+ let rating = averageRating;
+ const stars: string[] = [];
+
+ [1, 2, 3, 4, 5].forEach(() => {
+ if (rating >= 0.75) {
+ stars.push('full');
+ } else if (0.25 < rating && rating < 0.75) {
+ stars.push('half');
+ } else {
+ stars.push('empty');
+ }
+
+ rating -= 1;
+ });
+
+ return stars;
+};
+
+export default convertStarRating;
diff --git a/frontend/src/functions/formatDates.ts b/frontend/src/functions/formatDates.ts
new file mode 100644
index 00000000..dde6fb6d
--- /dev/null
+++ b/frontend/src/functions/formatDates.ts
@@ -0,0 +1,6 @@
+const formatDate = (date: string | number | Date) => {
+ const dateObj = new Date(date);
+ return Intl.DateTimeFormat(navigator.language).format(dateObj);
+};
+
+export default formatDate;
diff --git a/frontend/src/functions/formatNumbers.ts b/frontend/src/functions/formatNumbers.ts
new file mode 100644
index 00000000..6fedbb05
--- /dev/null
+++ b/frontend/src/functions/formatNumbers.ts
@@ -0,0 +1,6 @@
+const formatNumbers = (number: number, options?: Intl.NumberFormatOptions) => {
+ const formatNumber = Intl.NumberFormat(navigator.language, options);
+ return formatNumber.format(number);
+};
+
+export default formatNumbers;
diff --git a/frontend/src/functions/formatTime.ts b/frontend/src/functions/formatTime.ts
new file mode 100644
index 00000000..1f10108e
--- /dev/null
+++ b/frontend/src/functions/formatTime.ts
@@ -0,0 +1,32 @@
+// Formats times in seconds for frontend
+function formatTime(time: number) {
+ const hoursUnformatted = time / 3600;
+ const minutesUnformatted = (time % 3600) / 60;
+ const secondsUnformatted = time % 60;
+
+ const hoursFormatted = Math.trunc(hoursUnformatted);
+ let minutesFormatted;
+
+ if (minutesUnformatted < 10 && hoursFormatted > 0) {
+ minutesFormatted = '0' + Math.trunc(minutesUnformatted);
+ } else {
+ minutesFormatted = Math.trunc(minutesUnformatted).toString();
+ }
+
+ let secondsFormatted;
+ if (secondsUnformatted < 10) {
+ secondsFormatted = '0' + Math.trunc(secondsUnformatted);
+ } else {
+ secondsFormatted = Math.trunc(secondsUnformatted).toString();
+ }
+
+ let timeUnformatted = '';
+ if (hoursFormatted > 0) {
+ timeUnformatted = hoursFormatted + ':';
+ }
+
+ const timeFormatted = timeUnformatted.concat(minutesFormatted, ':', secondsFormatted);
+ return timeFormatted;
+}
+
+export default formatTime;
diff --git a/frontend/src/functions/getCookie.ts b/frontend/src/functions/getCookie.ts
new file mode 100644
index 00000000..30549432
--- /dev/null
+++ b/frontend/src/functions/getCookie.ts
@@ -0,0 +1,21 @@
+// source: https://docs.djangoproject.com/en/4.0/ref/csrf/
+function getCookie(name: string) {
+ let cookieValue = null;
+
+ if (document.cookie && document.cookie !== '') {
+ const cookies = document.cookie.split(';');
+
+ for (let i = 0; i < cookies.length; i++) {
+ const cookie = cookies[i].trim();
+
+ // Does this cookie string begin with the name we want?
+ if (cookie.substring(0, name.length + 1) === name + '=') {
+ cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
+ break;
+ }
+ }
+ }
+ return cookieValue;
+}
+
+export default getCookie;
diff --git a/frontend/src/functions/getIsAdmin.ts b/frontend/src/functions/getIsAdmin.ts
new file mode 100644
index 00000000..b5d5a10a
--- /dev/null
+++ b/frontend/src/functions/getIsAdmin.ts
@@ -0,0 +1,9 @@
+import { UserMeType } from '../api/actions/updateUserConfig';
+
+const loadIsAdmin = (config: UserMeType) => {
+ const isAdmin = config.is_staff || config.is_superuser;
+
+ return isAdmin;
+};
+
+export default loadIsAdmin;
diff --git a/frontend/src/functions/humanFileSize.ts b/frontend/src/functions/humanFileSize.ts
new file mode 100644
index 00000000..c53b8bc3
--- /dev/null
+++ b/frontend/src/functions/humanFileSize.ts
@@ -0,0 +1,35 @@
+/**
+ * Format bytes as human-readable text.
+ *
+ * @param bytes Number of bytes.
+ * @param si True to use metric (SI) units, aka powers of 1000. False to use
+ * binary (IEC), aka powers of 1024.
+ * @param dp Number of decimal places to display.
+ *
+ * @return Formatted string.
+ *
+ *
+ * source: https://stackoverflow.com/a/14919494
+ */
+function humanFileSize(bytes: number, si = false, dp = 1) {
+ const thresh = si ? 1000 : 1024;
+
+ if (Math.abs(bytes) < thresh) {
+ return bytes + ' B';
+ }
+
+ const units = si
+ ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
+ : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
+ let u = -1;
+ const r = 10 ** dp;
+
+ do {
+ bytes /= thresh;
+ ++u;
+ } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
+
+ return bytes.toFixed(dp) + ' ' + units[u];
+}
+
+export default humanFileSize;
diff --git a/frontend/src/functions/isDevEnvironment.ts b/frontend/src/functions/isDevEnvironment.ts
new file mode 100644
index 00000000..7f54e652
--- /dev/null
+++ b/frontend/src/functions/isDevEnvironment.ts
@@ -0,0 +1,7 @@
+const isDevEnvironment = () => {
+ const { DEV } = import.meta.env;
+
+ return DEV;
+};
+
+export default isDevEnvironment;
diff --git a/frontend/src/functions/watchedThreshold.ts b/frontend/src/functions/watchedThreshold.ts
new file mode 100644
index 00000000..06b4c0bb
--- /dev/null
+++ b/frontend/src/functions/watchedThreshold.ts
@@ -0,0 +1,21 @@
+function watchedThreshold(currentTime: number, duration: number) {
+ let watched = false;
+
+ if (duration <= 1800) {
+ // If video is less than 30 min
+ if (currentTime / duration >= 0.9) {
+ // Mark as watched at 90%
+ watched = true;
+ }
+ } else {
+ // If video is more than 30 min
+ if (currentTime >= duration - 120) {
+ // Mark as watched if there is two minutes left
+ watched = true;
+ }
+ }
+
+ return watched;
+}
+
+export default watchedThreshold;
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 00000000..5f060daf
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,314 @@
+import * as React from 'react';
+import * as ReactDOM from 'react-dom/client';
+import { createBrowserRouter, redirect, RouterProvider } from 'react-router-dom';
+import Routes from './configuration/routes/RouteList';
+import './style.css';
+import Base from './pages/Base';
+import About from './pages/About';
+import Channels from './pages/Channels';
+import ErrorPage from './pages/ErrorPage';
+import Home from './pages/Home';
+import Logout from './pages/Logout';
+import Playlist from './pages/Playlist';
+import Playlists from './pages/Playlists';
+import Search from './pages/Search';
+import SettingsDashboard from './pages/SettingsDashboard';
+import Video from './pages/Video';
+import Login from './pages/Login';
+import SettingsActions from './pages/SettingsActions';
+import SettingsApplication from './pages/SettingsApplication';
+import SettingsScheduling from './pages/SettingsScheduling';
+import SettingsUser from './pages/SettingsUser';
+import loadUserMeConfig from './api/loader/loadUserConfig';
+import loadAuth from './api/loader/loadAuth';
+import ChannelBase from './pages/ChannelBase';
+import ChannelVideo from './pages/ChannelVideo';
+import ChannelPlaylist from './pages/ChannelPlaylist';
+import ChannelAbout from './pages/ChannelAbout';
+import ChannelStream from './pages/ChannelStream';
+import Download from './pages/Download';
+import ChannelShorts from './pages/ChannelShorts';
+
+const router = createBrowserRouter(
+ [
+ {
+ path: Routes.Home,
+ loader: async () => {
+ console.log('------------ after reload');
+
+ const auth = await loadAuth();
+ if (auth.status === 403) {
+ return redirect(Routes.Login);
+ }
+
+ const authData = await auth.json();
+
+ const userConfig = await loadUserMeConfig();
+
+ return { userConfig, auth: authData };
+ },
+ element: ,
+ errorElement: ,
+ children: [
+ {
+ index: true,
+ element: ,
+ loader: async () => {
+ const authResponse = await loadAuth();
+ if (authResponse.status === 403) {
+ return redirect(Routes.Login);
+ }
+
+ const userConfig = await loadUserMeConfig();
+
+ return { userConfig };
+ },
+ },
+ {
+ path: Routes.Video(':videoId'),
+ element: ,
+ loader: async () => {
+ const authResponse = await loadAuth();
+ if (authResponse.status === 403) {
+ return redirect(Routes.Login);
+ }
+
+ return {};
+ },
+ },
+ {
+ path: Routes.Channels,
+ element: ,
+ loader: async () => {
+ const authResponse = await loadAuth();
+ if (authResponse.status === 403) {
+ return redirect(Routes.Login);
+ }
+
+ const userConfig = await loadUserMeConfig();
+
+ return { userConfig };
+ },
+ },
+ {
+ path: Routes.Channel(':channelId'),
+ element: ,
+ loader: async () => {
+ const authResponse = await loadAuth();
+ if (authResponse.status === 403) {
+ return redirect(Routes.Login);
+ }
+
+ return {};
+ },
+ children: [
+ {
+ index: true,
+ path: Routes.ChannelVideo(':channelId'),
+ element: ,
+ loader: async () => {
+ const authResponse = await loadAuth();
+ if (authResponse.status === 403) {
+ return redirect(Routes.Login);
+ }
+
+ const userConfig = await loadUserMeConfig();
+
+ return { userConfig };
+ },
+ },
+ {
+ path: Routes.ChannelStream(':channelId'),
+ element: ,
+ loader: async () => {
+ const authResponse = await loadAuth();
+ if (authResponse.status === 403) {
+ return redirect(Routes.Login);
+ }
+
+ return {};
+ },
+ },
+ {
+ path: Routes.ChannelShorts(':channelId'),
+ element: ,
+ loader: async () => {
+ const authResponse = await loadAuth();
+ if (authResponse.status === 403) {
+ return redirect(Routes.Login);
+ }
+
+ return {};
+ },
+ },
+ {
+ path: Routes.ChannelPlaylist(':channelId'),
+ element: ,
+ loader: async () => {
+ const authResponse = await loadAuth();
+ if (authResponse.status === 403) {
+ return redirect(Routes.Login);
+ }
+
+ const userConfig = await loadUserMeConfig();
+
+ return { userConfig };
+ },
+ },
+ {
+ path: Routes.ChannelAbout(':channelId'),
+ element: ,
+ loader: async () => {
+ const authResponse = await loadAuth();
+ if (authResponse.status === 403) {
+ return redirect(Routes.Login);
+ }
+
+ return {};
+ },
+ },
+ ],
+ },
+ {
+ path: Routes.Playlists,
+ element: ,
+ loader: async () => {
+ const authResponse = await loadAuth();
+ if (authResponse.status === 403) {
+ return redirect(Routes.Login);
+ }
+
+ const userConfig = await loadUserMeConfig();
+
+ return { userConfig };
+ },
+ },
+ {
+ path: Routes.Playlist(':playlistId'),
+ element: ,
+ loader: async () => {
+ const authResponse = await loadAuth();
+ if (authResponse.status === 403) {
+ return redirect(Routes.Login);
+ }
+
+ const userConfig = await loadUserMeConfig();
+
+ return { userConfig };
+ },
+ },
+ {
+ path: Routes.Downloads,
+ element: ,
+ loader: async () => {
+ const authResponse = await loadAuth();
+ if (authResponse.status === 403) {
+ return redirect(Routes.Login);
+ }
+
+ const userConfig = await loadUserMeConfig();
+
+ return { userConfig };
+ },
+ },
+ {
+ path: Routes.Search,
+ element: ,
+ loader: async () => {
+ const authResponse = await loadAuth();
+ if (authResponse.status === 403) {
+ return redirect(Routes.Login);
+ }
+
+ const userConfig = await loadUserMeConfig();
+
+ return { userConfig };
+ },
+ },
+ {
+ path: Routes.SettingsDashboard,
+ element: ,
+ loader: async () => {
+ const authResponse = await loadAuth();
+ if (authResponse.status === 403) {
+ return redirect(Routes.Login);
+ }
+
+ return {};
+ },
+ },
+ {
+ path: Routes.SettingsActions,
+ element: ,
+ loader: async () => {
+ const authResponse = await loadAuth();
+ if (authResponse.status === 403) {
+ return redirect(Routes.Login);
+ }
+
+ return {};
+ },
+ },
+ {
+ path: Routes.SettingsApplication,
+ element: ,
+ loader: async () => {
+ const authResponse = await loadAuth();
+ if (authResponse.status === 403) {
+ return redirect(Routes.Login);
+ }
+
+ return {};
+ },
+ },
+ {
+ path: Routes.SettingsScheduling,
+ element: ,
+ loader: async () => {
+ const authResponse = await loadAuth();
+ if (authResponse.status === 403) {
+ return redirect(Routes.Login);
+ }
+
+ return {};
+ },
+ },
+ {
+ path: Routes.SettingsUser,
+ element: ,
+ loader: async () => {
+ const auth = await loadAuth();
+ if (auth.status === 403) {
+ return redirect(Routes.Login);
+ }
+
+ const userConfig = await loadUserMeConfig();
+
+ return { userConfig };
+ },
+ },
+ {
+ path: Routes.About,
+ element: ,
+ },
+ ],
+ },
+ {
+ path: Routes.Login,
+ element: ,
+ errorElement: ,
+ },
+ {
+ path: Routes.Logout,
+ element: ,
+ errorElement: ,
+ },
+ ],
+ { basename: import.meta.env.BASE_URL },
+);
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/frontend/src/pages/About.tsx b/frontend/src/pages/About.tsx
new file mode 100644
index 00000000..7a73c244
--- /dev/null
+++ b/frontend/src/pages/About.tsx
@@ -0,0 +1,64 @@
+import { Helmet } from 'react-helmet';
+
+const About = () => {
+ return (
+ <>
+
+ TA | About
+
+
+
+
+
About The Tube Archivist
+
+
+
Useful Links
+
+ This project is in active and constant development, take a look at the{' '}
+
+ roadmap
+ {' '}
+ for a overview.
+
+
+ All functionality is documented in our up-to-date{' '}
+
+ user guide
+
+ .
+
+
+ All contributions are welcome: Open an{' '}
+
+ issue
+ {' '}
+ for any bugs and errors, join us on{' '}
+
+ Discord
+ {' '}
+ to discuss details. The{' '}
+
+ contributing
+ {' '}
+ page is a good place to get started.
+
+
+
+
Donate
+
+ Here are{' '}
+
+ some links
+
+ , if you want to buy the developer a coffee. Thank you for your support!
+
+
+
+ >
+ );
+};
+
+export default About;
diff --git a/frontend/src/pages/Base.tsx b/frontend/src/pages/Base.tsx
new file mode 100644
index 00000000..a9ad52ad
--- /dev/null
+++ b/frontend/src/pages/Base.tsx
@@ -0,0 +1,89 @@
+import { Outlet, useLoaderData, useLocation, useSearchParams } from 'react-router-dom';
+import Footer, { TaUpdateType } from '../components/Footer';
+import importColours from '../configuration/colours/getColours';
+import { UserMeType } from '../api/actions/updateUserConfig';
+import { useEffect, useState } from 'react';
+import Navigation from '../components/Navigation';
+import loadIsAdmin from '../functions/getIsAdmin';
+
+export type AuthenticationType = {
+ response: string;
+ user: number;
+ version: string;
+ ta_update: TaUpdateType;
+};
+
+type BaseLoaderData = {
+ userConfig: UserMeType;
+ auth: AuthenticationType;
+};
+
+export type OutletContextType = {
+ isAdmin: boolean;
+ currentPage: number;
+ setCurrentPage: (page: number) => void;
+};
+
+const Base = () => {
+ const { userConfig, auth } = useLoaderData() as BaseLoaderData;
+ const location = useLocation();
+
+ const userMeConfig = userConfig.config;
+
+ const searchParams = new URLSearchParams(location.search);
+
+ const currentPageFromUrl = Number(searchParams.get('page'));
+
+ const [currentPage, setCurrentPage] = useState(currentPageFromUrl);
+ const [, setSearchParams] = useSearchParams();
+
+ const isAdmin = loadIsAdmin(userConfig);
+ const version = auth.version;
+ const taUpdate = auth.ta_update;
+
+ useEffect(() => {
+ if (currentPageFromUrl !== currentPage) {
+ setCurrentPage(0);
+ }
+
+ // This should only be executed when location.pathname changes.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [location.pathname]);
+
+ useEffect(() => {
+ if (currentPageFromUrl !== currentPage) {
+ setCurrentPage(currentPageFromUrl);
+ }
+
+ // This should only be executed when currentPageFromUrl changes.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [currentPageFromUrl]);
+
+ useEffect(() => {
+ if (currentPageFromUrl !== currentPage) {
+ setSearchParams(params => {
+ params.set('page', currentPage.toString());
+
+ return params;
+ });
+ }
+
+ // This should only be executed when currentPage changes.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [currentPage]);
+
+ importColours(userMeConfig.stylesheet);
+
+ return (
+ <>
+
+
+ {/** Outlet: https://reactrouter.com/en/main/components/outlet */}
+
+
+
+ >
+ );
+};
+
+export default Base;
diff --git a/frontend/src/pages/ChannelAbout.tsx b/frontend/src/pages/ChannelAbout.tsx
new file mode 100644
index 00000000..1b7e752b
--- /dev/null
+++ b/frontend/src/pages/ChannelAbout.tsx
@@ -0,0 +1,350 @@
+import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
+import ChannelOverview from '../components/ChannelOverview';
+import { useEffect, useState } from 'react';
+import loadChannelById from '../api/loader/loadChannelById';
+import { ChannelResponseType } from './ChannelBase';
+import Linkify from '../components/Linkify';
+import deleteChannel from '../api/actions/deleteChannel';
+import Routes from '../configuration/routes/RouteList';
+import queueReindex, { ReindexType, ReindexTypeEnum } from '../api/actions/queueReindex';
+import formatDate from '../functions/formatDates';
+import PaginationDummy from '../components/PaginationDummy';
+import FormattedNumber from '../components/FormattedNumber';
+import { Helmet } from 'react-helmet';
+import Button from '../components/Button';
+
+const handleSponsorBlockIntegrationOverwrite = (integration: boolean | undefined) => {
+ if (integration === undefined) {
+ return 'False';
+ }
+
+ if (integration) {
+ return integration;
+ } else {
+ return 'Disabled';
+ }
+};
+
+export type ChannelBaseOutletContextType = {
+ isAdmin: boolean;
+ currentPage: number;
+ setCurrentPage: (page: number) => void;
+ startNotification: boolean;
+ setStartNotification: (status: boolean) => void;
+};
+
+export type OutletContextType = {
+ isAdmin: boolean;
+ currentPage: number;
+ setCurrentPage: (page: number) => void;
+};
+
+type ChannelAboutParams = {
+ channelId: string;
+};
+
+const ChannelAbout = () => {
+ const { channelId } = useParams() as ChannelAboutParams;
+ const { isAdmin, setStartNotification } = useOutletContext() as ChannelBaseOutletContextType;
+ const navigate = useNavigate();
+
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [descriptionExpanded, setDescriptionExpanded] = useState(false);
+ const [reindex, setReindex] = useState(false);
+ const [refresh, setRefresh] = useState(false);
+
+ const [channelResponse, setChannelResponse] = useState();
+
+ const channel = channelResponse?.data;
+
+ const channelOverwrites = channel?.channel_overwrites;
+
+ const handleSubmit = async (event: { preventDefault: () => void }) => {
+ event.preventDefault();
+
+ //TODO: implement request to about api endpoint ( when implemented )
+ // `/api/channel/${channel.channel_id}/about/`
+ };
+
+ useEffect(() => {
+ (async () => {
+ const channelResponse = await loadChannelById(channelId);
+
+ setChannelResponse(channelResponse);
+ setRefresh(false);
+ })();
+ }, [refresh, channelId]);
+
+ if (!channel) {
+ return 'Channel not found!';
+ }
+
+ return (
+ <>
+
+ TA | Channel: About {channel.channel_name}
+
+
+
+
+
+
+
+
Last refreshed: {formatDate(channel.channel_last_refresh)}
+ {channel.channel_active && (
+
+ Youtube:{' '}
+
+ Active
+
+
+ )}
+ {!channel.channel_active &&
Youtube: Deactivated
}
+
+
+
+
+
+ {channel.channel_views > 0 && (
+
+ )}
+
+ {isAdmin && (
+ <>
+
+ {!showDeleteConfirm && (
+
setShowDeleteConfirm(!showDeleteConfirm)}
+ />
+ )}
+
+ {showDeleteConfirm && (
+
+ Delete {channel.channel_name} including all videos?
+ {
+ await deleteChannel(channelId);
+ navigate(Routes.Channels);
+ }}
+ />{' '}
+ setShowDeleteConfirm(!showDeleteConfirm)}
+ />
+
+ )}
+
+ {reindex &&
Reindex scheduled
}
+ {!reindex && (
+
+ {
+ await queueReindex(channelId, ReindexTypeEnum.channel as ReindexType);
+
+ setReindex(true);
+ setStartNotification(true);
+ }}
+ />{' '}
+ {
+ await queueReindex(
+ channelId,
+ ReindexTypeEnum.channel as ReindexType,
+ true,
+ );
+
+ setReindex(true);
+ setStartNotification(true);
+ }}
+ />
+
+ )}
+ >
+ )}
+
+
+
+
+ {channel.channel_description && (
+
+
+ {channel.channel_description}
+
+
+
setDescriptionExpanded(!descriptionExpanded)}
+ />
+
+ )}
+
+ {channel.channel_tags && (
+
+
+ {channel.channel_tags.map(tag => {
+ return (
+
+ {tag}
+
+ );
+ })}
+
+
+ )}
+
+ {isAdmin && (
+
+ )}
+
+
+
+ >
+ );
+};
+
+export default ChannelAbout;
diff --git a/frontend/src/pages/ChannelBase.tsx b/frontend/src/pages/ChannelBase.tsx
new file mode 100644
index 00000000..45f23457
--- /dev/null
+++ b/frontend/src/pages/ChannelBase.tsx
@@ -0,0 +1,99 @@
+import { Link, Outlet, useOutletContext, useParams } from 'react-router-dom';
+import Routes from '../configuration/routes/RouteList';
+import { ChannelType } from './Channels';
+import { ConfigType } from './Home';
+import { OutletContextType } from './Base';
+import Notifications from '../components/Notifications';
+import { useEffect, useState } from 'react';
+import ChannelBanner from '../components/ChannelBanner';
+import loadChannelNav, { ChannelNavResponseType } from '../api/loader/loadChannelNav';
+
+type ChannelParams = {
+ channelId: string;
+};
+
+export type ChannelResponseType = {
+ data: ChannelType;
+ config: ConfigType;
+};
+
+const ChannelBase = () => {
+ const { channelId } = useParams() as ChannelParams;
+ const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
+
+ const [channelNav, setChannelNav] = useState();
+ const [startNotification, setStartNotification] = useState(false);
+
+ const { has_streams, has_shorts, has_playlists, has_pending } = channelNav || {};
+
+ useEffect(() => {
+ (async () => {
+ const channelNavResponse = await loadChannelNav(channelId);
+
+ setChannelNav(channelNavResponse);
+ })();
+ }, [channelId]);
+
+ if (!channelId) {
+ return [];
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
Videos
+
+ {has_streams && (
+
+ Streams
+
+ )}
+ {has_shorts && (
+
+ Shorts
+
+ )}
+ {has_playlists && (
+
+ Playlists
+
+ )}
+
+ About
+
+ {has_pending && isAdmin && (
+
+ Downloads
+
+ )}
+
+
+
setStartNotification(false)}
+ />
+
+
+
+ >
+ );
+};
+
+export default ChannelBase;
diff --git a/frontend/src/pages/ChannelPlaylist.tsx b/frontend/src/pages/ChannelPlaylist.tsx
new file mode 100644
index 00000000..15a8c8e4
--- /dev/null
+++ b/frontend/src/pages/ChannelPlaylist.tsx
@@ -0,0 +1,122 @@
+import { useLoaderData, useOutletContext, useParams } from 'react-router-dom';
+import Notifications from '../components/Notifications';
+import PlaylistList from '../components/PlaylistList';
+import { ViewLayoutType } from './Home';
+import { ViewStyles } from '../configuration/constants/ViewStyle';
+import { useEffect, useState } from 'react';
+import { OutletContextType } from './Base';
+import Pagination from '../components/Pagination';
+import ScrollToTopOnNavigate from '../components/ScrollToTop';
+import { Helmet } from 'react-helmet';
+import loadPlaylistList from '../api/loader/loadPlaylistList';
+import { PlaylistsResponseType } from './Playlists';
+import iconGridView from '/img/icon-gridview.svg';
+import iconListView from '/img/icon-listview.svg';
+import { UserMeType } from '../api/actions/updateUserConfig';
+
+type ChannelPlaylistLoaderDataType = {
+ userConfig: UserMeType;
+};
+
+const ChannelPlaylist = () => {
+ const { channelId } = useParams();
+ const { userConfig } = useLoaderData() as ChannelPlaylistLoaderDataType;
+ const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
+
+ const userMeConfig = userConfig.config;
+
+ const [showSubedOnly, setShowSubedOnly] = useState(userMeConfig.show_subed_only || false);
+ const [view, setView] = useState(userMeConfig.view_style_playlist || 'grid');
+ const [gridItems, setGridItems] = useState(userMeConfig.grid_items || 3);
+ const [refreshPlaylists, setRefreshPlaylists] = useState(false);
+
+ const [playlistsResponse, setPlaylistsResponse] = useState();
+
+ const playlistList = playlistsResponse?.data;
+ const pagination = playlistsResponse?.paginate;
+
+ const isGridView = view === ViewStyles.grid;
+ const gridView = isGridView ? `boxed-${gridItems}` : '';
+ const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
+
+ useEffect(() => {
+ (async () => {
+ const playlists = await loadPlaylistList({
+ channel: channelId,
+ subscribed: showSubedOnly,
+ });
+
+ setPlaylistsResponse(playlists);
+ setRefreshPlaylists(false);
+ })();
+ }, [channelId, refreshPlaylists, showSubedOnly, currentPage]);
+
+ return (
+ <>
+
+ TA | Channel: Playlists
+
+
+
+
+
+
+
+
Show subscribed only:
+
+ {
+ setShowSubedOnly(!showSubedOnly);
+ }}
+ type="checkbox"
+ />
+ {!showSubedOnly && (
+
+ Off
+
+ )}
+ {showSubedOnly && (
+
+ On
+
+ )}
+
+
+
+
{
+ setView('grid');
+ }}
+ alt="grid view"
+ />
+
{
+ setView('list');
+ }}
+ alt="list view"
+ />
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default ChannelPlaylist;
diff --git a/frontend/src/pages/ChannelShorts.tsx b/frontend/src/pages/ChannelShorts.tsx
new file mode 100644
index 00000000..3fa5635d
--- /dev/null
+++ b/frontend/src/pages/ChannelShorts.tsx
@@ -0,0 +1,16 @@
+import { Helmet } from 'react-helmet';
+import { useParams } from 'react-router-dom';
+
+const ChannelShorts = () => {
+ const { channelId } = useParams();
+
+ return (
+ <>
+
+ TA | Channel: {channel.channel_name}
+
+ >
+ );
+};
+
+export default ChannelShorts;
diff --git a/frontend/src/pages/ChannelStream.tsx b/frontend/src/pages/ChannelStream.tsx
new file mode 100644
index 00000000..4fa352a2
--- /dev/null
+++ b/frontend/src/pages/ChannelStream.tsx
@@ -0,0 +1,16 @@
+import { Helmet } from 'react-helmet';
+import { useParams } from 'react-router-dom';
+
+const ChannelStream = () => {
+ const { channelId } = useParams();
+
+ return (
+ <>
+
+ TA | Channel: {channel.channel_name}
+
+ >
+ );
+};
+
+export default ChannelStream;
diff --git a/frontend/src/pages/ChannelVideo.tsx b/frontend/src/pages/ChannelVideo.tsx
new file mode 100644
index 00000000..c90e4ba2
--- /dev/null
+++ b/frontend/src/pages/ChannelVideo.tsx
@@ -0,0 +1,209 @@
+import { useEffect, useState } from 'react';
+import {
+ Link,
+ useLoaderData,
+ useOutletContext,
+ useParams,
+ useSearchParams,
+} from 'react-router-dom';
+import { SortByType, SortOrderType, VideoResponseType, ViewLayoutType } from './Home';
+import { OutletContextType } from './Base';
+import { UserMeType } from '../api/actions/updateUserConfig';
+import VideoList from '../components/VideoList';
+import Routes from '../configuration/routes/RouteList';
+import Pagination from '../components/Pagination';
+import Filterbar from '../components/Filterbar';
+import { ViewStyleNames, ViewStyles } from '../configuration/constants/ViewStyle';
+import ChannelOverview from '../components/ChannelOverview';
+import loadChannelById from '../api/loader/loadChannelById';
+import { ChannelResponseType } from './ChannelBase';
+import ScrollToTopOnNavigate from '../components/ScrollToTop';
+import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
+import updateWatchedState from '../api/actions/updateWatchedState';
+import { Helmet } from 'react-helmet';
+import Button from '../components/Button';
+import loadVideoListByFilter from '../api/loader/loadVideoListByPage';
+
+type ChannelParams = {
+ channelId: string;
+};
+
+type ChannelVideoLoaderType = {
+ userConfig: UserMeType;
+};
+
+const ChannelVideo = () => {
+ const { channelId } = useParams() as ChannelParams;
+ const { userConfig } = useLoaderData() as ChannelVideoLoaderType;
+ const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
+ const [searchParams] = useSearchParams();
+ const videoId = searchParams.get('videoId');
+
+ const userMeConfig = userConfig.config;
+
+ const [hideWatched, setHideWatched] = useState(userMeConfig.hide_watched || false);
+ const [sortBy, setSortBy] = useState(userMeConfig.sort_by || 'published');
+ const [sortOrder, setSortOrder] = useState(userMeConfig.sort_order || 'asc');
+ const [view, setView] = useState(userMeConfig.view_style_home || 'grid');
+ const [gridItems, setGridItems] = useState(userMeConfig.grid_items || 3);
+ const [refresh, setRefresh] = useState(false);
+
+ const [channelResponse, setChannelResponse] = useState();
+ const [videoResponse, setVideoReponse] = useState();
+
+ const channel = channelResponse?.data;
+ const videoList = videoResponse?.data;
+ const pagination = videoResponse?.paginate;
+
+ const hasVideos = videoResponse?.data?.length !== 0;
+ const showEmbeddedVideo = videoId !== null;
+
+ const isGridView = view === ViewStyles.grid;
+ const gridView = isGridView ? `boxed-${gridItems}` : '';
+ const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
+
+ useEffect(() => {
+ (async () => {
+ if (
+ refresh ||
+ pagination?.current_page === undefined ||
+ currentPage !== pagination?.current_page
+ ) {
+ const channelResponse = await loadChannelById(channelId);
+ const videos = await loadVideoListByFilter({
+ channel: channelId,
+ page: currentPage,
+ watch: hideWatched ? 'unwatched' : undefined,
+ sort: sortBy,
+ order: sortOrder,
+ });
+
+ setChannelResponse(channelResponse);
+ setVideoReponse(videos);
+ setRefresh(false);
+ }
+ })();
+ // Do not add sort, order, hideWatched this will not work as expected!
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [refresh, currentPage, channelId, pagination?.current_page]);
+
+ const aggs = {
+ total_items: { value: '' },
+ total_duration: { value_str: '' },
+ total_size: { value: '' },
+ };
+
+ if (!channel) {
+ return (
+
+
+
Channel {channelId} not found!
+
+ );
+ }
+
+ return (
+ <>
+
+ TA | Channel: {channel.channel_name}
+
+
+
+
+
+
+ {aggs && (
+ <>
+
+ {aggs.total_items.value} videos | {' '}
+ {aggs.total_duration.value_str} playback | {' '}
+ Total size {aggs.total_size.value}
+
+
+ {
+ await updateWatchedState({
+ id: channel.channel_id,
+ is_watched: true,
+ });
+
+ setRefresh(true);
+ }}
+ />{' '}
+ {
+ await updateWatchedState({
+ id: channel.channel_id,
+ is_watched: false,
+ });
+
+ setRefresh(true);
+ }}
+ />
+
+ >
+ )}
+
+
+
+
+
+
+ {showEmbeddedVideo && }
+
+
+ {!hasVideos && (
+ <>
+
No videos found...
+
+ Try going to the downloads page to start the scan
+ and download tasks.
+
+ >
+ )}
+
+
+
+
+ {pagination && (
+
+ )}
+ >
+ );
+};
+
+export default ChannelVideo;
diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx
new file mode 100644
index 00000000..6d5c7380
--- /dev/null
+++ b/frontend/src/pages/Channels.tsx
@@ -0,0 +1,206 @@
+import { useLoaderData, useOutletContext } from 'react-router-dom';
+import loadChannelList from '../api/loader/loadChannelList';
+import iconGridView from '/img/icon-gridview.svg';
+import iconListView from '/img/icon-listview.svg';
+import iconAdd from '/img/icon-add.svg';
+import { useEffect, useState } from 'react';
+import Pagination, { PaginationType } from '../components/Pagination';
+import { ConfigType, ViewLayoutType } from './Home';
+import updateUserConfig, { UserConfigType, UserMeType } from '../api/actions/updateUserConfig';
+import { OutletContextType } from './Base';
+import ChannelList from '../components/ChannelList';
+import ScrollToTopOnNavigate from '../components/ScrollToTop';
+import Notifications from '../components/Notifications';
+import { Helmet } from 'react-helmet';
+import Button from '../components/Button';
+
+type ChannelOverwritesType = {
+ download_format?: string;
+ autodelete_days?: number;
+ index_playlists?: boolean;
+ integrate_sponsorblock?: boolean;
+ subscriptions_channel_size?: number;
+ subscriptions_live_channel_size?: number;
+ subscriptions_shorts_channel_size?: number;
+};
+
+export type ChannelType = {
+ channel_active: boolean;
+ channel_banner_url: string;
+ channel_description: string;
+ channel_id: string;
+ channel_last_refresh: string;
+ channel_name: string;
+ channel_overwrites?: ChannelOverwritesType;
+ channel_subs: number;
+ channel_subscribed: boolean;
+ channel_tags: string[];
+ channel_thumb_url: string;
+ channel_tvart_url: string;
+ channel_views: number;
+};
+
+type ChannelsListResponse = {
+ data: ChannelType[];
+ paginate: PaginationType;
+ config?: ConfigType;
+};
+
+type ChannelsLoaderDataType = {
+ userConfig: UserMeType;
+};
+
+const Channels = () => {
+ const { userConfig } = useLoaderData() as ChannelsLoaderDataType;
+ const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
+
+ const userMeConfig = userConfig.config;
+
+ const [channelListResponse, setChannelListResponse] = useState();
+ const [showSubscribedOnly, setShowSubscribedOnly] = useState(
+ userMeConfig.show_subed_only || false,
+ );
+ const [view, setView] = useState(userMeConfig.view_style_channel || 'grid');
+ const [showAddForm, setShowAddForm] = useState(false);
+ const [refresh, setRefresh] = useState(false);
+
+ const channels = channelListResponse?.data;
+ const pagination = channelListResponse?.paginate;
+ const channelCount = pagination?.total_hits;
+ const hasChannels = channels?.length !== 0;
+
+ useEffect(() => {
+ (async () => {
+ if (
+ userMeConfig.view_style_channel !== view ||
+ userMeConfig.show_subed_only !== showSubscribedOnly
+ ) {
+ const userConfig: UserConfigType = {
+ show_subed_only: showSubscribedOnly,
+ view_style_channel: view,
+ };
+
+ await updateUserConfig(userConfig);
+ }
+ })();
+ }, [showSubscribedOnly, userMeConfig.show_subed_only, userMeConfig.view_style_channel, view]);
+
+ useEffect(() => {
+ (async () => {
+ if (
+ refresh ||
+ pagination?.current_page === undefined ||
+ currentPage !== pagination?.current_page
+ ) {
+ const channelListResponse = await loadChannelList(currentPage, showSubscribedOnly);
+
+ setChannelListResponse(channelListResponse);
+ setRefresh(false);
+ }
+ })();
+ }, [currentPage, showSubscribedOnly, refresh, pagination?.current_page]);
+
+ return (
+ <>
+
+ TA | Channels
+
+
+
+
+
+
Channels
+
+ {isAdmin && (
+
+
{
+ setShowAddForm(!showAddForm);
+ }}
+ src={iconAdd}
+ alt="add-icon"
+ title="Subscribe to Channels"
+ />
+ {showAddForm && (
+
+
+ Subscribe to channels:
+
+
+
+
+
+ )}
+
+ )}
+
+
+
+
+
+
+
Show subscribed only:
+
+ {
+ setShowSubscribedOnly(!showSubscribedOnly);
+ }}
+ type="checkbox"
+ checked={showSubscribedOnly}
+ />
+ {!showSubscribedOnly && (
+
+ Off
+
+ )}
+ {showSubscribedOnly && (
+
+ On
+
+ )}
+
+
+
+
{
+ setView('grid');
+ }}
+ data-origin="channel"
+ data-value="grid"
+ alt="grid view"
+ />
+
{
+ setView('list');
+ }}
+ data-origin="channel"
+ data-value="list"
+ alt="list view"
+ />
+
+
+ {hasChannels &&
Total channels: {channelCount} }
+
+
+ {!hasChannels &&
No channels found... }
+
+ {hasChannels && (
+
+ )}
+
+
+ {pagination && (
+
+ )}
+
+ >
+ );
+};
+
+export default Channels;
diff --git a/frontend/src/pages/Download.tsx b/frontend/src/pages/Download.tsx
new file mode 100644
index 00000000..a51ba376
--- /dev/null
+++ b/frontend/src/pages/Download.tsx
@@ -0,0 +1,362 @@
+import iconRescan from '/img/icon-rescan.svg';
+import iconDownload from '/img/icon-download.svg';
+import iconAdd from '/img/icon-add.svg';
+import iconSubstract from '/img/icon-substract.svg';
+import iconGridView from '/img/icon-gridview.svg';
+import iconListView from '/img/icon-listview.svg';
+import { Fragment, useEffect, useState } from 'react';
+import { useLoaderData, useOutletContext, useSearchParams } from 'react-router-dom';
+import updateUserConfig, { UserConfigType, UserMeType } from '../api/actions/updateUserConfig';
+import { ConfigType, ViewLayoutType } from './Home';
+import loadDownloadQueue from '../api/loader/loadDownloadQueue';
+import { OutletContextType } from './Base';
+import Pagination, { PaginationType } from '../components/Pagination';
+import { ViewStyleNames, ViewStyles } from '../configuration/constants/ViewStyle';
+import updateDownloadQueue from '../api/actions/updateDownloadQueue';
+import updateTaskByName from '../api/actions/updateTaskByName';
+import Notifications from '../components/Notifications';
+import ScrollToTopOnNavigate from '../components/ScrollToTop';
+import { Helmet } from 'react-helmet';
+import Button from '../components/Button';
+import DownloadListItem from '../components/DownloadListItem';
+import loadDownloadAggs, { DownloadAggsType } from '../api/loader/loadDownloadAggs';
+
+type Download = {
+ auto_start: boolean;
+ channel_id: string;
+ channel_indexed: boolean;
+ channel_name: string;
+ duration: string;
+ message?: string;
+ published: string;
+ status: string;
+ timestamp: number;
+ title: string;
+ vid_thumb_url: string;
+ vid_type: string;
+ youtube_id: string;
+ _index: string;
+ _score: number;
+};
+
+export type DownloadResponseType = {
+ data?: Download[];
+ config?: ConfigType;
+ paginate?: PaginationType;
+};
+
+type DownloadLoaderDataType = {
+ userConfig: UserMeType;
+};
+
+const Download = () => {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const { userConfig } = useLoaderData() as DownloadLoaderDataType;
+ const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
+
+ const channelFilterFromUrl = searchParams.get('channel');
+ const userMeConfig = userConfig.config;
+
+ const [view, setView] = useState(userMeConfig.view_style_downloads || 'grid');
+ const [gridItems, setGridItems] = useState(userMeConfig.grid_items || 3);
+ const [showIgnored, setShowIgnored] = useState(userMeConfig.show_ignored_only || false);
+ const [refresh, setRefresh] = useState(false);
+ const [showHiddenForm, setShowHiddenForm] = useState(false);
+ const [downloadPending, setDownloadPending] = useState(false);
+ const [rescanPending, setRescanPending] = useState(false);
+
+ const [downloadQueueText, setDownloadQueueText] = useState('');
+
+ const [downloadResponse, setDownloadResponse] = useState();
+ const [downloadAggsResponse, setDownloadAggsResponse] = useState();
+
+ const downloadList = downloadResponse?.data;
+ const pagination = downloadResponse?.paginate;
+ const channelDownloads = downloadAggsResponse?.channel_downloads;
+ const channelAggsList = channelDownloads?.buckets;
+
+ const downloadCount = pagination?.total_hits;
+
+ const channel_filter_name =
+ downloadResponse?.data?.length && downloadResponse?.data?.length > 0
+ ? downloadResponse?.data[0].channel_name
+ : '';
+
+ const isGridView = view === ViewStyles.grid;
+ const gridView = isGridView ? `boxed-${gridItems}` : '';
+ const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
+
+ useEffect(() => {
+ (async () => {
+ if (
+ userMeConfig.show_ignored_only !== showIgnored ||
+ userMeConfig.view_style_downloads !== view ||
+ userMeConfig.grid_items !== gridItems
+ ) {
+ const userConfig: UserConfigType = {
+ show_ignored_only: showIgnored,
+ [ViewStyleNames.downloads]: view,
+ grid_items: gridItems,
+ };
+
+ await updateUserConfig(userConfig);
+ setRefresh(true);
+ }
+ })();
+ }, [
+ view,
+ gridItems,
+ showIgnored,
+ userMeConfig.show_ignored_only,
+ userMeConfig.view_style_downloads,
+ userMeConfig.grid_items,
+ ]);
+
+ useEffect(() => {
+ (async () => {
+ if (
+ refresh ||
+ pagination?.current_page === undefined ||
+ currentPage !== pagination?.current_page
+ ) {
+ const videos = await loadDownloadQueue(currentPage, channelFilterFromUrl, showIgnored);
+ const downloadAggs = await loadDownloadAggs();
+
+ setDownloadResponse(videos);
+ setDownloadAggsResponse(downloadAggs);
+ setRefresh(false);
+ }
+ })();
+
+ // Do not add showIgnored otherwise it will not update the userconfig first.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [refresh, currentPage, downloadPending]);
+
+ useEffect(() => {
+ setRefresh(true);
+ }, [channelFilterFromUrl]);
+
+ return (
+ <>
+
+ TA | Downloads
+
+
+
+
+
Downloads {channelFilterFromUrl && ` for ${channel_filter_name}`}
+
+
{
+ if (!isDone) {
+ setRescanPending(false);
+ setDownloadPending(false);
+ setRefresh(true);
+ }
+ }}
+ />
+
+
+
+
{
+ setRescanPending(!rescanPending);
+ await updateTaskByName('update_subscribed');
+ }}
+ src={iconRescan}
+ alt="rescan-icon"
+ />
+
Rescan subscriptions
+
+
+
{
+ setDownloadPending(!downloadPending);
+ await updateTaskByName('download_pending');
+ }}
+ src={iconDownload}
+ alt="download-icon"
+ />
+
Start download
+
+
+
{
+ setShowHiddenForm(!showHiddenForm);
+ }}
+ src={iconAdd}
+ alt="add-icon"
+ />
+
Add to download queue
+
+ {showHiddenForm && (
+
+ )}
+
+
+
+
+
Show only ignored videos:
+
+ {
+ setShowIgnored(!showIgnored);
+ }}
+ type="checkbox"
+ checked={showIgnored}
+ />
+ {!showIgnored && (
+
+ Off
+
+ )}
+ {showIgnored && (
+
+ On
+
+ )}
+
+
+
+ {channelAggsList && channelAggsList.length > 1 && (
+
{
+ const value = event.currentTarget.value;
+
+ const params = new URLSearchParams();
+ if (value !== 'all') {
+ params.append('channel', value);
+ }
+
+ setSearchParams(params);
+ }}
+ >
+
+ all
+
+ {channelAggsList.map(channel => {
+ const [name, id] = channel.key;
+ const count = channel.doc_count;
+
+ return (
+
+ {name} ({count})
+
+ );
+ })}
+
+ )}
+
+ {isGridView && (
+
+ {gridItems < 7 && (
+
{
+ setGridItems(gridItems + 1);
+ }}
+ alt="grid plus row"
+ />
+ )}
+ {gridItems > 3 && (
+
{
+ setGridItems(gridItems - 1);
+ }}
+ alt="grid minus row"
+ />
+ )}
+
+ )}
+
+
{
+ setView('grid');
+ }}
+ alt="grid view"
+ />
+
{
+ setView('list');
+ }}
+ alt="list view"
+ />
+
+
+
+ Total videos in queue: {downloadCount}
+ {downloadCount == 10000 && '+'}{' '}
+ {channelFilterFromUrl && (
+ <>
+ {' - from channel '}
+ {channel_filter_name}
+ >
+ )}
+
+
+
+
+
+ {downloadList &&
+ downloadList?.map(download => {
+ return (
+
+
+
+ );
+ })}
+
+
+
+
+ >
+ );
+};
+
+export default Download;
diff --git a/frontend/src/pages/ErrorPage.tsx b/frontend/src/pages/ErrorPage.tsx
new file mode 100644
index 00000000..1f7f6062
--- /dev/null
+++ b/frontend/src/pages/ErrorPage.tsx
@@ -0,0 +1,32 @@
+import { Helmet } from 'react-helmet';
+import { useRouteError } from 'react-router-dom';
+
+// This is not always the correct response
+type ErrorType = {
+ statusText: string;
+ message: string;
+};
+
+const ErrorPage = () => {
+ const error = useRouteError() as ErrorType;
+ console.error('ErrorPage', error);
+
+ return (
+ <>
+
+ TA | Oops!
+
+
+
+
Oops!
+
Sorry, an unexpected error has occurred.
+
+ {error?.statusText}
+ {error?.message}
+
+
+ >
+ );
+};
+
+export default ErrorPage;
diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx
new file mode 100644
index 00000000..8eedef26
--- /dev/null
+++ b/frontend/src/pages/Home.tsx
@@ -0,0 +1,257 @@
+import { useEffect, useState } from 'react';
+import { Link, useLoaderData, useOutletContext, useSearchParams } from 'react-router-dom';
+import Routes from '../configuration/routes/RouteList';
+import Pagination, { PaginationType } from '../components/Pagination';
+import loadVideoListByFilter from '../api/loader/loadVideoListByPage';
+import { UserMeType } from '../api/actions/updateUserConfig';
+import VideoList from '../components/VideoList';
+import { ChannelType } from './Channels';
+import { OutletContextType } from './Base';
+import Filterbar from '../components/Filterbar';
+import { ViewStyleNames, ViewStyles } from '../configuration/constants/ViewStyle';
+import ScrollToTopOnNavigate from '../components/ScrollToTop';
+import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
+import { Helmet } from 'react-helmet';
+
+export type PlayerType = {
+ watched: boolean;
+ duration: number;
+ duration_str: string;
+ progress: number;
+};
+
+export type StatsType = {
+ view_count: number;
+ like_count: number;
+ dislike_count: number;
+ average_rating: number;
+};
+
+export type StreamType = {
+ type: string;
+ index: number;
+ codec: string;
+ width?: number;
+ height?: number;
+ bitrate: number;
+};
+
+export type Subtitles = {
+ ext: string;
+ url: string;
+ name: string;
+ lang: string;
+ source: string;
+ media_url: string;
+};
+
+export type VideoType = {
+ active: boolean;
+ category: string[];
+ channel: ChannelType;
+ date_downloaded: number;
+ description: string;
+ comment_count?: number;
+ media_size: number;
+ media_url: string;
+ player: PlayerType;
+ published: string;
+ stats: StatsType;
+ streams: StreamType[];
+ subtitles: Subtitles[];
+ tags: string[];
+ title: string;
+ vid_last_refresh: string;
+ vid_thumb_base64: boolean;
+ vid_thumb_url: string;
+ vid_type: string;
+ youtube_id: string;
+};
+
+export type DownloadsType = {
+ limit_speed: boolean;
+ sleep_interval: number;
+ autodelete_days: boolean;
+ format: boolean;
+ format_sort: boolean;
+ add_metadata: boolean;
+ add_thumbnail: boolean;
+ subtitle: boolean;
+ subtitle_source: boolean;
+ subtitle_index: boolean;
+ comment_max: boolean;
+ comment_sort: string;
+ cookie_import: boolean;
+ throttledratelimit: boolean;
+ extractor_lang: boolean;
+ integrate_ryd: boolean;
+ integrate_sponsorblock: boolean;
+};
+
+export type ConfigType = {
+ enable_cast: boolean;
+ downloads: DownloadsType;
+};
+
+export type VideoResponseType = {
+ data?: VideoType[];
+ config?: ConfigType;
+ paginate?: PaginationType;
+};
+
+type ContinueVidsType = {
+ data?: VideoType[];
+ config?: ConfigType;
+ paginate?: PaginationType;
+};
+
+type HomeLoaderDataType = {
+ userConfig: UserMeType;
+};
+
+export type SortByType = 'published' | 'downloaded' | 'views' | 'likes' | 'duration' | 'filesize';
+export type SortOrderType = 'asc' | 'desc';
+export type ViewLayoutType = 'grid' | 'list';
+
+const Home = () => {
+ const { userConfig } = useLoaderData() as HomeLoaderDataType;
+ const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
+ const [searchParams] = useSearchParams();
+ const videoId = searchParams.get('videoId');
+
+ const userMeConfig = userConfig.config;
+
+ const [hideWatched, setHideWatched] = useState(userMeConfig.hide_watched || false);
+ const [sortBy, setSortBy] = useState(userMeConfig.sort_by || 'published');
+ const [sortOrder, setSortOrder] = useState(userMeConfig.sort_order || 'asc');
+ const [view, setView] = useState(userMeConfig.view_style_home || 'grid');
+ const [gridItems, setGridItems] = useState(userMeConfig.grid_items || 3);
+ const [showHidden, setShowHidden] = useState(false);
+ const [refreshVideoList, setRefreshVideoList] = useState(false);
+
+ const [videoResponse, setVideoReponse] = useState();
+ const [continueVideoResponse, setContinueVideoResponse] = useState();
+
+ const videoList = videoResponse?.data;
+ const pagination = videoResponse?.paginate;
+ const continueVideos = continueVideoResponse?.data;
+
+ const hasVideos = videoResponse?.data?.length !== 0;
+ const showEmbeddedVideo = videoId !== null;
+
+ const isGridView = view === ViewStyles.grid;
+ const gridView = isGridView ? `boxed-${gridItems}` : '';
+ const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
+
+ useEffect(() => {
+ (async () => {
+ if (
+ refreshVideoList ||
+ pagination?.current_page === undefined ||
+ currentPage !== pagination?.current_page
+ ) {
+ const videos = await loadVideoListByFilter({
+ page: currentPage,
+ watch: hideWatched ? 'unwatched' : undefined,
+ sort: sortBy,
+ order: sortOrder,
+ });
+
+ try {
+ const continueVideoResponse = await loadVideoListByFilter({ watch: 'continue' });
+ setContinueVideoResponse(continueVideoResponse);
+ } catch (error) {
+ console.log('Server error on continue vids?');
+ }
+
+ setVideoReponse(videos);
+
+ setRefreshVideoList(false);
+ }
+ })();
+ // Do not add sort, order, hideWatched this will not work as expected!
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [refreshVideoList, currentPage, pagination?.current_page]);
+
+ return (
+ <>
+
+ TubeArchivist
+
+
+
+ {continueVideos && continueVideos.length > 0 && (
+ <>
+
+
Continue Watching
+
+
+
+
+ >
+ )}
+
+
+
Recent Videos
+
+
+
+
+
+ {showEmbeddedVideo && }
+
+
+
+ {!hasVideos && (
+ <>
+
No videos found...
+
+ If you've already added a channel or playlist, try going to the{' '}
+ downloads page to start the scan and download
+ tasks.
+
+ >
+ )}
+
+ {hasVideos && (
+
+ )}
+
+
+
+ {pagination && (
+
+ )}
+ >
+ );
+};
+
+export default Home;
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
new file mode 100644
index 00000000..4448976f
--- /dev/null
+++ b/frontend/src/pages/Login.tsx
@@ -0,0 +1,106 @@
+import { useState } from 'react';
+import Routes from '../configuration/routes/RouteList';
+import { useNavigate } from 'react-router-dom';
+import importColours, { ColourConstant, ColourVariants } from '../configuration/colours/getColours';
+import { Helmet } from 'react-helmet';
+import Button from '../components/Button';
+import signIn from '../api/actions/signIn';
+
+const Login = () => {
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [saveLogin, setSaveLogin] = useState(false);
+ const navigate = useNavigate();
+
+ importColours(ColourConstant.Dark as ColourVariants);
+
+ const form_error = false;
+
+ const handleSubmit = async (event: { preventDefault: () => void }) => {
+ event.preventDefault();
+
+ const loginResponse = await signIn(username, password, saveLogin);
+
+ const signedIn = loginResponse.status === 200;
+
+ if (signedIn) {
+ navigate(Routes.Home);
+ } else {
+ navigate(Routes.Login);
+ }
+ };
+
+ return (
+ <>
+
+ TA | Welcome
+
+
+
+
Tube Archivist
+
Your Self Hosted YouTube Media Server
+
+ {form_error &&
Failed to login.
}
+
+
+
+
+
+ Github
+
+ {' '}
+
+
+ Donate
+
+
+
+
+
+ >
+ );
+};
+
+export default Login;
diff --git a/frontend/src/pages/Logout.tsx b/frontend/src/pages/Logout.tsx
new file mode 100644
index 00000000..f8f7e786
--- /dev/null
+++ b/frontend/src/pages/Logout.tsx
@@ -0,0 +1,17 @@
+import { useNavigate } from 'react-router-dom';
+import importColours, { ColourConstant, ColourVariants } from '../configuration/colours/getColours';
+import Routes from '../configuration/routes/RouteList';
+import { useEffect } from 'react';
+
+const Logout = () => {
+ importColours(ColourConstant.Dark as ColourVariants);
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ navigate(Routes.Login);
+ }, []);
+
+ return <>Logout>;
+};
+
+export default Logout;
diff --git a/frontend/src/pages/Playlist.tsx b/frontend/src/pages/Playlist.tsx
new file mode 100644
index 00000000..c554474d
--- /dev/null
+++ b/frontend/src/pages/Playlist.tsx
@@ -0,0 +1,390 @@
+import { useEffect, useState } from 'react';
+import {
+ Link,
+ useLoaderData,
+ useNavigate,
+ useOutletContext,
+ useParams,
+ useSearchParams,
+} from 'react-router-dom';
+import { UserMeType } from '../api/actions/updateUserConfig';
+import loadPlaylistById from '../api/loader/loadPlaylistById';
+import { OutletContextType } from './Base';
+import { ConfigType, VideoType, ViewLayoutType } from './Home';
+import Filterbar from '../components/Filterbar';
+import { PlaylistEntryType } from './Playlists';
+import loadChannelById from '../api/loader/loadChannelById';
+import VideoList from '../components/VideoList';
+import Pagination, { PaginationType } from '../components/Pagination';
+import ChannelOverview from '../components/ChannelOverview';
+import Linkify from '../components/Linkify';
+import { ViewStyleNames, ViewStyles } from '../configuration/constants/ViewStyle';
+import updatePlaylistSubscription from '../api/actions/updatePlaylistSubscription';
+import deletePlaylist from '../api/actions/deletePlaylist';
+import Routes from '../configuration/routes/RouteList';
+import { ChannelResponseType } from './ChannelBase';
+import formatDate from '../functions/formatDates';
+import queueReindex from '../api/actions/queueReindex';
+import updateWatchedState from '../api/actions/updateWatchedState';
+import ScrollToTopOnNavigate from '../components/ScrollToTop';
+import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
+import { Helmet } from 'react-helmet';
+import Button from '../components/Button';
+import loadVideoListByFilter from '../api/loader/loadVideoListByPage';
+
+export type PlaylistType = {
+ playlist_active: boolean;
+ playlist_channel: string;
+ playlist_channel_id: string;
+ playlist_description: string;
+ playlist_entries: PlaylistEntryType[];
+ playlist_id: string;
+ playlist_last_refresh: string;
+ playlist_name: string;
+ playlist_subscribed: boolean;
+ playlist_thumbnail: string;
+ playlist_type: string;
+ _index: string;
+ _score: number;
+};
+
+type PlaylistLoaderDataType = {
+ userConfig: UserMeType;
+};
+
+export type PlaylistResponseType = {
+ data?: PlaylistType;
+ config?: ConfigType;
+};
+
+export type VideoResponseType = {
+ data?: VideoType[];
+ config?: ConfigType;
+ paginate?: PaginationType;
+};
+
+const Playlist = () => {
+ const { playlistId } = useParams();
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const videoId = searchParams.get('videoId');
+
+ const { userConfig } = useLoaderData() as PlaylistLoaderDataType;
+ const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
+
+ const userMeConfig = userConfig.config;
+
+ const [hideWatched, setHideWatched] = useState(userMeConfig.hide_watched || false);
+ const [view, setView] = useState(userMeConfig.view_style_home || 'grid');
+ const [gridItems, setGridItems] = useState(userMeConfig.grid_items || 3);
+ const [descriptionExpanded, setDescriptionExpanded] = useState(false);
+ const [refresh, setRefresh] = useState(false);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [reindex, setReindex] = useState(false);
+
+ const [playlistResponse, setPlaylistResponse] = useState();
+ const [channelResponse, setChannelResponse] = useState();
+ const [videoResponse, setVideoResponse] = useState();
+
+ const playlist = playlistResponse?.data;
+ const channel = channelResponse?.data;
+ const videos = videoResponse?.data;
+ const pagination = videoResponse?.paginate;
+
+ const palylistEntries = playlistResponse?.data?.playlist_entries;
+ const videoArchivedCount = Number(palylistEntries?.filter(video => video.downloaded).length);
+ const videoInPlaylistCount = pagination?.total_hits;
+ const showEmbeddedVideo = videoId !== null;
+
+ const isGridView = view === ViewStyles.grid;
+ const gridView = isGridView ? `boxed-${gridItems}` : '';
+ const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
+
+ useEffect(() => {
+ (async () => {
+ if (
+ refresh ||
+ pagination?.current_page === undefined ||
+ currentPage !== pagination?.current_page
+ ) {
+ const playlist = await loadPlaylistById(playlistId);
+ const video = await loadVideoListByFilter({
+ playlist: playlistId,
+ page: currentPage,
+ watch: hideWatched ? 'unwatched' : undefined,
+ sort: 'downloaded', // downloaded or published? or playlist sort order?
+ });
+
+ const isCustomPlaylist = playlist?.data?.playlist_type === 'custom';
+ if (!isCustomPlaylist) {
+ const channel = await loadChannelById(playlist.data.playlist_channel_id);
+
+ setChannelResponse(channel);
+ }
+
+ setPlaylistResponse(playlist);
+ setVideoResponse(video);
+ setRefresh(false);
+ }
+ })();
+ // Do not add hideWatched this will not work as expected!
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [playlistId, refresh, currentPage, pagination?.current_page]);
+
+ if (!playlistId || !playlist) {
+ return `Playlist ${playlistId} not found!`;
+ }
+
+ const isCustomPlaylist = playlist.playlist_type === 'custom';
+
+ return (
+ <>
+
+ TA | Playlist: {playlist.playlist_name}
+
+
+
+
+
{playlist.playlist_name}
+
+
+ {!isCustomPlaylist && channel && (
+
+ )}
+
+
+
+
Last refreshed: {formatDate(playlist.playlist_last_refresh)}
+ {!isCustomPlaylist && (
+ <>
+
+ Playlist:
+ {playlist.playlist_subscribed && (
+ <>
+ {isAdmin && (
+ {
+ await updatePlaylistSubscription(playlistId, false);
+
+ setRefresh(true);
+ }}
+ />
+ )}
+ >
+ )}{' '}
+ {!playlist.playlist_subscribed && (
+ {
+ await updatePlaylistSubscription(playlistId, true);
+
+ setRefresh(true);
+ }}
+ />
+ )}
+
+ {playlist.playlist_active && (
+
+ Youtube:{' '}
+
+ Active
+
+
+ )}
+ {!playlist.playlist_active &&
Youtube: Deactivated
}
+ >
+ )}
+
+ {!showDeleteConfirm && (
+
setShowDeleteConfirm(!showDeleteConfirm)}
+ />
+ )}
+
+ {showDeleteConfirm && (
+
+ Delete {playlist.playlist_name}?
+
+ {
+ await deletePlaylist(playlistId, false);
+ navigate(Routes.Playlists);
+ }}
+ />
+
+ {
+ await deletePlaylist(playlistId, true);
+ navigate(Routes.Playlists);
+ }}
+ />
+
+
+ setShowDeleteConfirm(!showDeleteConfirm)} />
+
+ )}
+
+
+
+
+ {videoArchivedCount > 0 && (
+ <>
+
+ Total Videos archived: {videoArchivedCount}/{videoInPlaylistCount}
+
+
+ {
+ await updateWatchedState({
+ id: playlistId,
+ is_watched: true,
+ });
+
+ setRefresh(true);
+ }}
+ />{' '}
+ {
+ await updateWatchedState({
+ id: playlistId,
+ is_watched: false,
+ });
+
+ setRefresh(true);
+ }}
+ />
+
+ >
+ )}
+
+ {reindex &&
Reindex scheduled
}
+ {!reindex && (
+
+ {!isCustomPlaylist && (
+ {
+ setReindex(true);
+
+ await queueReindex(playlist.playlist_id, 'playlist');
+ }}
+ />
+ )}{' '}
+ {
+ setReindex(true);
+
+ await queueReindex(playlist.playlist_id, 'playlist', true);
+ }}
+ />
+
+ )}
+
+
+
+
+ {playlist.playlist_description && (
+
+
+ {playlist.playlist_description}
+
+
+
setDescriptionExpanded(!descriptionExpanded)}
+ />
+
+ )}
+
+
+
+
+
+
+ {showEmbeddedVideo && }
+
+
+
+ {videoInPlaylistCount === 0 && (
+ <>
+
No videos found...
+ {isCustomPlaylist && (
+
+ Try going to the home page to add videos to this
+ playlist.
+
+ )}
+
+ {!isCustomPlaylist && (
+
+ Try going to the downloads page to start the
+ scan and download tasks.
+
+ )}
+ >
+ )}
+ {videoInPlaylistCount !== 0 && (
+
+ )}
+
+
+
+
+ >
+ );
+};
+
+export default Playlist;
diff --git a/frontend/src/pages/Playlists.tsx b/frontend/src/pages/Playlists.tsx
new file mode 100644
index 00000000..5b995fc4
--- /dev/null
+++ b/frontend/src/pages/Playlists.tsx
@@ -0,0 +1,219 @@
+import { useEffect, useState } from 'react';
+import { useLoaderData, useOutletContext } from 'react-router-dom';
+
+import iconAdd from '/img/icon-add.svg';
+import iconGridView from '/img/icon-gridview.svg';
+import iconListView from '/img/icon-listview.svg';
+
+import { OutletContextType } from './Base';
+import updateUserConfig, { UserConfigType, UserMeType } from '../api/actions/updateUserConfig';
+import loadPlaylistList from '../api/loader/loadPlaylistList';
+import { ConfigType, ViewLayoutType } from './Home';
+import Pagination, { PaginationType } from '../components/Pagination';
+import PlaylistList from '../components/PlaylistList';
+import { PlaylistType } from './Playlist';
+import updatePlaylistSubscription from '../api/actions/updatePlaylistSubscription';
+import createCustomPlaylist from '../api/actions/createCustomPlaylist';
+import ScrollToTopOnNavigate from '../components/ScrollToTop';
+import { Helmet } from 'react-helmet';
+import Button from '../components/Button';
+
+export type PlaylistEntryType = {
+ youtube_id: string;
+ title: string;
+ uploader: string;
+ idx: number;
+ downloaded: boolean;
+};
+
+export type PlaylistsResponseType = {
+ data?: PlaylistType[];
+ config?: ConfigType;
+ paginate?: PaginationType;
+};
+
+type PlaylistLoaderDataType = {
+ userConfig: UserMeType;
+};
+
+const Playlists = () => {
+ const { userConfig } = useLoaderData() as PlaylistLoaderDataType;
+ const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
+
+ const userMeConfig = userConfig.config;
+
+ const [showSubedOnly, setShowSubedOnly] = useState(userMeConfig.show_subed_only || false);
+ const [view, setView] = useState(userMeConfig.view_style_playlist || 'grid');
+ const [showAddForm, setShowAddForm] = useState(false);
+ const [refresh, setRefresh] = useState(false);
+ const [playlistsToAddText, setPlaylistsToAddText] = useState('');
+ const [customPlaylistsToAddText, setCustomPlaylistsToAddText] = useState('');
+
+ const [playlistResponse, setPlaylistReponse] = useState();
+
+ const playlistList = playlistResponse?.data;
+ const pagination = playlistResponse?.paginate;
+
+ const hasPlaylists = playlistResponse?.data?.length !== 0;
+
+ useEffect(() => {
+ (async () => {
+ if (
+ userMeConfig.view_style_playlist !== view ||
+ userMeConfig.show_subed_only !== showSubedOnly
+ ) {
+ const userConfig: UserConfigType = {
+ show_subed_only: showSubedOnly,
+ view_style_playlist: view,
+ };
+
+ await updateUserConfig(userConfig);
+ }
+ })();
+ }, [showSubedOnly, userMeConfig.show_subed_only, userMeConfig.view_style_playlist, view]);
+
+ useEffect(() => {
+ (async () => {
+ if (
+ refresh ||
+ pagination?.current_page === undefined ||
+ currentPage !== pagination?.current_page
+ ) {
+ const playlist = await loadPlaylistList({ page: currentPage });
+
+ setPlaylistReponse(playlist);
+ setRefresh(false);
+ }
+ })();
+ }, [refresh, currentPage, showSubedOnly, view, pagination?.current_page]);
+
+ return (
+ <>
+
+ TA | Playlists
+
+
+
+
+
+
Playlists
+
+ {isAdmin && (
+
+
{
+ setShowAddForm(!showAddForm);
+ }}
+ src={iconAdd}
+ alt="add-icon"
+ title="Subscribe to Playlists"
+ />
+ {showAddForm && (
+
+
+ Subscribe to playlists:
+
+
+
+ Or create custom playlist:
+
+
+ )}
+
+ )}
+
+
+
+
+
+
+
Show subscribed only:
+
+ {
+ setShowSubedOnly(!showSubedOnly);
+ }}
+ type="checkbox"
+ />
+ {!showSubedOnly && (
+
+ Off
+
+ )}
+ {showSubedOnly && (
+
+ On
+
+ )}
+
+
+
+
{
+ setView('grid');
+ }}
+ alt="grid view"
+ />
+
{
+ setView('list');
+ }}
+ alt="list view"
+ />
+
+
+
+
+ {!hasPlaylists &&
No playlists found... }
+
+ {hasPlaylists && (
+
+ )}
+
+
+
+
+ >
+ );
+};
+
+export default Playlists;
diff --git a/frontend/src/pages/Search.tsx b/frontend/src/pages/Search.tsx
new file mode 100644
index 00000000..68765792
--- /dev/null
+++ b/frontend/src/pages/Search.tsx
@@ -0,0 +1,170 @@
+import { useLoaderData, useSearchParams } from 'react-router-dom';
+import { UserMeType } from '../api/actions/updateUserConfig';
+import { useEffect, useState } from 'react';
+import { VideoType, ViewLayoutType } from './Home';
+import loadSearch from '../api/loader/loadSearch';
+import { PlaylistType } from './Playlist';
+import { ChannelType } from './Channels';
+import VideoList from '../components/VideoList';
+import ChannelList from '../components/ChannelList';
+import PlaylistList from '../components/PlaylistList';
+import SubtitleList from '../components/SubtitleList';
+import { ViewStyles } from '../configuration/constants/ViewStyle';
+import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
+import { Helmet } from 'react-helmet';
+import SearchExampleQueries from '../components/SearchExampleQueries';
+
+const EmptySearchResponse: SearchResultsType = {
+ results: {
+ video_results: [],
+ channel_results: [],
+ playlist_results: [],
+ fulltext_results: [],
+ },
+ queryType: 'simple',
+};
+
+type SearchResultType = {
+ video_results: VideoType[];
+ channel_results: ChannelType[];
+ playlist_results: PlaylistType[];
+ fulltext_results: [];
+};
+
+type SearchResultsType = {
+ results: SearchResultType;
+ queryType: string;
+};
+
+type SearchLoaderDataType = {
+ userConfig: UserMeType;
+};
+
+const Search = () => {
+ const { userConfig } = useLoaderData() as SearchLoaderDataType;
+ const [searchParams] = useSearchParams();
+ const videoId = searchParams.get('videoId');
+ const userMeConfig = userConfig.config;
+
+ const view = (userMeConfig.view_style_home || ViewStyles.grid) as ViewLayoutType;
+ const gridItems = userMeConfig.grid_items || 3;
+
+ const [searchQuery, setSearchQuery] = useState('');
+ const [searchResults, setSearchResults] = useState();
+
+ const [refresh, setRefresh] = useState(false);
+
+ const videoList = searchResults?.results.video_results;
+ const channelList = searchResults?.results.channel_results;
+ const playlistList = searchResults?.results.playlist_results;
+ const fulltextList = searchResults?.results.fulltext_results;
+ const queryType = searchResults?.queryType;
+ const showEmbeddedVideo = videoId !== null;
+
+ const hasSearchQuery = searchQuery.length > 0;
+ const hasVideos = Number(videoList?.length) > 0;
+ const hasChannels = Number(channelList?.length) > 0;
+ const hasPlaylist = Number(playlistList?.length) > 0;
+ const hasFulltext = Number(fulltextList?.length) > 0;
+
+ const isSimpleQuery = queryType === 'simple';
+ const isVideoQuery = queryType === 'video' || isSimpleQuery;
+ const isChannelQuery = queryType === 'channel' || isSimpleQuery;
+ const isPlaylistQuery = queryType === 'playlist' || isSimpleQuery;
+ const isFullTextQuery = queryType === 'full' || isSimpleQuery;
+
+ const isGridView = view === ViewStyles.grid;
+ const gridView = isGridView ? `boxed-${gridItems}` : '';
+ const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
+
+ useEffect(() => {
+ (async () => {
+ if (!hasSearchQuery) {
+ setSearchResults(EmptySearchResponse);
+
+ return;
+ }
+
+ const searchResults = await loadSearch(searchQuery);
+
+ setSearchResults(searchResults);
+ setRefresh(false);
+ })();
+ }, [searchQuery, refresh, hasSearchQuery]);
+
+ return (
+ <>
+
+ TubeArchivist
+
+ {showEmbeddedVideo && }
+
+
+
Search your Archive
+
+
+
+ {
+ setSearchQuery(event.target.value);
+ }}
+ />
+
+
+
+ {hasSearchQuery && isVideoQuery && (
+
+ )}
+
+ {hasSearchQuery && isChannelQuery && (
+
+
Channel Results
+
+
+
+
+ )}
+
+ {hasSearchQuery && isPlaylistQuery && (
+
+ )}
+
+ {hasSearchQuery && isFullTextQuery && (
+
+
Fulltext Results
+
+
+
+
+ )}
+
+
+ {!hasVideos && !hasChannels && !hasPlaylist && !hasFulltext &&
}
+
+ >
+ );
+};
+
+export default Search;
diff --git a/frontend/src/pages/SettingsActions.tsx b/frontend/src/pages/SettingsActions.tsx
new file mode 100644
index 00000000..e89fe661
--- /dev/null
+++ b/frontend/src/pages/SettingsActions.tsx
@@ -0,0 +1,245 @@
+import { useEffect, useState } from 'react';
+import loadBackupList from '../api/loader/loadBackupList';
+import SettingsNavigation from '../components/SettingsNavigation';
+import deleteDownloadQueueByFilter from '../api/actions/deleteDownloadQueueByFilter';
+import updateTaskByName from '../api/actions/updateTaskByName';
+import queueBackup from '../api/actions/queueBackup';
+import restoreBackup from '../api/actions/restoreBackup';
+import Notifications from '../components/Notifications';
+import { Helmet } from 'react-helmet';
+import Button from '../components/Button';
+
+type Backup = {
+ filename: string;
+ file_path: string;
+ file_size: number;
+ timestamp: string;
+ reason: string;
+};
+
+type BackupListType = Backup[];
+
+const SettingsActions = () => {
+ const [deleteIgnored, setDeleteIgnored] = useState(false);
+ const [deletePending, setDeletePending] = useState(false);
+ const [processingImports, setProcessingImports] = useState(false);
+ const [reEmbed, setReEmbed] = useState(false);
+ const [backupStarted, setBackupStarted] = useState(false);
+ const [isRestoringBackup, setIsRestoringBackup] = useState(false);
+ const [reScanningFileSystem, setReScanningFileSystem] = useState(false);
+
+ const [backupListResponse, setBackupListResponse] = useState();
+
+ const backups = backupListResponse;
+ const hasBackups = !!backups && backups?.length > 0;
+
+ useEffect(() => {
+ (async () => {
+ const backupListResponse = await loadBackupList();
+
+ setBackupListResponse(backupListResponse);
+ })();
+ }, []);
+
+ return (
+ <>
+
+ TA | Actions
+
+
+
+
{
+ setDeleteIgnored(false);
+ setDeletePending(false);
+ setProcessingImports(false);
+ setReEmbed(false);
+ setBackupStarted(false);
+ setIsRestoringBackup(false);
+ setReScanningFileSystem(false);
+ }}
+ />
+
+
+
Actions
+
+
+
Delete download queue
+
Delete your pending or previously ignored videos from your download queue.
+ {deleteIgnored &&
Deleting download queue: ignored
}
+ {!deleteIgnored && (
+
{
+ await deleteDownloadQueueByFilter('ignore');
+ setDeleteIgnored(true);
+ }}
+ />
+ )}{' '}
+ {deletePending && Deleting download queue: pending
}
+ {!deletePending && (
+ {
+ await deleteDownloadQueueByFilter('pending');
+ setDeletePending(true);
+ }}
+ />
+ )}
+
+
+
Manual media files import.
+
+ Add files to the cache/import folder. Make
+ sure to follow the instructions in the Github{' '}
+
+ Wiki
+
+ .
+
+
+ {processingImports &&
Processing import
}
+ {!processingImports && (
+
{
+ await updateTaskByName('manual_import');
+ setProcessingImports(true);
+ }}
+ />
+ )}
+
+
+
+
Embed thumbnails into media file.
+
Set extracted youtube thumbnail as cover art of the media file.
+
+ {reEmbed &&
Processing thumbnails
}
+ {!reEmbed && (
+
{
+ await updateTaskByName('resync_thumbs');
+ setReEmbed(true);
+ }}
+ />
+ )}
+
+
+
+
ZIP file index backup
+
+ Export your database to a zip file stored at{' '}
+ cache/backup .
+
+
+
+ Zip file backups are very slow for large archives and consistency is not guaranteed,
+ use snapshots instead. Make sure no other tasks are running when creating a Zip file
+ backup.
+
+
+
+ {backupStarted &&
Backing up archive
}
+ {!backupStarted && (
+
{
+ await queueBackup();
+ setBackupStarted(true);
+ }}
+ />
+ )}
+
+
+
+
Restore from backup
+
+ Danger Zone : This will replace your existing index
+ with the backup.
+
+
+ Restore from available backup files from{' '}
+ cache/backup .
+
+ {!hasBackups &&
No backups found.
}
+ {hasBackups && (
+ <>
+
+
+ Timestamp
+ Source
+ Filename
+
+ {isRestoringBackup &&
Restoring from backup
}
+ {!isRestoringBackup &&
+ backups.map(backup => {
+ return (
+
+ {
+ await restoreBackup(backup.filename);
+ setIsRestoringBackup(true);
+ }}
+ />
+ {backup.timestamp}
+ {backup.reason}
+ {backup.filename}
+
+ );
+ })}
+ >
+ )}
+
+
+
Rescan filesystem
+
+ Danger Zone : This will delete the metadata of
+ deleted videos from the filesystem.
+
+
+ Rescan your media folder looking for missing videos and clean up index. More infos on
+ the Github{' '}
+
+ Wiki
+
+ .
+
+
+ {reScanningFileSystem &&
File system scan in progress
}
+ {!reScanningFileSystem && (
+
{
+ await updateTaskByName('rescan_filesystem');
+ setReScanningFileSystem(true);
+ }}
+ />
+ )}
+
+
+
+ >
+ );
+};
+
+export default SettingsActions;
diff --git a/frontend/src/pages/SettingsApplication.tsx b/frontend/src/pages/SettingsApplication.tsx
new file mode 100644
index 00000000..11c27762
--- /dev/null
+++ b/frontend/src/pages/SettingsApplication.tsx
@@ -0,0 +1,969 @@
+import { useEffect, useState } from 'react';
+import loadSnapshots from '../api/loader/loadSnapshots';
+import Notifications from '../components/Notifications';
+import PaginationDummy from '../components/PaginationDummy';
+import SettingsNavigation from '../components/SettingsNavigation';
+import restoreSnapshot from '../api/actions/restoreSnapshot';
+import queueSnapshot from '../api/actions/queueSnapshot';
+import updateCookie, { ValidatedCookieType } from '../api/actions/updateCookie';
+import deleteApiToken from '../api/actions/deleteApiToken';
+import { Helmet } from 'react-helmet';
+import Button from '../components/Button';
+import loadAppsettingsConfig, { AppSettingsConfigType } from '../api/loader/loadAppsettingsConfig';
+import updateAppsettingsConfig from '../api/actions/updateAppsettingsConfig';
+import loadApiToken from '../api/loader/loadApiToken';
+
+type SnapshotType = {
+ id: string;
+ state: string;
+ es_version: string;
+ start_date: string;
+ end_date: string;
+ end_stamp: number;
+ duration_s: number;
+};
+
+type SnapshotListType = {
+ next_exec: number;
+ next_exec_str: string;
+ expire_after: string;
+ snapshots?: SnapshotType[];
+};
+
+type SettingsApplicationReponses = {
+ snapshots?: SnapshotListType;
+ appSettingsConfig?: AppSettingsConfigType;
+ apiToken: string;
+};
+
+const SettingsApplication = () => {
+ const [response, setResponse] = useState({
+ snapshots: undefined,
+ appSettingsConfig: undefined,
+ apiToken: '',
+ });
+
+ const snapshots = response?.snapshots;
+ const appSettingsConfig = response?.appSettingsConfig;
+ const apiToken = response.apiToken;
+
+ // Subscriptions
+ const [videoPageSize, setVideoPageSize] = useState(
+ appSettingsConfig?.subscriptions.channel_size || 0,
+ );
+ const [livePageSize, setLivePageSize] = useState(
+ appSettingsConfig?.subscriptions.live_channel_size || 0,
+ );
+ const [shortPageSize, setShortPageSize] = useState(
+ appSettingsConfig?.subscriptions.shorts_channel_size || 0,
+ );
+ const [isAutostart, setIsAutostart] = useState(
+ appSettingsConfig?.subscriptions.auto_start || false,
+ );
+
+ // Downloads
+ const [currentDownloadSpeed, setCurrentDownloadSpeed] = useState(
+ appSettingsConfig?.downloads.limit_speed || 0,
+ );
+ const [currentThrottledRate, setCurrentThrottledRate] = useState(
+ appSettingsConfig?.downloads.throttledratelimit || 0,
+ );
+ const [currentScrapingSleep, setCurrentScrapingSleep] = useState(
+ appSettingsConfig?.downloads.sleep_interval || 0,
+ );
+ const [currentAutodelete, setCurrentAutodelete] = useState(
+ appSettingsConfig?.downloads.autodelete_days || 0,
+ );
+
+ // Download Format
+ const [downloadsFormat, setDownloadsFormat] = useState(appSettingsConfig?.downloads.format || '');
+ const [downloadsFormatSort, setDownloadsFormatSort] = useState(
+ appSettingsConfig?.downloads.format_sort || '',
+ );
+ const [downloadsExtractorLang, setDownloadsExtractorLang] = useState(
+ appSettingsConfig?.downloads.extractor_lang || '',
+ );
+ const [embedMetadata, setEmbedMetadata] = useState(
+ appSettingsConfig?.downloads.add_metadata || false,
+ );
+ const [embedThumbnail, setEmbedThumbnail] = useState(
+ appSettingsConfig?.downloads.add_thumbnail || false,
+ );
+
+ // Subtitles
+ const [subtitleLang, setSubtitleLang] = useState(appSettingsConfig?.downloads.subtitle || '');
+ const [subtitleSource, setSubtitleSource] = useState(
+ appSettingsConfig?.downloads.subtitle_source || '',
+ );
+ const [indexSubtitles, setIndexSubtitles] = useState(
+ appSettingsConfig?.downloads.subtitle_index || false,
+ );
+
+ // Comments
+ const [commentsMax, setCommentsMax] = useState(appSettingsConfig?.downloads.comment_max || 0);
+ const [commentsSort, setCommentsSort] = useState(appSettingsConfig?.downloads.comment_sort || '');
+
+ // Cookie
+ const [cookieImport, setCookieImport] = useState(
+ appSettingsConfig?.downloads.cookie_import || false,
+ );
+ const [validatingCookie, setValidatingCookie] = useState(false);
+ const [cookieResponse, setCookieResponse] = useState();
+
+ // Integrations
+ const [showApiToken, setShowApiToken] = useState(false);
+ const [downloadDislikes, setDownloadDislikes] = useState(
+ appSettingsConfig?.downloads.integrate_ryd || false,
+ );
+ const [enableSponsorBlock, setEnableSponsorBlock] = useState(
+ appSettingsConfig?.downloads.integrate_sponsorblock || false,
+ );
+ const [resetTokenResponse, setResetTokenResponse] = useState({});
+
+ // Snapshots
+ const [enableSnapshots, setEnableSnapshots] = useState(
+ appSettingsConfig?.application.enable_snapshot || false,
+ );
+ const [isSnapshotQueued, setIsSnapshotQueued] = useState(false);
+ const [restoringSnapshot, setRestoringSnapshot] = useState(false);
+
+ const onSubmit = async () => {
+ return await updateAppsettingsConfig({
+ application: {
+ enable_snapshot: enableSnapshots,
+ },
+ downloads: {
+ limit_speed: currentDownloadSpeed,
+ sleep_interval: currentScrapingSleep,
+ autodelete_days: currentAutodelete,
+ format: downloadsFormat,
+ format_sort: downloadsFormatSort,
+ add_metadata: embedMetadata,
+ add_thumbnail: embedThumbnail,
+ subtitle: subtitleLang,
+ subtitle_source: subtitleSource,
+ subtitle_index: indexSubtitles,
+ comment_max: commentsMax,
+ comment_sort: commentsSort,
+ cookie_import: cookieImport,
+ throttledratelimit: currentThrottledRate,
+ extractor_lang: downloadsExtractorLang,
+ integrate_ryd: downloadDislikes,
+ integrate_sponsorblock: enableSponsorBlock,
+ },
+ subscriptions: {
+ auto_start: isAutostart,
+ channel_size: videoPageSize,
+ live_channel_size: livePageSize,
+ shorts_channel_size: shortPageSize,
+ },
+ });
+ };
+
+ useEffect(() => {
+ (async () => {
+ const snapshotResponse = await loadSnapshots();
+ const appSettingsConfig = await loadAppsettingsConfig();
+ const apiToken = await loadApiToken();
+
+ setResponse({
+ snapshots: snapshotResponse,
+ appSettingsConfig,
+ apiToken: apiToken.token,
+ });
+ })();
+ }, []);
+
+ return (
+ <>
+
+ TA | Application Settings
+
+
+
+
+
+
+
Application Configurations
+
+
+
+
+
+ >
+ );
+};
+
+export default SettingsApplication;
diff --git a/frontend/src/pages/SettingsDashboard.tsx b/frontend/src/pages/SettingsDashboard.tsx
new file mode 100644
index 00000000..fd1f98d3
--- /dev/null
+++ b/frontend/src/pages/SettingsDashboard.tsx
@@ -0,0 +1,263 @@
+import { useEffect, useState } from 'react';
+import SettingsNavigation from '../components/SettingsNavigation';
+import loadStatsVideo from '../api/loader/loadStatsVideo';
+import loadStatsChannel from '../api/loader/loadStatsChannel';
+import loadStatsPlaylist from '../api/loader/loadStatsPlaylist';
+import loadStatsDownload from '../api/loader/loadStatsDownload';
+import loadStatsWatchProgress from '../api/loader/loadStatsWatchProgress';
+import loadStatsDownloadHistory from '../api/loader/loadStatsDownloadHistory';
+import loadStatsBiggestChannels from '../api/loader/loadStatsBiggestChannels';
+import OverviewStats from '../components/OverviewStats';
+import VideoTypeStats from '../components/VideoTypeStats';
+import ApplicationStats from '../components/ApplicationStats';
+import WatchProgressStats from '../components/WatchProgressStats';
+import DownloadHistoryStats from '../components/DownloadHistoryStats';
+import BiggestChannelsStats from '../components/BiggestChannelsStats';
+import Notifications from '../components/Notifications';
+import PaginationDummy from '../components/PaginationDummy';
+import { Helmet } from 'react-helmet';
+
+export type VideoStatsType = {
+ doc_count: number;
+ media_size: number;
+ duration: number;
+ duration_str: string;
+ type_videos: {
+ doc_count: number;
+ media_size: number;
+ duration: number;
+ duration_str: string;
+ };
+ type_shorts: {
+ doc_count: number;
+ media_size: number;
+ duration: number;
+ duration_str: string;
+ };
+ active_true: {
+ doc_count: number;
+ media_size: number;
+ duration: number;
+ duration_str: string;
+ };
+ active_false: {
+ doc_count: number;
+ media_size: number;
+ duration: number;
+ duration_str: string;
+ };
+ type_streams: {
+ doc_count: number;
+ media_size: number;
+ duration: number;
+ duration_str: string;
+ };
+};
+
+export type ChannelStatsType = {
+ doc_count: number;
+ active_true: number;
+ subscribed_true: number;
+};
+
+export type PlaylistStatsType = {
+ doc_count: number;
+ active_false: number;
+ active_true: number;
+ subscribed_true: number;
+};
+
+export type DownloadStatsType = {
+ pending: number;
+ pending_videos: number;
+ pending_shorts: number;
+ pending_streams: number;
+};
+
+export type WatchProgressStatsType = {
+ total: {
+ duration: number;
+ duration_str: string;
+ items: number;
+ };
+ unwatched: {
+ duration: number;
+ duration_str: string;
+ progress: number;
+ items: number;
+ };
+ watched: {
+ duration: number;
+ duration_str: string;
+ progress: number;
+ items: number;
+ };
+};
+
+type DownloadHistoryType = {
+ date: string;
+ count: number;
+ media_size: number;
+};
+
+export type DownloadHistoryStatsType = DownloadHistoryType[];
+
+type BiggestChannelsType = {
+ id: string;
+ name: string;
+ doc_count: number;
+ duration: number;
+ duration_str: string;
+ media_size: number;
+};
+
+export type BiggestChannelsStatsType = BiggestChannelsType[];
+
+type DashboardStatsReponses = {
+ videoStats?: VideoStatsType;
+ channelStats?: ChannelStatsType;
+ playlistStats?: PlaylistStatsType;
+ downloadStats?: DownloadStatsType;
+ watchProgressStats?: WatchProgressStatsType;
+ downloadHistoryStats?: DownloadHistoryStatsType;
+ biggestChannelsStatsByCount?: BiggestChannelsStatsType;
+ biggestChannelsStatsByDuration?: BiggestChannelsStatsType;
+ biggestChannelsStatsByMediaSize?: BiggestChannelsStatsType;
+};
+
+const SettingsDashboard = () => {
+ const [useSi, setUseSi] = useState(false);
+
+ const [response, setResponse] = useState({
+ videoStats: undefined,
+ });
+
+ const videoStats = response?.videoStats;
+ const channelStats = response?.channelStats;
+ const playlistStats = response?.playlistStats;
+ const downloadStats = response?.downloadStats;
+ const watchProgressStats = response?.watchProgressStats;
+ const downloadHistoryStats = response?.downloadHistoryStats;
+ const biggestChannelsStatsByCount = response?.biggestChannelsStatsByCount;
+ const biggestChannelsStatsByDuration = response?.biggestChannelsStatsByDuration;
+ const biggestChannelsStatsByMediaSize = response?.biggestChannelsStatsByMediaSize;
+
+ useEffect(() => {
+ (async () => {
+ const all = await Promise.all([
+ await loadStatsVideo(),
+ await loadStatsChannel(),
+ await loadStatsPlaylist(),
+ await loadStatsDownload(),
+ await loadStatsWatchProgress(),
+ await loadStatsDownloadHistory(),
+ await loadStatsBiggestChannels('doc_count'),
+ await loadStatsBiggestChannels('duration'),
+ await loadStatsBiggestChannels('media_size'),
+ ]);
+
+ const [
+ videoStats,
+ channelStats,
+ playlistStats,
+ downloadStats,
+ watchProgressStats,
+ downloadHistoryStats,
+ biggestChannelsStatsByCount,
+ biggestChannelsStatsByDuration,
+ biggestChannelsStatsByMediaSize,
+ ] = all;
+
+ setResponse({
+ videoStats,
+ channelStats,
+ playlistStats,
+ downloadStats,
+ watchProgressStats,
+ downloadHistoryStats,
+ biggestChannelsStatsByCount,
+ biggestChannelsStatsByDuration,
+ biggestChannelsStatsByMediaSize,
+ });
+ })();
+ }, []);
+
+ return (
+ <>
+
+ TA | Settings Dashboard
+
+
+
+
+
+
Your Archive
+
+
+ File Sizes in:
+ {
+ const value = event.target.value;
+ console.log(value);
+ setUseSi(value === 'true');
+ }}
+ >
+ SI units
+ Binary units
+
+
+
+
+
+
+
+
+
Download History
+
+
+
+
+
+
Biggest Channels
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default SettingsDashboard;
diff --git a/frontend/src/pages/SettingsScheduling.tsx b/frontend/src/pages/SettingsScheduling.tsx
new file mode 100644
index 00000000..6709a8ef
--- /dev/null
+++ b/frontend/src/pages/SettingsScheduling.tsx
@@ -0,0 +1,462 @@
+import { Helmet } from 'react-helmet';
+import Notifications from '../components/Notifications';
+import SettingsNavigation from '../components/SettingsNavigation';
+import Button from '../components/Button';
+
+type CronTabType = {
+ minute: number;
+ hour: number;
+ day_of_week: number;
+};
+
+type SchedulerErrorType = {
+ errors: string[];
+};
+
+type NotificationItemType = {
+ task: string;
+ notification: {
+ title: string;
+ urls: string[];
+ };
+};
+
+type SettingsSchedulingResponseType = {
+ update_subscribed: {
+ crontab: CronTabType;
+ };
+ check_reindex: {
+ crontab: CronTabType;
+ task_config: {
+ days: 0;
+ };
+ };
+ thumbnail_check: {
+ crontab: CronTabType;
+ };
+ download_pending: {
+ crontab: CronTabType;
+ };
+ run_backup: {
+ crontab: CronTabType;
+ task_config: {
+ rotate: false;
+ };
+ };
+ notifications: {
+ items: NotificationItemType[];
+ };
+ scheduler_form: {
+ update_subscribed: SchedulerErrorType;
+ download_pending: SchedulerErrorType;
+ check_reindex: SchedulerErrorType;
+ thumbnail_check: SchedulerErrorType;
+ run_backup: SchedulerErrorType;
+ };
+};
+
+const SettingsScheduling = () => {
+ const response: SettingsSchedulingResponseType = {
+ update_subscribed: {
+ crontab: {
+ minute: 0,
+ hour: 0,
+ day_of_week: 0,
+ },
+ },
+ check_reindex: {
+ crontab: {
+ minute: 0,
+ hour: 0,
+ day_of_week: 0,
+ },
+ task_config: {
+ days: 0,
+ },
+ },
+ thumbnail_check: {
+ crontab: {
+ minute: 0,
+ hour: 0,
+ day_of_week: 0,
+ },
+ },
+ download_pending: {
+ crontab: {
+ minute: 0,
+ hour: 0,
+ day_of_week: 0,
+ },
+ },
+ run_backup: {
+ crontab: {
+ minute: 0,
+ hour: 0,
+ day_of_week: 0,
+ },
+ task_config: {
+ rotate: false,
+ },
+ },
+ notifications: {
+ items: [
+ {
+ task: '',
+ notification: {
+ title: '',
+ urls: [''],
+ },
+ },
+ ],
+ },
+ scheduler_form: {
+ update_subscribed: {
+ errors: ['error?'],
+ },
+ download_pending: {
+ errors: ['error?'],
+ },
+ check_reindex: {
+ errors: ['error?'],
+ },
+ thumbnail_check: {
+ errors: ['error?'],
+ },
+ run_backup: {
+ errors: ['error?'],
+ },
+ },
+ };
+
+ const {
+ check_reindex,
+ download_pending,
+ notifications,
+ run_backup,
+ scheduler_form,
+ thumbnail_check,
+ update_subscribed,
+ } = response;
+
+ return (
+ <>
+
+ TA | Scheduling Settings
+
+
+
+
+
+
+
Scheduler Setup
+
+
+ Schedule settings expect a cron like format, where the first value is minute, second
+ is hour and third is day of the week.
+
+
Examples:
+
+
+ 0 15 * : Run task every day at 15:00 in the
+ afternoon.
+
+
+ 30 8 */2 : Run task every second day of the
+ week (Sun, Tue, Thu, Sat) at 08:30 in the morning.
+
+
+ auto : Sensible default.
+
+
+
Note:
+
+
+ Avoid an unnecessary frequent schedule to not get blocked by YouTube. For that
+ reason, the scheduler doesn't support schedules that trigger more than once per
+ hour.
+
+
+
+
+
+
+ >
+ );
+};
+
+export default SettingsScheduling;
diff --git a/frontend/src/pages/SettingsUser.tsx b/frontend/src/pages/SettingsUser.tsx
new file mode 100644
index 00000000..16fbb7ba
--- /dev/null
+++ b/frontend/src/pages/SettingsUser.tsx
@@ -0,0 +1,143 @@
+import { useLoaderData, useNavigate, useOutletContext } from 'react-router-dom';
+import updateUserConfig, { UserConfigType, UserMeType } from '../api/actions/updateUserConfig';
+import { useEffect, useState } from 'react';
+import loadUserMeConfig from '../api/loader/loadUserConfig';
+import { ColourConstant, ColourVariants } from '../configuration/colours/getColours';
+import SettingsNavigation from '../components/SettingsNavigation';
+import Notifications from '../components/Notifications';
+import { Helmet } from 'react-helmet';
+import Button from '../components/Button';
+import { OutletContextType } from './Base';
+
+type SettingsUserLoaderData = {
+ userConfig: UserMeType;
+};
+
+const SettingsUser = () => {
+ const { isAdmin } = useOutletContext() as OutletContextType;
+ const { userConfig } = useLoaderData() as SettingsUserLoaderData;
+ const navigate = useNavigate();
+
+ const userMeConfig = userConfig.config;
+ const { stylesheet, page_size } = userMeConfig;
+
+ const [selectedStylesheet, setSelectedStylesheet] = useState(userMeConfig.stylesheet);
+ const [selectedPageSize, setSelectedPageSize] = useState(userMeConfig.page_size);
+ const [refresh, setRefresh] = useState(false);
+
+ const [userConfigResponse, setUserConfigResponse] = useState();
+
+ const stylesheetOverwritable =
+ userConfigResponse?.stylesheet || stylesheet || (ColourConstant.Dark as ColourVariants);
+ const pageSizeOverwritable = userConfigResponse?.page_size || page_size || 12;
+
+ useEffect(() => {
+ (async () => {
+ if (refresh) {
+ const userConfigResponse = await loadUserMeConfig();
+
+ setUserConfigResponse(userConfigResponse.config);
+ setRefresh(false);
+ navigate(0);
+ }
+ })();
+ }, [navigate, refresh]);
+
+ return (
+ <>
+
+ TA | User Settings
+
+
+
+
+
+
+
User Configurations
+
+
+
+
Stylesheet
+
+
+ Current stylesheet:{' '}
+ {stylesheetOverwritable}
+
+
Select your preferred stylesheet.
+
+
{
+ setSelectedStylesheet(event.target.value as ColourVariants);
+ }}
+ >
+ -- change stylesheet --
+ {Object.entries(ColourConstant).map(([key, value]) => {
+ return (
+
+ {key}
+
+ );
+ })}
+
+
+
+
+
Archive View
+
+
+ Current page size: {pageSizeOverwritable}
+
+
Result of videos showing in archive page
+
+
+
{
+ setSelectedPageSize(Number(event.target.value));
+ }}
+ >
+
+
+
{
+ await updateUserConfig({
+ page_size: selectedPageSize,
+ stylesheet: selectedStylesheet,
+ });
+
+ setRefresh(true);
+ }}
+ />
+
+
+ {isAdmin && (
+ <>
+
+
Users
+
+
+
User Management
+
+ Access the admin interface for basic user management functionality like adding and
+ deleting users, changing passwords and more.
+
+
+
+
+
+ >
+ )}
+
+ >
+ );
+};
+
+export default SettingsUser;
diff --git a/frontend/src/pages/Video.tsx b/frontend/src/pages/Video.tsx
new file mode 100644
index 00000000..5bbdb372
--- /dev/null
+++ b/frontend/src/pages/Video.tsx
@@ -0,0 +1,535 @@
+import { Link, useNavigate, useOutletContext, useParams } from 'react-router-dom';
+import loadVideoById from '../api/loader/loadVideoById';
+import { Fragment, useEffect, useState } from 'react';
+import { ConfigType, VideoType } from './Home';
+import VideoPlayer, { VideoProgressType } from '../components/VideoPlayer';
+import iconEye from '/img/icon-eye.svg';
+import iconThumb from '/img/icon-thumb.svg';
+import iconStarFull from '/img/icon-star-full.svg';
+import iconStarEmpty from '/img/icon-star-empty.svg';
+import iconStarHalf from '/img/icon-star-half.svg';
+import iconClose from '/img/icon-close.svg';
+import iconUnseen from '/img/icon-unseen.svg';
+import iconSeen from '/img/icon-seen.svg';
+import Routes from '../configuration/routes/RouteList';
+import Linkify from '../components/Linkify';
+import loadSimmilarVideosById from '../api/loader/loadSimmilarVideosById';
+import VideoList from '../components/VideoList';
+import updateWatchedState from '../api/actions/updateWatchedState';
+import humanFileSize from '../functions/humanFileSize';
+import ScrollToTopOnNavigate from '../components/ScrollToTop';
+import loadVideoProgressById from '../api/loader/loadVideoProgressById';
+import ChannelOverview from '../components/ChannelOverview';
+import deleteVideo from '../api/actions/deleteVideo';
+import capitalizeFirstLetter from '../functions/capitalizeFirstLetter';
+import formatDate from '../functions/formatDates';
+import formatNumbers from '../functions/formatNumbers';
+import queueReindex from '../api/actions/queueReindex';
+import loadSponsorblockByVideoId from '../api/loader/loadSponsorblockByVideoId';
+import GoogleCast from '../components/GoogleCast';
+import WatchedCheckBox from '../components/WatchedCheckBox';
+import convertStarRating from '../functions/convertStarRating';
+import loadPlaylistList from '../api/loader/loadPlaylistList';
+import { PlaylistsResponseType } from './Playlists';
+import PaginationDummy from '../components/PaginationDummy';
+import updateCustomPlaylist from '../api/actions/updateCustomPlaylist';
+import { PlaylistType } from './Playlist';
+import loadCommentsbyVideoId from '../api/loader/loadCommentsbyVideoId';
+import CommentBox, { CommentsType } from '../components/CommentBox';
+import { Helmet } from 'react-helmet';
+import Button from '../components/Button';
+import { OutletContextType } from './Base';
+import getApiUrl from '../configuration/getApiUrl';
+import loadVideoNav, { VideoNavResponseType } from '../api/loader/loadVideoNav';
+
+const isInPlaylist = (videoId: string, playlist: PlaylistType) => {
+ return playlist.playlist_entries.some(entry => {
+ return entry.youtube_id === videoId;
+ });
+};
+
+type VideoParams = {
+ videoId: string;
+};
+
+type PlaylistNavPreviousItemType = {
+ youtube_id: string;
+ vid_thumb: string;
+ idx: number;
+ title: string;
+};
+
+type PlaylistNavNextItemType = {
+ youtube_id: string;
+ vid_thumb: string;
+ idx: number;
+ title: string;
+};
+
+type PlaylistNavItemType = {
+ playlist_meta: {
+ current_idx: string;
+ playlist_id: string;
+ playlist_name: string;
+ playlist_channel: string;
+ };
+ playlist_previous: PlaylistNavPreviousItemType;
+ playlist_next: PlaylistNavNextItemType;
+};
+
+type PlaylistNavType = PlaylistNavItemType[];
+
+export type SponsorBlockSegmentType = {
+ category: string;
+ actionType: string;
+ segment: number[];
+ UUID: string;
+ videoDuration: number;
+ locked: number;
+ votes: number;
+};
+
+export type SponsorBlockType = {
+ last_refresh: number;
+ has_unlocked: boolean;
+ is_enabled: boolean;
+ segments: SponsorBlockSegmentType[];
+ message?: string;
+};
+
+export type SimilarVideosResponseType = {
+ data: VideoType[];
+ config: ConfigType;
+};
+
+export type VideoResponseType = {
+ data: VideoType;
+ config: ConfigType;
+ playlist_nav: PlaylistNavType;
+};
+
+type CommentsResponseType = {
+ data: CommentsType[];
+ config: ConfigType;
+};
+
+export type VideoCommentsResponseType = {
+ data: VideoType;
+ config: ConfigType;
+ playlist_nav: PlaylistNavType;
+};
+
+const Video = () => {
+ const { isAdmin } = useOutletContext() as OutletContextType;
+ const { videoId } = useParams() as VideoParams;
+ const navigate = useNavigate();
+
+ const [descriptionExpanded, setDescriptionExpanded] = useState(false);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [showAddToPlaylist, setShowAddToPlaylist] = useState(false);
+ const [refreshVideoList, setRefreshVideoList] = useState(false);
+ const [reindex, setReindex] = useState(false);
+
+ const [videoResponse, setVideoResponse] = useState();
+ const [simmilarVideos, setSimmilarVideos] = useState();
+ const [videoProgress, setVideoProgress] = useState();
+ const [videoPlaylistNav, setVideoPlaylistNav] = useState();
+ const [sponsorblockResponse, setSponsorblockResponse] = useState();
+ const [customPlaylistsResponse, setCustomPlaylistsResponse] = useState();
+ const [commentsResponse, setCommentsResponse] = useState();
+
+ useEffect(() => {
+ (async () => {
+ const videoResponse = await loadVideoById(videoId);
+ const simmilarVideosResponse = await loadSimmilarVideosById(videoId);
+ const videoProgressResponse = await loadVideoProgressById(videoId);
+ // const sponsorblockReponse = await loadSponsorblockByVideoId(videoId);
+ const customPlaylistsResponse = await loadPlaylistList({ type: 'custom' });
+ const commentsResponse = await loadCommentsbyVideoId(videoId);
+ const videoNavResponse = await loadVideoNav(videoId);
+
+ setVideoResponse(videoResponse);
+ setSimmilarVideos(simmilarVideosResponse);
+ setVideoProgress(videoProgressResponse);
+ // setSponsorblockResponse(sponsorblockReponse);
+ setVideoPlaylistNav(videoNavResponse);
+ setCustomPlaylistsResponse(customPlaylistsResponse);
+ setCommentsResponse(commentsResponse);
+ setRefreshVideoList(false);
+ })();
+ }, [videoId, refreshVideoList]);
+
+ if (videoResponse === undefined) {
+ return [];
+ }
+
+ const video = videoResponse.data;
+ const watched = videoResponse.data.player.watched;
+ const config = videoResponse.config;
+ const playlistNav = videoPlaylistNav;
+ const sponsorBlock = sponsorblockResponse;
+ const customPlaylists = customPlaylistsResponse?.data;
+ const starRating = convertStarRating(video?.stats?.average_rating);
+ const comments = commentsResponse?.data;
+
+ console.log('playlistNav', playlistNav);
+
+ const cast = config.enable_cast;
+
+ return (
+ <>
+
+ TA | {video.title}
+
+
+
+
+
+
+
+ {cast && (
+ {
+ setRefreshVideoList(true);
+ }}
+ />
+ )}
+ {video.title}
+
+
+
+
+
+
+
Published: {formatDate(video.published)}
+
Last refreshed: {formatDate(video.vid_last_refresh)}
+
+ Watched:
+ {
+ await updateWatchedState({
+ id: videoId,
+ is_watched: status,
+ });
+
+ setRefreshVideoList(true);
+ }}
+ />
+
+ {video.active && (
+
+ Youtube:{' '}
+
+ Active
+
+
+ )}
+ {!video.active &&
Youtube: Deactivated
}
+
+
+
+
+
+ : {formatNumbers(video.stats.view_count)}
+
+
+ : {formatNumbers(video.stats.like_count)}
+
+ {video.stats.dislike_count > 0 && (
+
+ :{' '}
+ {video.stats.dislike_count}
+
+ )}
+ {video?.stats && starRating && (
+
+ {starRating?.map?.((star, index) => {
+ if (star === 'full') {
+ return
;
+ }
+
+ if (star === 'half') {
+ return
;
+ }
+
+ return
;
+ })}
+
+ )}
+
+
+
+
+
+
+ {reindex &&
Reindex scheduled
}
+ {!reindex && (
+ <>
+ {isAdmin && (
+
+ {
+ await queueReindex(video.youtube_id, 'video');
+ setReindex(true);
+ }}
+ />
+
+ )}
+ >
+ )}
+
+
+ {' '}
+ {isAdmin && (
+ <>
+ {!showDeleteConfirm && (
+
setShowDeleteConfirm(!showDeleteConfirm)}
+ />
+ )}
+
+ {showDeleteConfirm && (
+
+ Are you sure?
+ {
+ await deleteVideo(videoId);
+ navigate(Routes.Channel(video.channel.channel_id));
+ }}
+ />{' '}
+ setShowDeleteConfirm(!showDeleteConfirm)}
+ />
+
+ )}
+ >
+ )}{' '}
+ {!showAddToPlaylist && (
+ {
+ setShowAddToPlaylist(true);
+ }}
+ />
+ )}
+ {showAddToPlaylist && (
+ <>
+
+
{
+ setShowAddToPlaylist(false);
+ }}
+ />
+
Add video to...
+
+ {customPlaylists?.map(playlist => {
+ return (
+
{
+ if (isInPlaylist(videoId, playlist)) {
+ await updateCustomPlaylist('remove', playlist.playlist_id, videoId);
+ } else {
+ await updateCustomPlaylist('create', playlist.playlist_id, videoId);
+ }
+
+ setRefreshVideoList(true);
+ }}
+ >
+ {isInPlaylist(videoId, playlist) && (
+
+ )}
+
+ {!isInPlaylist(videoId, playlist) && (
+
+ )}
+
+ {playlist.playlist_name}
+
+ );
+ })}
+
+
+ Create playlist
+
+
+ >
+ )}
+
+
+
+ {video.media_size &&
File size: {humanFileSize(video.media_size)}
}
+
+ {video.streams &&
+ video.streams.map(stream => {
+ return (
+
+ {capitalizeFirstLetter(stream.type)}: {stream.codec}{' '}
+ {humanFileSize(stream.bitrate)}/s
+ {stream.width && (
+ <>
+ | {stream.width}x{stream.height}
+ >
+ )}{' '}
+
+ );
+ })}
+
+
+ {video.tags && video.tags.length > 0 && (
+
+
+ {video.tags.map(tag => {
+ return (
+
+ {tag}
+
+ );
+ })}
+
+
+ )}
+
+ {video.description && (
+
+
+ {video.description}
+
+
+
setDescriptionExpanded(!descriptionExpanded)}
+ />
+
+ )}
+
+ {playlistNav && (
+ <>
+ {playlistNav.map(playlistItem => {
+ return (
+
+
+
+ Playlist [{playlistItem.playlist_meta.current_idx + 1}
+ ]: {playlistItem.playlist_meta.playlist_name}
+
+
+
+
+ {playlistItem.playlist_previous && (
+ <>
+
+
+
+
+
Previous:
+
+
+ [{playlistItem.playlist_previous.idx + 1}]{' '}
+ {playlistItem.playlist_previous.title}
+
+
+
+ >
+ )}
+
+
+ {playlistItem.playlist_next && (
+ <>
+
+
Next:
+
+
+ [{playlistItem.playlist_next.idx + 1}]{' '}
+ {playlistItem.playlist_next.title}
+
+
+
+
+
+
+ >
+ )}
+
+
+
+ );
+ })}
+ >
+ )}
+
+
+
+ {video.comment_count == 0 && (
+
+ Video has no comments
+
+ )}
+
+ {video.comment_count && (
+
+
Comments: {video.comment_count}
+
+
+ )}
+
+
+
+
+ >
+ );
+};
+
+export default Video;
diff --git a/frontend/src/style.css b/frontend/src/style.css
new file mode 100644
index 00000000..022de910
--- /dev/null
+++ b/frontend/src/style.css
@@ -0,0 +1,1363 @@
+@font-face {
+ font-family: 'Sen-Bold';
+ src: url('/font/Sen-Bold.woff');
+ font-family: 'Sen-Bold';
+}
+
+@font-face {
+ font-family: 'Sen-Regular';
+ src: url('/font/Sen-Regular.woff');
+ font-family: 'Sen-Regular';
+}
+
+* {
+ margin: 0;
+ padding: 0;
+}
+
+html {
+ height: 100%;
+ scrollbar-color: var(--accent-font-dark) #0000;
+}
+
+::-webkit-scrollbar {
+ width: 7px;
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--accent-font-dark);
+ border-radius: 10px;
+}
+
+body,
+#root {
+ background-color: var(--main-bg);
+ min-height: 100%;
+ display: grid;
+ grid-template-rows: 1fr auto;
+}
+
+a {
+ font-family: Sen-Regular, sans-serif;
+ text-decoration: none;
+ color: var(--accent-font-light);
+}
+
+h1 {
+ font-family: Sen-Bold, sans-serif;
+ font-size: 2.3em;
+ color: var(--accent-font-light);
+}
+
+h2 {
+ font-size: 1.2em;
+ margin-bottom: 10px;
+ font-family: Sen-Bold, sans-serif;
+ color: var(--accent-font-dark);
+}
+
+h3 {
+ font-size: 1.1em;
+ margin-bottom: 7px;
+ font-family: Sen-Regular, sans-serif;
+ color: var(--accent-font-light);
+}
+
+h4 {
+ font-size: 0.7em;
+ margin-bottom: 7px;
+ font-family: Sen-Regular, sans-serif;
+ color: var(--accent-font-light);
+}
+
+p,
+i,
+li {
+ font-family: Sen-Regular, sans-serif;
+ margin-bottom: 10px;
+ color: var(--main-font);
+}
+
+ul {
+ margin-left: 20px;
+}
+
+td,
+th,
+span,
+label {
+ font-family: Sen-Regular, sans-serif;
+ color: var(--main-font);
+ text-align: left;
+}
+
+select,
+input {
+ padding: 5px;
+ margin: 5px;
+ border-radius: 0;
+ color: var(--main-bg);
+ background-color: var(--accent-font-light);
+}
+
+select {
+ border: none;
+}
+
+input {
+ border: solid 1px var(--main-font);
+}
+
+textarea {
+ width: 100%;
+}
+
+button {
+ border-radius: 0;
+ padding: 5px 13px;
+ border: none;
+ cursor: pointer;
+ background-color: var(--accent-font-dark);
+ color: #ffffff;
+}
+
+button:hover {
+ background-color: var(--accent-font-light);
+ transform: scale(1.05);
+ color: var(--main-bg);
+}
+
+.button-box {
+ padding: 5px 0;
+}
+
+.unsubscribe {
+ background-color: var(--accent-font-light);
+}
+
+.unsubscribe:hover {
+ background-color: var(--accent-font-dark);
+}
+
+.boxed-content {
+ max-width: 1000px;
+ width: 80%;
+ margin: 0 auto;
+}
+
+.boxed-content.boxed-4 {
+ max-width: 1200px;
+ width: 80%;
+}
+
+.boxed-content.boxed-5,
+.boxed-content.boxed-6,
+.boxed-content.boxed-7 {
+ max-width: unset;
+ width: 85%;
+}
+
+.round-img img {
+ border-radius: 50%;
+}
+
+.settings-current {
+ color: var(--accent-font-light);
+}
+
+.top-banner {
+ background-image: var(--banner);
+ background-repeat: no-repeat;
+ background-size: contain;
+ height: 10vh;
+ min-height: 80px;
+ max-height: 120px;
+ background-position: center center;
+}
+
+.footer {
+ margin: 0;
+ padding: 20px 0;
+ background-color: var(--highlight-bg);
+ grid-row-start: 2;
+ grid-row-end: 3;
+}
+
+.footer a {
+ text-decoration: underline;
+}
+
+.footer .boxed-content {
+ text-align: center;
+}
+
+/* toggle on-off */
+.toggle {
+ display: flex;
+ align-items: center;
+}
+
+.toggleBox > input[type='checkbox'] {
+ position: relative;
+ width: 70px;
+ height: 30px;
+ background-color: var(--accent-font-light);
+ border-color: var(--accent-font-light);
+ appearance: none;
+ border-radius: 15px;
+ transition: 0.4s;
+ box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
+ cursor: pointer;
+}
+
+.toggleBox > input:checked[type='checkbox'] {
+ background-color: var(--accent-font-dark);
+ border-color: var(--accent-font-dark);
+}
+
+.toggleBox > input[type='checkbox']::before {
+ z-index: 2;
+ position: absolute;
+ content: '';
+ left: 0;
+ top: 0;
+ width: 30px;
+ height: 30px;
+ background-color: white;
+ border-radius: 50%;
+ transform: scale(1.1);
+ transition: 0.4s;
+}
+
+.toggleBox > input:checked[type='checkbox']::before {
+ left: 40px;
+}
+
+.toggleBox {
+ margin-left: 10px;
+ position: relative;
+ display: inline;
+}
+
+.toggleBox > label {
+ position: absolute;
+ color: var(--main-font);
+ pointer-events: none;
+}
+
+.toggleBox > .onbtn {
+ right: 70%;
+ top: 45%;
+ transform: translate(50%, -50%);
+ font-family: Sen-Regular, sans-serif;
+}
+
+.toggleBox > .ofbtn {
+ left: 37%;
+ top: 45%;
+ transform: translate(50%, -50%);
+ font-family: Sen-Regular, sans-serif;
+ color: var(--main-font);
+}
+
+/* delete button */
+.delete-confirm button {
+ margin: 3px 0;
+}
+
+.danger-button {
+ background-color: var(--highlight-error);
+}
+
+.danger-button:hover {
+ background-color: var(--highlight-error-light);
+}
+
+/* navigation */
+.top-nav {
+ display: block;
+ padding: 5px 0;
+ position: relative;
+}
+
+.nav-items {
+ width: 100%;
+ display: flex;
+ justify-content: center;
+}
+
+.nav-item {
+ font-size: 1.3em;
+ padding: 10px 20px;
+ margin: 0 10px;
+ border-bottom: 2px solid;
+ color: var(--accent-font-dark);
+}
+
+.nav-icons {
+ width: auto;
+ display: inline-flex;
+ position: absolute;
+ top: 50%;
+ right: 0;
+ transform: translate(0, -50%);
+}
+
+.nav-icons img {
+ width: 40px;
+ padding: 0 5px;
+ filter: var(--img-filter);
+}
+
+#castbutton {
+ float: right;
+ width: 40px;
+ padding: 0 5px;
+ --disconnected-color: var(--accent-font-dark);
+ --connected-color: var(--accent-font-light);
+}
+
+.alert-hover:hover {
+ filter: var(--img-filter-error);
+}
+
+/* top of page */
+.title-bar {
+ padding-top: 30px;
+}
+
+.sort {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+.padding-box {
+ padding: 30px 0;
+}
+
+.two-col {
+ display: flex;
+}
+
+.two-col > div {
+ width: 50%;
+}
+
+.view-controls {
+ display: grid;
+ grid-template-columns: 1fr auto auto;
+ border-bottom: 2px solid;
+ border-color: var(--accent-font-dark);
+ margin: 15px 0;
+}
+
+.view-icons,
+.grid-count {
+ display: flex;
+ justify-content: end;
+ align-items: center;
+}
+
+.view-icons img {
+ width: 30px;
+ margin: 5px 10px;
+ cursor: pointer;
+ filter: var(--img-filter);
+}
+
+.grid-count img {
+ width: 15px;
+ margin: 5px;
+ cursor: pointer;
+ filter: var(--img-filter);
+}
+
+#text-reveal {
+ height: 0;
+ overflow: hidden;
+}
+
+#text-expand {
+ overflow: hidden;
+ display: -webkit-inline-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 4;
+}
+
+/* video player */
+.player-wrapper {
+ background-color: var(--highlight-bg);
+ margin: 20px 0;
+}
+
+.video-player {
+ display: grid;
+ align-content: space-evenly;
+ height: 100vh;
+ position: relative; /* needed for modal */
+}
+
+#notifications {
+ position: relative;
+}
+
+.notifications {
+ text-align: center;
+ width: 80%;
+ margin: auto;
+}
+
+.sponsorblock {
+ text-align: center;
+ width: 80%;
+ margin: auto;
+}
+
+.video-player video,
+.video-main video {
+ max-height: 80vh;
+ width: 90%;
+ max-width: 1500px;
+ margin: 0 auto;
+ display: block;
+}
+
+.player-title img {
+ width: 30px;
+ margin: 10px 10px 10px 0;
+}
+
+/* fix for safari full screen not scaling full */
+video:-webkit-full-screen {
+ max-height: unset !important;
+ max-width: unset !important;
+}
+
+/* video list */
+.video-list {
+ display: grid;
+ grid-gap: 1rem;
+ margin-top: 1rem;
+}
+
+.video-list.grid.grid-3 {
+ grid-template-columns: 1fr 1fr 1fr;
+}
+
+.video-list.grid.grid-4 {
+ grid-template-columns: 1fr 1fr 1fr 1fr;
+}
+
+.video-list.grid.grid-5 {
+ grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
+}
+
+.video-list.grid.grid-6 {
+ grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr;
+}
+
+.video-list.grid.grid-7 {
+ grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
+}
+
+.video-list.list {
+ grid-template-columns: unset;
+}
+
+.video-item {
+ overflow: hidden;
+}
+
+.video-item:hover .video-tags {
+ opacity: 1;
+}
+
+.video-item.list {
+ display: grid;
+ grid-template-columns: 26% auto;
+ background-color: var(--highlight-bg);
+ align-items: center;
+}
+
+.video-progress-bar,
+.notification-progress-bar {
+ position: absolute;
+ background-color: var(--accent-font-dark);
+ height: 7px;
+ left: 0;
+ bottom: 3px;
+}
+
+.video-thumb img {
+ width: 100%;
+ position: relative;
+}
+
+.video-tags {
+ position: absolute;
+ top: 5px;
+ left: 0;
+ padding: 5px;
+ opacity: 0;
+ transition: 300ms ease-in-out;
+}
+
+.video-tags span {
+ background-color: var(--accent-font-light);
+ padding: 5px;
+}
+
+.video-play img {
+ width: 40px;
+ filter: var(--img-filter);
+}
+
+.video-thumb-wrap {
+ position: relative;
+}
+
+.video-thumb-wrap:hover > .video-play {
+ opacity: 1;
+ padding: 15px;
+}
+
+.video-play {
+ opacity: 0;
+ transition: all 0.3s ease-in-out;
+ position: absolute;
+ top: 50%;
+ right: 50%;
+ transform: translate(50%, -50%);
+ background-color: var(--highlight-bg);
+ border-radius: 50%;
+ padding: 8px;
+}
+
+.video-desc.grid {
+ padding: 10px;
+ height: 100%;
+ background-color: var(--highlight-bg);
+}
+
+.video-desc.list {
+ padding: 10px;
+ height: 100%;
+ display: flex;
+ flex-wrap: wrap;
+ align-content: center;
+}
+
+.video-desc > div {
+ width: 100%;
+}
+
+.video-desc img {
+ width: 20px;
+ margin-right: 10px;
+}
+
+.video-desc a {
+ text-decoration: none;
+ text-align: left;
+}
+
+.video-desc h3,
+.player-title h3 {
+ font-size: 0.9em;
+ text-transform: uppercase;
+}
+
+.player-stats {
+ float: right;
+ display: flex;
+ align-items: center;
+ margin-top: 10px;
+}
+
+.player-stats span {
+ margin: 0 5px;
+}
+
+.video-desc-player {
+ margin-bottom: 8px;
+ display: flex;
+ align-items: center;
+}
+
+.watch-button,
+.dot-button,
+.close-button {
+ cursor: pointer;
+ filter: var(--img-filter);
+}
+
+.video-more {
+ text-decoration: underline;
+ text-align: right;
+}
+
+/* pagination */
+.pagination {
+ padding: 30px 0;
+ margin-left: auto;
+ margin-right: auto;
+ text-align: center;
+}
+
+.pagination-item {
+ padding: 5px;
+ border: 1px solid;
+}
+
+/* info box */
+.title-split {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+}
+
+.title-split img {
+ width: 40px;
+ filter: var(--img-filter);
+ cursor: pointer;
+ margin: 0 10px;
+}
+
+.title-split-form {
+ padding-top: 30px;
+ display: flex;
+}
+
+.info-box {
+ display: grid;
+ grid-gap: 1rem;
+ margin-top: 1rem;
+}
+
+.description-box,
+.comments-section {
+ margin-top: 1rem;
+ padding: 15px;
+ background-color: var(--highlight-bg);
+}
+
+.info-box-4 {
+ grid-template-columns: 1fr 1fr 1fr 1fr;
+}
+
+.info-box-3 {
+ grid-template-columns: 1fr 1fr 1fr;
+}
+
+.info-box-2 {
+ grid-template-columns: 1fr 1fr;
+}
+
+.info-box-1 {
+ grid-template-columns: 1fr;
+}
+
+.info-box img {
+ width: 80px;
+ margin: 0 15px;
+}
+
+.info-box-item {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ padding: 15px;
+ background-color: var(--highlight-bg);
+}
+
+.info-box-item p {
+ width: 100%;
+}
+
+.description-text {
+ width: 100%;
+}
+
+.description-text br {
+ margin-bottom: 10px;
+}
+
+.overwrite-form {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ width: 100%;
+}
+
+.overwrite-form button {
+ width: 200px;
+}
+
+.overwrite-form-item {
+ margin-bottom: 1rem;
+}
+
+.overwrite-form-item input {
+ width: 90%;
+}
+
+.hidden-overwrite {
+ display: none;
+}
+
+/* login */
+.login-page {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ text-align: center;
+ align-content: center;
+}
+
+.login-page > * {
+ width: 100%;
+}
+
+.login-page img {
+ width: 100%;
+ max-width: 200px;
+ max-height: 200px;
+ margin-bottom: 40px;
+ content: var(--logo);
+}
+
+.login-page form {
+ margin: 30px 0;
+}
+
+.login-page input {
+ min-width: 200px;
+}
+
+#id_remember_me {
+ min-width: unset;
+}
+
+.login-page button,
+.login-page .danger-zone {
+ width: 210px;
+ margin-top: 5px;
+}
+
+.login-links a {
+ text-decoration: underline;
+ margin: 30px 0;
+ padding: 20px;
+}
+
+.footer-colors {
+ grid-row-start: 2;
+ grid-row-end: 3;
+ display: flex;
+}
+
+.footer-colors div {
+ padding: 20px 0;
+ width: 33.33%;
+}
+
+.col-1 {
+ background-color: var(--highlight-bg);
+}
+
+.col-2 {
+ background-color: var(--accent-font-dark);
+}
+
+.col-3 {
+ background-color: var(--accent-font-light);
+}
+
+/* video page */
+.video-main {
+ margin: 1rem 0;
+ position: relative; /* needed for modal */
+}
+
+.video-modal {
+ position: absolute;
+ z-index: 1;
+ top: 20%;
+ width: 100%;
+ text-align: center;
+}
+
+.video-modal-text {
+ background: rgba(0, 0, 0, 0.5);
+ color: #eeeeee;
+ font-size: 1.3em;
+ display: none;
+}
+
+.video-main video {
+ max-height: 70vh;
+ margin-bottom: 1rem;
+}
+
+.video-info-watched {
+ display: flex;
+ align-items: center;
+}
+
+.video-info-watched img {
+ width: 20px;
+ margin-left: 5px;
+}
+
+.thumb-icon {
+ display: flex;
+}
+
+.video-tag-box {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.video-tag {
+ padding: 5px 10px;
+ margin: 5px;
+ border: 1px solid var(--accent-font-light);
+}
+
+.thumb-icon img,
+.rating-stars img {
+ width: 20px;
+ margin: 0 5px;
+ filter: var(--img-filter);
+}
+
+.dislike {
+ transform: rotate(180deg);
+}
+
+.playlist-wrap {
+ background-color: var(--highlight-bg);
+ margin: 1rem 0;
+ padding: 1rem;
+}
+
+.playlist-wrap > a > h3 {
+ text-align: center;
+}
+
+.playlist-nav {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ margin-bottom: 10px;
+}
+
+.playlist-nav-item {
+ display: flex;
+ justify-content: space-between;
+}
+
+.playlist-nav-item img {
+ width: 200px;
+}
+
+.playlist-desc {
+ padding: 5px;
+ width: 100%;
+}
+
+.comment-box {
+ padding-bottom: 1rem;
+ overflow: hidden;
+}
+
+.comment-box h3 {
+ line-break: anywhere;
+}
+
+.comments-replies {
+ display: none;
+ padding-left: 1rem;
+ border-left: 1px solid var(--accent-font-light);
+ margin-top: 1rem;
+}
+
+.comment-highlight {
+ background-color: var(--main-font);
+ padding: 3px;
+ color: var(--accent-font-dark);
+ font-family: Sen-bold, sans-serif;
+ width: fit-content;
+}
+
+.comment-meta {
+ display: flex;
+}
+
+.space-carrot {
+ margin: 0 5px;
+}
+
+.comment-like img {
+ width: 20px;
+ margin-left: 5px;
+ filter: var(--img-filter-error);
+}
+
+/* multi search page */
+.multi-search-box {
+ padding-right: 20px;
+}
+
+.multi-search-box input {
+ width: 100%;
+}
+
+.multi-search-result,
+#multi-search-results-placeholder {
+ padding: 1rem 0;
+}
+
+#multi-search-results-placeholder span {
+ font-family: monospace;
+ color: var(--accent-font-dark);
+ background-color: var(--highlight-bg);
+}
+
+#multi-search-results-placeholder span.value {
+ color: var(--accent-font-light);
+}
+
+#multi-search-results-placeholder ul {
+ margin-top: 10px;
+}
+
+/* channel overview page */
+.channel-list.list {
+ display: block;
+}
+
+.channel-list.grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 1rem;
+}
+
+.channel-item.list {
+ padding-bottom: 1rem;
+}
+
+.channel-item.grid > .info-box {
+ display: block;
+}
+
+.channel-banner img {
+ margin-top: 1rem;
+ width: 100%;
+}
+
+.channel-banner.grid {
+ overflow: hidden;
+}
+
+.channel-banner.list img {
+ width: 100%;
+}
+
+.channel-banner.grid img {
+ width: 250%;
+ transform: translateX(-30%);
+}
+
+.info-box-item.child-page-nav {
+ justify-content: center;
+}
+
+.info-box-item.child-page-nav a {
+ padding: 0 1rem;
+}
+
+.info-box-item.child-page-nav a:hover {
+ text-decoration: underline;
+}
+
+/* playlist overview page */
+.playlist-list.list {
+ display: grid;
+ grid-template-columns: unset;
+ gap: 1rem;
+}
+
+.playlist-list.grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 1rem;
+}
+
+.playlist-item {
+ overflow: hidden;
+}
+
+.playlist-item.list {
+ display: flex;
+}
+
+.playlist-thumbnail img {
+ width: 100%;
+}
+
+.playlist-desc.grid {
+ padding: 10px;
+ height: 100%;
+ background-color: var(--highlight-bg);
+}
+
+.playlist-desc.list {
+ width: 100%;
+ padding: 10px;
+ height: unset;
+ background-color: var(--highlight-bg);
+ display: flex;
+ flex-wrap: wrap;
+ align-content: center;
+}
+
+.playlist-desc.list > a,
+.playlist-desc.list > p {
+ width: 100%;
+}
+
+/* download page */
+.icon-text {
+ background-color: var(--highlight-bg);
+ text-align: center;
+ padding: 15px;
+}
+
+.icon-text img {
+ filter: var(--img-filter);
+ cursor: pointer;
+}
+
+.task-control-icons {
+ display: flex;
+ justify-content: center;
+}
+
+.task-control-icons img {
+ width: 30px;
+ cursor: pointer;
+ margin: 5px;
+}
+
+#stop-icon {
+ filter: var(--img-filter);
+}
+
+#kill-icon {
+ filter: var(--img-filter-error);
+}
+
+.title-split {
+ display: flex;
+ justify-content: space-between;
+}
+
+/* status message */
+.notification {
+ position: relative;
+ background-color: var(--highlight-bg);
+ text-align: center;
+ padding: 30px 0 15px 0;
+ margin: 1rem 0;
+}
+
+.notification.info {
+ background-color: var(--highlight-bg);
+}
+
+.notification.error {
+ background-color: var(--highlight-error);
+}
+
+.notification.error h3 {
+ color: #fff;
+}
+
+/* settings */
+.settings-group {
+ background-color: var(--highlight-bg);
+ padding: 20px;
+ margin: 20px 0;
+}
+
+.settings-item {
+ margin-top: 25px;
+}
+
+.settings-item input {
+ min-width: 300px;
+}
+
+.settings-item .agg-channel-table {
+ width: 100%;
+}
+
+.settings-item .agg-channel-right-align {
+ white-space: nowrap;
+ text-align: right;
+}
+
+.danger-zone {
+ background-color: var(--highlight-error);
+ color: #fff;
+ padding: 3px;
+}
+
+.backup-grid-row {
+ display: grid;
+ grid-template-columns: 10% 10% 10% auto;
+ align-items: center;
+ padding: 5px 10px;
+ border-bottom: solid 1px;
+ border-color: var(--main-font);
+}
+
+.backup-grid-row > span {
+ margin-left: 10px;
+}
+
+/* about */
+.about-section {
+ padding: 20px 0;
+}
+
+.about-section ol {
+ margin-left: 20px;
+}
+
+.about-section ul {
+ margin-top: 15px;
+}
+
+.about-section li {
+ margin-bottom: 10px;
+}
+
+.about-icon img {
+ margin-left: 5px;
+ width: 20px;
+ cursor: unset;
+}
+
+/* animation */
+.rotate-img {
+ animation: rotation 4s infinite linear;
+}
+
+.bounce-img {
+ animation: bounce 1.5s infinite ease-in-out alternate;
+}
+
+.pulse-img {
+ animation: pulse 1.5s infinite ease-in-out alternate;
+}
+
+@keyframes rotation {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(359deg);
+ }
+}
+
+@keyframes bounce {
+ 0% {
+ transform: translateY(-5%);
+ }
+ 100% {
+ transform: translateY(5%);
+ scale: 1.15;
+ }
+}
+
+@keyframes pulse {
+ 0% {
+ scale: 1;
+ }
+ 100% {
+ scale: 1.15;
+ }
+}
+
+/* tablet */
+@media screen and (max-width: 1000px), screen and (max-height: 850px) {
+ .boxed-content,
+ .boxed-content.boxed-4,
+ .boxed-content.boxed-5,
+ .boxed-content.boxed-6,
+ .boxed-content.boxed-7 {
+ width: 90%;
+ }
+ .video-list.grid.grid-3,
+ .video-list.grid.grid-4,
+ .video-list.grid.grid-5,
+ .video-list.grid.grid-6,
+ .video-list.grid.grid-7,
+ .channel-list.grid,
+ .playlist-list.grid {
+ grid-template-columns: 1fr 1fr;
+ }
+ .video-item.list,
+ .playlist-item.list {
+ display: grid;
+ grid-template-columns: 35% auto;
+ }
+ .two-col {
+ display: block;
+ }
+ .two-col > div {
+ width: 100%;
+ }
+ .top-nav {
+ flex-wrap: wrap-reverse;
+ display: flex;
+ }
+ .nav-icons {
+ width: 100%;
+ justify-content: center;
+ position: unset;
+ transform: unset;
+ }
+ .grid-count {
+ display: none;
+ }
+ .video-player {
+ height: unset;
+ padding: 20px 0;
+ }
+ .video-player video {
+ width: 90%;
+ }
+}
+
+/* phone */
+@media screen and (max-width: 600px) {
+ * {
+ word-wrap: anywhere;
+ }
+ .video-list.grid.grid-3,
+ .video-list.grid.grid-4,
+ .video-list.grid.grid-5,
+ .video-list.grid.grid-6,
+ .video-list.grid.grid-7,
+ .channel-list.grid,
+ .video-item.list,
+ .playlist-list.list,
+ .playlist-list.grid,
+ .info-box-2,
+ .info-box-3,
+ .info-box-4,
+ .overwrite-form {
+ grid-template-columns: 1fr;
+ }
+ .playlist-item.list {
+ display: block;
+ }
+ .video-desc.grid {
+ height: unset;
+ display: flex;
+ flex-wrap: wrap-reverse;
+ }
+ .boxed-content {
+ width: 95%;
+ }
+ .footer {
+ text-align: center;
+ }
+ .footer .boxed-content span {
+ width: 100%;
+ display: block;
+ }
+ .toggle {
+ flex-wrap: wrap;
+ }
+ .nav-items {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ }
+ .nav-item {
+ padding: 5px 0;
+ margin: 15px;
+ text-align: center;
+ }
+ .view-controls.three {
+ grid-template-columns: unset;
+ justify-content: center;
+ }
+ .sort {
+ display: block;
+ }
+ .sort select {
+ margin: unset;
+ }
+ .description-box {
+ display: block;
+ }
+ .backup-grid-row {
+ display: flex;
+ flex-wrap: wrap;
+ padding: 10px 0;
+ justify-content: center;
+ }
+ .backup-grid-row span {
+ padding: 5px 0;
+ }
+ .playlist-nav {
+ display: block;
+ grid-template-columns: unset;
+ }
+ .playlist-nav-item {
+ display: block;
+ justify-content: unset;
+ }
+ .playlist-nav-item img {
+ width: 100%;
+ }
+ .td,
+ th,
+ span,
+ label {
+ text-align: unset;
+ }
+}
+
+.video-popup-menu img {
+ width: 12px;
+ cursor: pointer;
+ filter: var(--img-filter);
+}
+
+.video-popup-menu {
+ border-top: 2px solid;
+ border-color: var(--accent-font-dark);
+ margin: 5px 0;
+ padding-top: 10px;
+}
+
+.video-popup-menu img.move-video-button {
+ width: 24px;
+ cursor: pointer;
+ filter: var(--img-filter);
+}
+
+.video-popup-menu-close-button {
+ cursor: pointer;
+ filter: var(--img-filter);
+ float: right;
+}
+
+.video-desc-details {
+ display: flex;
+ justify-content: space-between;
+}
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 00000000..11f02fe2
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 00000000..2ed1ecc5
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 00000000..97ede7ee
--- /dev/null
+++ b/frontend/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 00000000..41261f94
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,17 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react-swc';
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+ base: '/',
+ server: {
+ host: true,
+ port: 3000, // This is the port which we will use in docker
+ // Thanks @sergiomoura for the window fix
+ // add the next lines if you're using windows and hot reload doesn't work
+ watch: {
+ usePolling: true,
+ },
+ },
+});