complete ui overhaul

This commit is contained in:
Mustafa Soylu
2025-06-08 12:39:39 +02:00
parent 62cbeeb513
commit c3b2233cf5
94 changed files with 6024 additions and 14178 deletions

24
spotizerr-ui/.gitignore vendored Normal file
View File

@@ -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?

54
spotizerr-ui/README.md Normal file
View File

@@ -0,0 +1,54 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```

View File

@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

13
spotizerr-ui/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spotizerr</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

41
spotizerr-ui/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "spotizerr-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/postcss": "^4.1.8",
"@tailwindcss/vite": "^4.1.8",
"@tanstack/react-query": "^5.80.6",
"@tanstack/react-router": "^1.120.18",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-devtools": "^1.120.18",
"axios": "^1.9.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.57.0",
"sonner": "^2.0.5",
"tailwindcss": "^4.1.8",
"use-debounce": "^10.0.5"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@types/node": "^22.15.30",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
}
}

2949
spotizerr-ui/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
import tailwindcss from '@tailwindcss/postcss';
export default {
plugins: [tailwindcss],
};

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="12" cy="12" r="5"></circle>
<circle cx="12" cy="12" r="1"></circle>
</svg>

After

Width:  |  Height:  |  Size: 337 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 284 B

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>binoculars-filled</title>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="icon" fill="#000000" transform="translate(64.000000, 64.000000)">
<path d="M277.333333,-4.26325641e-14 C300.897483,-4.69612282e-14 320,19.1025173 320,42.6666667 L320.001038,66.6886402 C356.805359,76.1619142 384,109.571799 384,149.333333 L384,298.666667 C384,345.794965 345.794965,384 298.666667,384 C251.538368,384 213.333333,345.794965 213.333333,298.666667 L213.334343,290.517566 C207.67282,295.585196 200.196268,298.666667 192,298.666667 C183.803732,298.666667 176.32718,295.585196 170.665657,290.517566 L170.666667,298.666667 C170.666667,345.794965 132.461632,384 85.3333333,384 C38.2050347,384 -4.26325641e-14,345.794965 -4.26325641e-14,298.666667 L-4.26325641e-14,149.333333 C-4.75019917e-14,109.57144 27.1951335,76.1613096 63.9999609,66.6883831 L64,42.6666667 C64,19.1025173 83.1025173,-3.83039001e-14 106.666667,-4.26325641e-14 C130.230816,-4.69612282e-14 149.333333,19.1025173 149.333333,42.6666667 L149.333764,69.7082895 C158.827303,75.200153 166.008403,84.2448998 169.058923,95.0243894 C174.872894,89.046446 183.002825,85.3333333 192,85.3333333 C200.997175,85.3333333 209.127106,89.046446 214.940994,95.0238729 C217.991694,84.2447833 225.172752,75.2001286 234.666215,69.7083018 L234.666667,42.6666667 C234.666667,19.1025173 253.769184,-3.83039001e-14 277.333333,-4.26325641e-14 Z M85.3333333,256 C61.769184,256 42.6666667,275.102517 42.6666667,298.666667 C42.6666667,322.230816 61.769184,341.333333 85.3333333,341.333333 C108.897483,341.333333 128,322.230816 128,298.666667 C128,275.102517 108.897483,256 85.3333333,256 Z M298.666667,256 C275.102517,256 256,275.102517 256,298.666667 C256,322.230816 275.102517,341.333333 298.666667,341.333333 C322.230816,341.333333 341.333333,322.230816 341.333333,298.666667 C341.333333,275.102517 322.230816,256 298.666667,256 Z" id="Combined-Shape">
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8C16 12.4183 12.4183 16 8 16ZM11.7069 6.70739C12.0975 6.31703 12.0978 5.68386 11.7074 5.29318C11.3171 4.9025 10.6839 4.90224 10.2932 5.29261L6.99765 8.58551L5.70767 7.29346C5.31746 6.90262 4.6843 6.90212 4.29346 7.29233C3.90262 7.68254 3.90212 8.3157 4.29233 8.70654L6.28912 10.7065C6.47655 10.8943 6.7309 10.9998 6.99619 11C7.26147 11.0002 7.51595 10.8949 7.70361 10.7074L11.7069 6.70739Z" fill="#212121"/>
</svg>

After

Width:  |  Height:  |  Size: 723 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 5L4.99998 19M5.00001 5L19 19" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 356 B

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 492 492" xml:space="preserve">
<g>
<g>
<path d="M442.668,268.536l-16.116-16.12c-5.06-5.068-11.824-7.872-19.024-7.872c-7.208,0-14.584,2.804-19.644,7.872
L283.688,355.992V26.924C283.688,12.084,272.856,0,258.02,0h-22.804c-14.832,0-28.404,12.084-28.404,26.924v330.24
L102.824,252.416c-5.068-5.068-11.444-7.872-18.652-7.872c-7.2,0-13.776,2.804-18.84,7.872l-16.028,16.12
c-10.488,10.492-10.444,27.56,0.044,38.052l177.576,177.556c5.056,5.056,11.84,7.856,19.1,7.856h0.076
c7.204,0,13.972-2.8,19.028-7.856l177.54-177.552C453.164,296.104,453.164,279.028,442.668,268.536z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 891 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 16H13L10.8368 13.3376C9.96488 13.7682 8.99592 14 8 14C6.09909 14 4.29638 13.1557 3.07945 11.6953L0 8L3.07945 4.30466C3.14989 4.22013 3.22229 4.13767 3.29656 4.05731L0 0H3L16 16ZM5.35254 6.58774C5.12755 7.00862 5 7.48941 5 8C5 9.65685 6.34315 11 8 11C8.29178 11 8.57383 10.9583 8.84053 10.8807L5.35254 6.58774Z" fill="#000000"/>
<path d="M16 8L14.2278 10.1266L7.63351 2.01048C7.75518 2.00351 7.87739 2 8 2C9.90091 2 11.7036 2.84434 12.9206 4.30466L16 8Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 752 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 8L3.07945 4.30466C4.29638 2.84434 6.09909 2 8 2C9.90091 2 11.7036 2.84434 12.9206 4.30466L16 8L12.9206 11.6953C11.7036 13.1557 9.90091 14 8 14C6.09909 14 4.29638 13.1557 3.07945 11.6953L0 8ZM8 11C9.65685 11 11 9.65685 11 8C11 6.34315 9.65685 5 8 5C6.34315 5 5 6.34315 5 8C5 9.65685 6.34315 11 8 11Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 597 B

BIN
spotizerr-ui/public/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.07868 5.06891C8.87402 1.27893 15.0437 1.31923 18.8622 5.13778C22.6824 8.95797 22.7211 15.1313 18.9262 18.9262C15.1312 22.7211 8.95793 22.6824 5.13774 18.8622C2.87389 16.5984 1.93904 13.5099 2.34047 10.5812C2.39672 10.1708 2.775 9.88377 3.18537 9.94002C3.59575 9.99627 3.88282 10.3745 3.82658 10.7849C3.4866 13.2652 4.27782 15.881 6.1984 17.8016C9.44288 21.0461 14.6664 21.0646 17.8655 17.8655C21.0646 14.6664 21.046 9.44292 17.8015 6.19844C14.5587 2.95561 9.33889 2.93539 6.13935 6.12957L6.88705 6.13333C7.30126 6.13541 7.63535 6.47288 7.63327 6.88709C7.63119 7.3013 7.29372 7.63539 6.87951 7.63331L4.33396 7.62052C3.92269 7.61845 3.58981 7.28556 3.58774 6.8743L3.57495 4.32874C3.57286 3.91454 3.90696 3.57707 4.32117 3.57498C4.73538 3.5729 5.07285 3.907 5.07493 4.32121L5.07868 5.06891ZM11.9999 7.24992C12.4141 7.24992 12.7499 7.58571 12.7499 7.99992V11.6893L15.0302 13.9696C15.3231 14.2625 15.3231 14.7374 15.0302 15.0302C14.7373 15.3231 14.2624 15.3231 13.9696 15.0302L11.2499 12.3106V7.99992C11.2499 7.58571 11.5857 7.24992 11.9999 7.24992Z" fill="#1C274C"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30" width="30px" height="30px"> <path d="M 15 2 A 1 1 0 0 0 14.300781 2.2851562 L 3.3925781 11.207031 A 1 1 0 0 0 3.3554688 11.236328 L 3.3183594 11.267578 L 3.3183594 11.269531 A 1 1 0 0 0 3 12 A 1 1 0 0 0 4 13 L 5 13 L 5 24 C 5 25.105 5.895 26 7 26 L 23 26 C 24.105 26 25 25.105 25 24 L 25 13 L 26 13 A 1 1 0 0 0 27 12 A 1 1 0 0 0 26.681641 11.267578 L 26.666016 11.255859 A 1 1 0 0 0 26.597656 11.199219 L 25 9.8925781 L 25 6 C 25 5.448 24.552 5 24 5 L 23 5 C 22.448 5 22 5.448 22 6 L 22 7.4394531 L 15.677734 2.2675781 A 1 1 0 0 0 15 2 z M 18 15 L 22 15 L 22 23 L 18 23 L 18 15 z"/></svg>

After

Width:  |  Height:  |  Size: 673 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Warning / Info">
<path id="Vector" d="M12 11V16M12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21ZM12.0498 8V8.1L11.9502 8.1002V8H12.0498Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 527 B

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="800px" height="800px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<title>ic_fluent_missing_metadata_24_filled</title>
<desc>Created with Sketch.</desc>
<g id="🔍-System-Icons" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="ic_fluent_missing_metadata_24_filled" fill="#212121" fill-rule="nonzero">
<path d="M17.5,12 C20.5376,12 23,14.4624 23,17.5 C23,20.5376 20.5376,23 17.5,23 C14.4624,23 12,20.5376 12,17.5 C12,14.4624 14.4624,12 17.5,12 Z M19.7501,2 C20.9927,2 22.0001,3.00736 22.0001,4.25 L22.0001,9.71196 C22.0001,10.50198 21.7124729,11.2623046 21.1951419,11.8530093 L21.0222,12.0361 C20.0073,11.3805 18.7981,11 17.5,11 C13.9101,11 11,13.9101 11,17.5 C11,18.7703 11.3644,19.9554 11.9943,20.9567 C10.7373,21.7569 9.05064,21.6098 7.95104,20.5143 L3.48934,16.0592 C2.21887,14.7913 2.21724,12.7334 3.48556,11.4632 L11.9852,2.95334 C12.5948,2.34297 13.4221,2 14.2847,2 L19.7501,2 Z M17.5,19.88 C17.1551,19.88 16.8755,20.1596 16.8755,20.5045 C16.8755,20.8494 17.1551,21.129 17.5,21.129 C17.8449,21.129 18.1245,20.8494 18.1245,20.5045 C18.1245,20.1596 17.8449,19.88 17.5,19.88 Z M17.5,14.0031 C16.4521,14.0031 15.6357,14.8205 15.6467,15.9574 C15.6493,16.2335 15.8753,16.4552 16.1514,16.4526 C16.4276,16.4499 16.6493,16.2239 16.6465901,15.9478 C16.6411,15.3688 17.0063,15.0031 17.5,15.0031 C17.9724,15.0031 18.3534,15.395 18.3534,15.9526 C18.3534,16.1448571 18.298151,16.2948694 18.1295283,16.5141003 L18.0355,16.63 L17.9365,16.7432 L17.6711,17.0333 C17.1868,17.5749 17,17.9255 17,18.5006 C17,18.7767 17.2239,19.0006 17.5,19.0006 C17.7762,19.0006 18,18.7767 18,18.5006 C18,18.297425 18.0585703,18.1416422 18.2388846,17.9103879 L18.3238,17.8063 L18.4247,17.6908 L18.6905,17.4003 C19.1682,16.866 19.3534,16.5186 19.3534,15.9526 C19.3534,14.8489 18.5311,14.0031 17.5,14.0031 Z M17,5.50218 C16.1716,5.50218 15.5001,6.17374 15.5001,7.00216 C15.5001,7.83057 16.1716,8.50213 17,8.50213 C17.8284,8.50213 18.5,7.83057 18.5,7.00216 C18.5,6.17374 17.8284,5.50218 17,5.50218 Z" id="🎨-Color">
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18V5l12-2v13"></path>
<circle cx="6" cy="18" r="3"></circle>
<circle cx="18" cy="16" r="3"></circle>
</svg>

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM12 7C12.5523 7 13 7.44772 13 8V11H16C16.5523 11 17 11.4477 17 12C17 12.5523 16.5523 13 16 13H13V16C13 16.5523 12.5523 17 12 17C11.4477 17 11 16.5523 11 16V13H8C7.44772 13 7 12.5523 7 12C7 11.4477 7.44772 11 8 11H11V8C11 7.44772 11.4477 7 12 7Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 652 B

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<!-- Main container -->
<rect x="20" y="15" width="60" height="70" rx="5" ry="5" fill="none" stroke="#b3b3b3" stroke-width="3" stroke-dasharray="5,3"/>
<!-- Upper item (faded) -->
<rect x="25" y="25" width="50" height="10" rx="2" ry="2" fill="#b3b3b3" opacity="0.3"/>
<!-- Middle item (faded) -->
<rect x="25" y="45" width="50" height="10" rx="2" ry="2" fill="#b3b3b3" opacity="0.2"/>
<!-- Bottom item (very faded) -->
<rect x="25" y="65" width="50" height="10" rx="2" ry="2" fill="#b3b3b3" opacity="0.1"/>
<!-- X mark for empty -->
<g transform="translate(50, 50)" stroke="#b3b3b3" stroke-width="3" opacity="0.6">
<line x1="-15" y1="-15" x2="15" y2="15" />
<line x1="15" y1="-15" x2="-15" y2="15" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 873 B

7
spotizerr-ui/public/queue.svg Executable file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect x="43.93" y="68.27" width="36.07" height="7.99" rx="2" ry="2"/>
<path d="M33.82 76.26h-4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a1.9 1.9 0 0 1-2 2zm0-17.85h-4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a1.9 1.9 0 0 1-2 2z"/>
<rect x="43.93" y="50.42" width="36.07" height="7.99" rx="2" ry="2"/>
<rect x="49.92" y="32.57" width="30.08" height="7.99" rx="2" ry="2"/>
<path d="M47.55 26.33l-2.12-2.12a1.44 1.44 0 0 0-2.12 0L30.08 37.32l-5.37-5.24a1.44 1.44 0 0 0-2.12 0L20.47 34.2a1.44 1.44 0 0 0 0 2.12l7.36 7.36a3 3 0 0 0 4.24 0L47.55 28.46a1.69 1.69 0 0 0 0-2.13z"/>
</svg>

After

Width:  |  Height:  |  Size: 666 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M17.8069373,7 C16.4464601,5.07869636 14.3936238,4 12,4 C7.581722,4 4,7.581722 4,12 L2,12 C2,6.4771525 6.4771525,2 12,2 C14.8042336,2 17.274893,3.18251178 19,5.27034886 L19,4 L21,4 L21,9 L16,9 L16,7 L17.8069373,7 Z M6.19306266,17 C7.55353989,18.9213036 9.60637619,20 12,20 C16.418278,20 20,16.418278 20,12 L22,12 C22,17.5228475 17.5228475,22 12,22 C9.19576641,22 6.72510698,20.8174882 5,18.7296511 L5,20 L3,20 L3,15 L8,15 L8,17 L6.19306266,17 Z M12.0003283,15.9983464 C11.4478622,15.9983464 11,15.5506311 11,14.9983464 C11,14.4460616 11.4478622,13.9983464 12.0003283,13.9983464 C12.5527943,13.9983464 13.0006565,14.4460616 13.0006565,14.9983464 C13.0006565,15.5506311 12.5527943,15.9983464 12.0003283,15.9983464 Z M11.0029544,6.99834639 L13.0036109,6.99834639 L13.0036109,12.9983464 L11.0029544,12.9983464 L11.0029544,6.99834639 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 21C16.9706 21 21 16.9706 21 12C21 9.69494 20.1334 7.59227 18.7083 6L16 3M12 3C7.02944 3 3 7.02944 3 12C3 14.3051 3.86656 16.4077 5.29168 18L8 21M21 3H16M16 3V8M3 21H8M8 21V16" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 500 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>

After

Width:  |  Height:  |  Size: 284 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 48 48">
<path d="M38.86 25.95c.08-.64.14-1.29.14-1.95s-.06-1.31-.14-1.95l4.23-3.31c.38-.3.49-.84.24-1.28l-4-6.93c-.25-.43-.77-.61-1.22-.43l-4.98 2.01c-1.03-.79-2.16-1.46-3.38-1.97L29 4.84c-.09-.47-.5-.84-1-.84h-8c-.5 0-.91.37-.99.84l-.75 5.3a14.8 14.8 0 0 0-3.38 1.97L9.9 10.1a1 1 0 0 0-1.22.43l-4 6.93c-.25.43-.14.97.24 1.28l4.22 3.31C9.06 22.69 9 23.34 9 24s.06 1.31.14 1.95l-4.22 3.31c-.38.3-.49.84-.24 1.28l4 6.93c.25.43.77.61 1.22.43l4.98-2.01c1.03.79 2.16 1.46 3.38 1.97l.75 5.3c.08.47.49.84.99.84h8c.5 0 .91-.37.99-.84l.75-5.3a14.8 14.8 0 0 0 3.38-1.97l4.98 2.01a1 1 0 0 0 1.22-.43l4-6.93c.25-.43.14-.97-.24-1.28l-4.22-3.31zM24 31c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z"/>
</svg>

After

Width:  |  Height:  |  Size: 778 B

View File

@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12,2a8.945,8.945,0,0,0-9,8.889,8.826,8.826,0,0,0,3.375,6.933v1.956A2.236,2.236,0,0,0,8.625,22h6.75a2.236,2.236,0,0,0,2.25-2.222V17.822A8.826,8.826,0,0,0,21,10.889,8.945,8.945,0,0,0,12,2ZM11,20H9V18a1,1,0,0,1,2,0ZM9,15a2,2,0,1,1,2-2A2,2,0,0,1,9,15Zm6,5H13V18a1,1,0,0,1,2,0Zm0-5a2,2,0,1,1,2-2A2,2,0,0,1,15,15Z"/></svg>

After

Width:  |  Height:  |  Size: 550 B

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 4.4C3.439 4.4 0 9.232 0 10c0 .766 3.439 5.6 10 5.6 6.56 0 10-4.834 10-5.6 0-.768-3.44-5.6-10-5.6zm0 9.907c-2.455 0-4.445-1.928-4.445-4.307S7.545 5.691 10 5.691s4.444 1.93 4.444 4.309-1.989 4.307-4.444 4.307zM10 10c-.407-.447.663-2.154 0-2.154-1.228 0-2.223.965-2.223 2.154s.995 2.154 2.223 2.154c1.227 0 2.223-.965 2.223-2.154 0-.547-1.877.379-2.223 0z"/></svg>

After

Width:  |  Height:  |  Size: 597 B

View File

@@ -0,0 +1,73 @@
import { useQueue, type QueueItem } from '../contexts/queue-context';
export function Queue() {
const { items, isVisible, removeItem, clearQueue, toggleVisibility } = useQueue();
if (!isVisible) return null;
const renderStatus = (item: QueueItem) => {
switch (item.status) {
case 'downloading':
return (
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
className="bg-blue-600 h-1.5 rounded-full"
style={{ width: `${item.progress || 0}%` }}
></div>
</div>
);
case 'completed':
return <span className="text-green-500 font-semibold">Completed</span>;
case 'error':
return <span className="text-red-500 font-semibold truncate" title={item.error}>{item.error || 'Failed'}</span>;
default:
return <span className="text-gray-500">{item.status}</span>;
}
};
const renderItemDetails = (item: QueueItem) => {
if (item.status !== 'downloading' || !item.progress) return null;
return (
<div className="text-xs text-gray-400 flex justify-between w-full">
<span>{item.progress.toFixed(0)}%</span>
<span>{item.speed}</span>
<span>{item.size}</span>
<span>{item.eta}</span>
</div>
)
}
return (
<div className="fixed bottom-4 right-4 w-96 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50 flex flex-col">
<div className="flex justify-between items-center p-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-semibold">Download Queue</h3>
<div className="flex items-center gap-2">
<button onClick={clearQueue} className="text-sm text-gray-500 hover:text-red-500" title="Clear All">Clear</button>
<button onClick={() => toggleVisibility()} className="text-gray-500 hover:text-white" title="Close">
<img src="/cross.svg" alt="Close" className="w-4 h-4" />
</button>
</div>
</div>
<div className="p-3 max-h-96 overflow-y-auto space-y-3">
{items.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 text-center py-4">Queue is empty.</p>
) : (
items.map((item) => (
<div key={item.id} className="text-sm">
<div className="flex justify-between items-center">
<span className="font-medium truncate pr-2">{item.name}</span>
<button onClick={() => removeItem(item.id)} className="text-gray-400 hover:text-red-500 flex-shrink-0">
<img src="/cross.svg" alt="Remove" className="w-4 h-4" />
</button>
</div>
<div className="mt-1 space-y-1">
{renderStatus(item)}
{renderItemDetails(item)}
</div>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,150 @@
import { useState } from 'react';
import { useForm, type SubmitHandler } from 'react-hook-form';
import apiClient from '../../lib/api-client';
import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// --- Type Definitions ---
type Service = 'spotify' | 'deezer';
interface Credential {
name: string;
}
// A single form shape with optional fields
interface AccountFormData {
accountName: string;
accountRegion?: string;
authBlob?: string; // Spotify specific
arl?: string; // Deezer specific
}
// --- API Functions ---
const fetchCredentials = async (service: Service): Promise<Credential[]> => {
const { data } = await apiClient.get<string[]>(`/credentials/${service}`);
return data.map(name => ({ name }));
};
const addCredential = async ({ service, data }: { service: Service, data: AccountFormData }) => {
const payload = service === 'spotify'
? { blob_content: data.authBlob, region: data.accountRegion }
: { arl: data.arl, region: data.accountRegion };
const { data: response } = await apiClient.post(`/credentials/${service}/${data.accountName}`, payload);
return response;
};
const deleteCredential = async ({ service, name }: { service: Service, name:string }) => {
const { data: response } = await apiClient.delete(`/credentials/${service}/${name}`);
return response;
};
// --- Component ---
export function AccountsTab() {
const queryClient = useQueryClient();
const [activeService, setActiveService] = useState<Service>('spotify');
const [isAdding, setIsAdding] = useState(false);
const { data: credentials, isLoading } = useQuery({
queryKey: ['credentials', activeService],
queryFn: () => fetchCredentials(activeService),
});
const { register, handleSubmit, reset, formState: { errors } } = useForm<AccountFormData>();
const addMutation = useMutation({
mutationFn: addCredential,
onSuccess: () => {
toast.success('Account added successfully!');
queryClient.invalidateQueries({ queryKey: ['credentials', activeService] });
setIsAdding(false);
reset();
},
onError: (error) => {
toast.error(`Failed to add account: ${error.message}`);
},
});
const deleteMutation = useMutation({
mutationFn: deleteCredential,
onSuccess: (_, variables) => {
toast.success(`Account "${variables.name}" deleted.`);
queryClient.invalidateQueries({ queryKey: ['credentials', activeService] });
},
onError: (error) => {
toast.error(`Failed to delete account: ${error.message}`);
},
});
const onSubmit: SubmitHandler<AccountFormData> = (data) => {
addMutation.mutate({ service: activeService, data });
};
const renderAddForm = () => (
<form onSubmit={handleSubmit(onSubmit)} className="p-4 border rounded-lg mt-4 space-y-4">
<h4 className="font-semibold">Add New {activeService === 'spotify' ? 'Spotify' : 'Deezer'} Account</h4>
<div className="flex flex-col gap-2">
<label htmlFor="accountName">Account Name</label>
<input id="accountName" {...register('accountName', { required: 'This field is required' })} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" />
{errors.accountName && <p className="text-red-500 text-sm">{errors.accountName.message}</p>}
</div>
{activeService === 'spotify' && (
<div className="flex flex-col gap-2">
<label htmlFor="authBlob">Auth Blob (JSON)</label>
<textarea id="authBlob" {...register('authBlob', { required: activeService === 'spotify' ? 'Auth Blob is required' : false })} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" rows={4}></textarea>
{errors.authBlob && <p className="text-red-500 text-sm">{errors.authBlob.message}</p>}
</div>
)}
{activeService === 'deezer' && (
<div className="flex flex-col gap-2">
<label htmlFor="arl">ARL Token</label>
<input id="arl" {...register('arl', { required: activeService === 'deezer' ? 'ARL is required' : false })} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" />
{errors.arl && <p className="text-red-500 text-sm">{errors.arl.message}</p>}
</div>
)}
<div className="flex flex-col gap-2">
<label htmlFor="accountRegion">Region (Optional)</label>
<input id="accountRegion" {...register('accountRegion')} placeholder="e.g. US, GB" className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div className="flex gap-2">
<button type="submit" disabled={addMutation.isPending} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
{addMutation.isPending ? 'Saving...' : 'Save Account'}
</button>
<button type="button" onClick={() => setIsAdding(false)} className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700">
Cancel
</button>
</div>
</form>
);
return (
<div className="space-y-6">
<div className="flex gap-2 border-b">
<button onClick={() => setActiveService('spotify')} className={`p-2 ${activeService === 'spotify' ? 'border-b-2 border-blue-500 font-semibold' : ''}`}>Spotify</button>
<button onClick={() => setActiveService('deezer')} className={`p-2 ${activeService === 'deezer' ? 'border-b-2 border-blue-500 font-semibold' : ''}`}>Deezer</button>
</div>
{isLoading ? (
<p>Loading accounts...</p>
) : (
<div className="space-y-2">
{credentials?.map(cred => (
<div key={cred.name} className="flex justify-between items-center p-3 bg-gray-800 rounded-md">
<span>{cred.name}</span>
<button onClick={() => deleteMutation.mutate({ service: activeService, name: cred.name })} disabled={deleteMutation.isPending && deleteMutation.variables?.name === cred.name} className="text-red-500 hover:text-red-400">
Delete
</button>
</div>
))}
</div>
)}
{!isAdding && (
<button onClick={() => setIsAdding(true)} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
Add Account
</button>
)}
{isAdding && renderAddForm()}
</div>
);
}

View File

@@ -0,0 +1,143 @@
import { useForm, type SubmitHandler } from 'react-hook-form';
import apiClient from '../../lib/api-client';
import { toast } from 'sonner';
import { useMutation, useQueryClient } from '@tanstack/react-query';
// --- Type Definitions ---
interface DownloadSettings {
maxConcurrentDownloads: number;
realTime: boolean;
fallback: boolean;
convertTo: 'MP3' | 'AAC' | 'OGG' | 'OPUS' | 'FLAC' | 'WAV' | 'ALAC' | '';
bitrate: string;
maxRetries: number;
retryDelaySeconds: number;
retryDelayIncrease: number;
threads: number;
path: string;
skipExisting: boolean;
m3u: boolean;
hlsThreads: number;
}
interface DownloadsTabProps {
config: DownloadSettings;
isLoading: boolean;
}
const CONVERSION_FORMATS: Record<string, string[]> = {
MP3: ['32k', '64k', '96k', '128k', '192k', '256k', '320k'],
AAC: ['32k', '64k', '96k', '128k', '192k', '256k'],
OGG: ['64k', '96k', '128k', '192k', '256k', '320k'],
OPUS: ['32k', '64k', '96k', '128k', '192k', '256k'],
FLAC: [],
WAV: [],
ALAC: []
};
// --- API Functions ---
const saveDownloadConfig = async (data: Partial<DownloadSettings>) => {
const { data: response } = await apiClient.post('/config', data);
return response;
};
// --- Component ---
export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: saveDownloadConfig,
onSuccess: () => {
toast.success('Download settings saved successfully!');
queryClient.invalidateQueries({ queryKey: ['config'] });
},
onError: (error) => {
toast.error(`Failed to save settings: ${error.message}`);
},
});
const { register, handleSubmit, watch } = useForm<DownloadSettings>({
values: config,
});
const selectedFormat = watch('convertTo');
const onSubmit: SubmitHandler<DownloadSettings> = (data) => {
mutation.mutate({
...data,
maxConcurrentDownloads: Number(data.maxConcurrentDownloads),
maxRetries: Number(data.maxRetries),
retryDelaySeconds: Number(data.retryDelaySeconds),
retryDelayIncrease: Number(data.retryDelayIncrease),
});
};
if (isLoading) {
return <div>Loading download settings...</div>;
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
{/* Download Settings */}
<div className="space-y-4">
<h3 className="text-xl font-semibold">Download Behavior</h3>
<div className="flex flex-col gap-2">
<label htmlFor="maxConcurrentDownloads">Max Concurrent Downloads</label>
<input id="maxConcurrentDownloads" type="number" min="1" {...register('maxConcurrentDownloads')} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div className="flex items-center justify-between">
<label htmlFor="realTimeToggle">Real-time downloading</label>
<input id="realTimeToggle" type="checkbox" {...register('realTime')} className="h-6 w-6 rounded" />
</div>
<div className="flex items-center justify-between">
<label htmlFor="fallbackToggle">Download Fallback</label>
<input id="fallbackToggle" type="checkbox" {...register('fallback')} className="h-6 w-6 rounded" />
</div>
</div>
{/* Conversion Settings */}
<div className="space-y-4">
<h3 className="text-xl font-semibold">Conversion</h3>
<div className="flex flex-col gap-2">
<label htmlFor="convertToSelect">Convert To Format</label>
<select id="convertToSelect" {...register('convertTo')} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">No Conversion</option>
{Object.keys(CONVERSION_FORMATS).map(format => (
<option key={format} value={format}>{format}</option>
))}
</select>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="bitrateSelect">Bitrate</label>
<select id="bitrateSelect" {...register('bitrate')} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" disabled={!selectedFormat || CONVERSION_FORMATS[selectedFormat]?.length === 0}>
<option value="">Auto</option>
{(CONVERSION_FORMATS[selectedFormat] || []).map(rate => (
<option key={rate} value={rate}>{rate}</option>
))}
</select>
</div>
</div>
{/* Retry Options */}
<div className="space-y-4">
<h3 className="text-xl font-semibold">Retries</h3>
<div className="flex flex-col gap-2">
<label htmlFor="maxRetries">Max Retry Attempts</label>
<input id="maxRetries" type="number" min="0" {...register('maxRetries')} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div className="flex flex-col gap-2">
<label htmlFor="retryDelaySeconds">Initial Retry Delay (s)</label>
<input id="retryDelaySeconds" type="number" min="1" {...register('retryDelaySeconds')} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div className="flex flex-col gap-2">
<label htmlFor="retryDelayIncrease">Retry Delay Increase (s)</label>
<input id="retryDelayIncrease" type="number" min="0" {...register('retryDelayIncrease')} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
<button type="submit" disabled={mutation.isPending} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
{mutation.isPending ? 'Saving...' : 'Save Download Settings'}
</button>
</form>
);
}

View File

@@ -0,0 +1,156 @@
import { useRef } from 'react';
import { useForm, type SubmitHandler } from 'react-hook-form';
import apiClient from '../../lib/api-client';
import { toast } from 'sonner';
import { useMutation, useQueryClient } from '@tanstack/react-query';
// --- Type Definitions ---
interface FormattingSettings {
customDirFormat: string;
customTrackFormat: string;
tracknumPadding: boolean;
saveCover: boolean;
track: string;
album: string;
playlist: string;
compilation: string;
}
interface FormattingTabProps {
config: FormattingSettings;
isLoading: boolean;
}
// --- API Functions ---
const saveFormattingConfig = async (data: Partial<FormattingSettings>) => {
const { data: response } = await apiClient.post('/config', data);
return response;
};
// --- Placeholders ---
const placeholders = {
"Common": {
"%music%": "Track title",
"%artist%": "Track artist",
"%album%": "Album name",
"%ar_album%": "Album artist",
"%tracknum%": "Track number",
"%year%": "Year of release",
},
"Additional": {
"%discnum%": "Disc number",
"%date%": "Release date",
"%genre%": "Music genre",
"%isrc%": "ISRC",
"%explicit%": "Explicit flag",
"%duration%": "Track duration (s)",
},
};
const PlaceholderSelector = ({ onSelect }: { onSelect: (value: string) => void }) => (
<select
onChange={(e) => onSelect(e.target.value)}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm mt-1"
>
<option value="">-- Insert Placeholder --</option>
{Object.entries(placeholders).map(([group, options]) => (
<optgroup label={group} key={group}>
{Object.entries(options).map(([value, label]) => (
<option key={value} value={value}>{`${value} - ${label}`}</option>
))}
</optgroup>
))}
</select>
);
// --- Component ---
export function FormattingTab({ config, isLoading }: FormattingTabProps) {
const queryClient = useQueryClient();
const dirInputRef = useRef<HTMLInputElement | null>(null);
const trackInputRef = useRef<HTMLInputElement | null>(null);
const mutation = useMutation({
mutationFn: saveFormattingConfig,
onSuccess: () => {
toast.success('Formatting settings saved!');
queryClient.invalidateQueries({ queryKey: ['config'] });
},
onError: (error) => {
toast.error(`Failed to save settings: ${error.message}`);
},
});
const { register, handleSubmit, setValue } = useForm<FormattingSettings>({
values: config,
});
// Correctly register the refs for react-hook-form while also holding a local ref.
const { ref: dirFormatRef, ...dirFormatRest } = register('customDirFormat');
const { ref: trackFormatRef, ...trackFormatRest } = register('customTrackFormat');
const handlePlaceholderSelect = (field: 'customDirFormat' | 'customTrackFormat', inputRef: React.RefObject<HTMLInputElement | null>) => (value: string) => {
if (!value || !inputRef.current) return;
const { selectionStart, selectionEnd } = inputRef.current;
const currentValue = inputRef.current.value;
const newValue = currentValue.substring(0, selectionStart ?? 0) + value + currentValue.substring(selectionEnd ?? 0);
setValue(field, newValue);
};
const onSubmit: SubmitHandler<FormattingSettings> = (data) => {
mutation.mutate(data);
};
if (isLoading) {
return <div>Loading formatting settings...</div>;
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
<div className="space-y-4">
<h3 className="text-xl font-semibold">File Naming</h3>
<div className="flex flex-col gap-2">
<label htmlFor="customDirFormat">Custom Directory Format</label>
<input
id="customDirFormat"
type="text"
{...dirFormatRest}
ref={(e) => {
dirFormatRef(e);
dirInputRef.current = e;
}}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<PlaceholderSelector onSelect={handlePlaceholderSelect('customDirFormat', dirInputRef)} />
</div>
<div className="flex flex-col gap-2">
<label htmlFor="customTrackFormat">Custom Track Format</label>
<input
id="customTrackFormat"
type="text"
{...trackFormatRest}
ref={(e) => {
trackFormatRef(e);
trackInputRef.current = e;
}}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<PlaceholderSelector onSelect={handlePlaceholderSelect('customTrackFormat', trackInputRef)} />
</div>
<div className="flex items-center justify-between">
<label htmlFor="tracknumPaddingToggle">Track Number Padding</label>
<input id="tracknumPaddingToggle" type="checkbox" {...register('tracknumPadding')} className="h-6 w-6 rounded" />
</div>
<div className="flex items-center justify-between">
<label htmlFor="saveCoverToggle">Save Album Cover</label>
<input id="saveCoverToggle" type="checkbox" {...register('saveCover')} className="h-6 w-6 rounded" />
</div>
</div>
<button type="submit" disabled={mutation.isPending} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
{mutation.isPending ? 'Saving...' : 'Save Formatting Settings'}
</button>
</form>
);
}

View File

@@ -0,0 +1,149 @@
import { useForm } from 'react-hook-form';
import apiClient from '../../lib/api-client';
import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSettings } from '../../contexts/settings-context';
// --- Type Definitions ---
interface Credential {
name: string;
}
interface GeneralSettings {
service: 'spotify' | 'deezer';
spotify: string;
spotifyQuality: 'NORMAL' | 'HIGH' | 'VERY_HIGH';
deezer: string;
deezerQuality: 'MP3_128' | 'MP3_320' | 'FLAC';
}
interface GeneralTabProps {
config: GeneralSettings;
isLoading: boolean;
}
// --- API Functions ---
const fetchCredentials = async (service: 'spotify' | 'deezer'): Promise<Credential[]> => {
const { data } = await apiClient.get<string[]>(`/credentials/${service}`);
return data.map(name => ({ name }));
};
const saveGeneralConfig = (data: Partial<GeneralSettings>) => apiClient.post('/config', data);
// --- Component ---
export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabProps) {
const queryClient = useQueryClient();
const { settings: globalSettings, isLoading: settingsLoading } = useSettings();
const { data: spotifyAccounts, isLoading: spotifyLoading } = useQuery({ queryKey: ['credentials', 'spotify'], queryFn: () => fetchCredentials('spotify') });
const { data: deezerAccounts, isLoading: deezerLoading } = useQuery({ queryKey: ['credentials', 'deezer'], queryFn: () => fetchCredentials('deezer') });
const { register, handleSubmit } = useForm<GeneralSettings>({
values: config,
});
const mutation = useMutation({
mutationFn: saveGeneralConfig,
onSuccess: () => {
toast.success('General settings saved!');
queryClient.invalidateQueries({ queryKey: ['config'] });
},
onError: (e: Error) => toast.error(`Failed to save: ${e.message}`),
});
const onSubmit = (data: GeneralSettings) => mutation.mutate(data);
const isLoading = isConfigLoading || spotifyLoading || deezerLoading || settingsLoading;
if (isLoading) return <p>Loading general settings...</p>;
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
<div className="space-y-4">
<h3 className="text-xl font-semibold">Service Defaults</h3>
<div className="flex flex-col gap-2">
<label htmlFor="service">Default Service</label>
<select
id="service"
{...register('service')}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="spotify">Spotify</option>
<option value="deezer">Deezer</option>
</select>
</div>
</div>
<div className="space-y-4">
<h3 className="text-xl font-semibold">Spotify Settings</h3>
<div className="flex flex-col gap-2">
<label htmlFor="spotifyAccount">Active Spotify Account</label>
<select
id="spotifyAccount"
{...register('spotify')}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{spotifyAccounts?.map(acc => <option key={acc.name} value={acc.name}>{acc.name}</option>)}
</select>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="spotifyQuality">Spotify Quality</label>
<select
id="spotifyQuality"
{...register('spotifyQuality')}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="NORMAL">OGG 96kbps</option>
<option value="HIGH">OGG 160kbps</option>
<option value="VERY_HIGH">OGG 320kbps (Premium)</option>
</select>
</div>
</div>
<div className="space-y-4">
<h3 className="text-xl font-semibold">Deezer Settings</h3>
<div className="flex flex-col gap-2">
<label htmlFor="deezerAccount">Active Deezer Account</label>
<select
id="deezerAccount"
{...register('deezer')}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{deezerAccounts?.map(acc => <option key={acc.name} value={acc.name}>{acc.name}</option>)}
</select>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="deezerQuality">Deezer Quality</label>
<select
id="deezerQuality"
{...register('deezerQuality')}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="MP3_128">MP3 128kbps</option>
<option value="MP3_320">MP3 320kbps</option>
<option value="FLAC">FLAC (HiFi)</option>
</select>
</div>
</div>
<div className="space-y-4">
<h3 className="text-xl font-semibold">Content Filters</h3>
<div className="form-item--row">
<label>Filter Explicit Content</label>
<div className="flex items-center gap-2">
<span className={`font-semibold ${globalSettings?.explicitFilter ? 'text-green-400' : 'text-red-400'}`}>
{globalSettings?.explicitFilter ? 'Enabled' : 'Disabled'}
</span>
<span className="text-xs bg-gray-600 text-white px-2 py-1 rounded-full">ENV</span>
</div>
</div>
<p className="text-sm text-gray-500 mt-1">
The explicit content filter is controlled by an environment variable and cannot be changed here.
</p>
</div>
<button type="submit" disabled={mutation.isPending} className="btn-primary">
{mutation.isPending ? 'Saving...' : 'Save General Settings'}
</button>
</form>
);
}

View File

@@ -0,0 +1,174 @@
import { useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import apiClient from '../../lib/api-client';
import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// --- Type Definitions ---
interface SpotifyApiSettings {
client_id: string;
client_secret: string;
}
interface WebhookSettings {
url: string;
events: string[];
available_events: string[]; // Provided by API, not saved
}
// --- API Functions ---
const fetchSpotifyApiConfig = async (): Promise<SpotifyApiSettings> => {
const { data } = await apiClient.get('/credentials/spotify_api_config');
return data;
};
const saveSpotifyApiConfig = (data: SpotifyApiSettings) => apiClient.put('/credentials/spotify_api_config', data);
const fetchWebhookConfig = async (): Promise<WebhookSettings> => {
// Mock a response since backend endpoint doesn't exist
// This will prevent the UI from crashing.
return Promise.resolve({
url: '',
events: [],
available_events: ["download_start", "download_complete", "download_failed", "watch_added"],
});
};
const saveWebhookConfig = (data: Partial<WebhookSettings>) => {
toast.info("Webhook configuration is not available.");
return Promise.resolve(data);
};
const testWebhook = (url: string) => {
toast.info("Webhook testing is not available.");
return Promise.resolve(url);
}
// --- Components ---
function SpotifyApiForm() {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ['spotifyApiConfig'], queryFn: fetchSpotifyApiConfig });
const { register, handleSubmit, reset } = useForm<SpotifyApiSettings>();
const mutation = useMutation({
mutationFn: saveSpotifyApiConfig,
onSuccess: () => {
toast.success('Spotify API settings saved!');
queryClient.invalidateQueries({ queryKey: ['spotifyApiConfig'] });
},
onError: (e) => toast.error(`Failed to save: ${e.message}`),
});
useEffect(() => { if (data) reset(data); }, [data, reset]);
const onSubmit = (formData: SpotifyApiSettings) => mutation.mutate(formData);
if (isLoading) return <p>Loading Spotify API settings...</p>;
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="flex flex-col gap-2">
<label htmlFor="client_id">Client ID</label>
<input id="client_id" type="password" {...register('client_id')} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="Optional"/>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="client_secret">Client Secret</label>
<input id="client_secret" type="password" {...register('client_secret')} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="Optional" />
</div>
<button type="submit" disabled={mutation.isPending} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
{mutation.isPending ? 'Saving...' : 'Save Spotify API'}
</button>
</form>
);
}
function WebhookForm() {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ['webhookConfig'], queryFn: fetchWebhookConfig });
const { register, handleSubmit, control, reset, watch } = useForm<WebhookSettings>();
const currentUrl = watch('url');
const mutation = useMutation({
mutationFn: saveWebhookConfig,
onSuccess: () => {
// No toast needed since the function shows one
queryClient.invalidateQueries({ queryKey: ['webhookConfig'] });
},
onError: (e) => toast.error(`Failed to save: ${e.message}`),
});
const testMutation = useMutation({
mutationFn: testWebhook,
onSuccess: () => {
// No toast needed
},
onError: (e) => toast.error(`Webhook test failed: ${e.message}`),
});
useEffect(() => { if (data) reset(data); }, [data, reset]);
const onSubmit = (formData: WebhookSettings) => mutation.mutate(formData);
if (isLoading) return <p>Loading Webhook settings...</p>;
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="flex flex-col gap-2">
<label htmlFor="webhookUrl">Webhook URL</label>
<input id="webhookUrl" type="url" {...register('url')} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="https://example.com/webhook" />
</div>
<div className="flex flex-col gap-2">
<label>Webhook Events</label>
<div className="grid grid-cols-2 gap-4 pt-2">
{data?.available_events.map((event) => (
<Controller
key={event}
name="events"
control={control}
render={({ field }) => (
<label className="flex items-center gap-2">
<input
type="checkbox"
className="h-5 w-5 rounded"
checked={field.value?.includes(event) ?? false}
onChange={(e) => {
const value = field.value || [];
const newValues = e.target.checked
? [...value, event]
: value.filter((v) => v !== event);
field.onChange(newValues);
}}
/>
<span className="capitalize">{event.replace(/_/g, ' ')}</span>
</label>
)}
/>
))}
</div>
</div>
<div className="flex gap-2">
<button type="submit" disabled={mutation.isPending} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
{mutation.isPending ? 'Saving...' : 'Save Webhook'}
</button>
<button type="button" onClick={() => testMutation.mutate(currentUrl)} disabled={!currentUrl || testMutation.isPending} className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 disabled:opacity-50">
Test
</button>
</div>
</form>
);
}
export function ServerTab() {
return (
<div className="space-y-8">
<div>
<h3 className="text-xl font-semibold">Spotify API</h3>
<p className="text-sm text-gray-500 mt-1">Provide your own API credentials to avoid rate-limiting issues.</p>
<SpotifyApiForm />
</div>
<hr className="border-gray-600" />
<div>
<h3 className="text-xl font-semibold">Webhooks</h3>
<p className="text-sm text-gray-500 mt-1">Get notifications for events like download completion. (Currently disabled)</p>
<WebhookForm />
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { useEffect } from 'react';
import { useForm, type SubmitHandler, Controller } from 'react-hook-form';
import apiClient from '../../lib/api-client';
import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// --- Type Definitions ---
const ALBUM_GROUPS = ["album", "single", "compilation", "appears_on"] as const;
type AlbumGroup = typeof ALBUM_GROUPS[number];
interface WatchSettings {
enabled: boolean;
watchPollIntervalSeconds: number;
watchedArtistAlbumGroup: AlbumGroup[];
}
// --- API Functions ---
const fetchWatchConfig = async (): Promise<WatchSettings> => {
const { data } = await apiClient.get('/config/watch');
return data;
};
const saveWatchConfig = async (data: Partial<WatchSettings>) => {
const { data: response } = await apiClient.post('/config/watch', data);
return response;
};
// --- Component ---
export function WatchTab() {
const queryClient = useQueryClient();
const { data: config, isLoading } = useQuery({
queryKey: ['watchConfig'],
queryFn: fetchWatchConfig,
});
const mutation = useMutation({
mutationFn: saveWatchConfig,
onSuccess: () => {
toast.success('Watch settings saved successfully!');
queryClient.invalidateQueries({ queryKey: ['watchConfig'] });
},
onError: (error) => {
toast.error(`Failed to save settings: ${error.message}`);
},
});
const { register, handleSubmit, control, reset } = useForm<WatchSettings>();
useEffect(() => {
if (config) {
reset(config);
}
}, [config, reset]);
const onSubmit: SubmitHandler<WatchSettings> = (data) => {
mutation.mutate({
...data,
watchPollIntervalSeconds: Number(data.watchPollIntervalSeconds),
});
};
if (isLoading) {
return <div>Loading watch settings...</div>;
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
<div className="space-y-4">
<h3 className="text-xl font-semibold">Watchlist Behavior</h3>
<div className="flex items-center justify-between">
<label htmlFor="watchEnabledToggle">Enable Watchlist</label>
<input id="watchEnabledToggle" type="checkbox" {...register('enabled')} className="h-6 w-6 rounded" />
</div>
<div className="flex flex-col gap-2">
<label htmlFor="watchPollIntervalSeconds">Watch Poll Interval (seconds)</label>
<input id="watchPollIntervalSeconds" type="number" min="60" {...register('watchPollIntervalSeconds')} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" />
<p className="text-sm text-gray-500 mt-1">
How often to check watched items for updates.
</p>
</div>
</div>
<div className="space-y-4">
<h3 className="text-xl font-semibold">Artist Album Groups</h3>
<p className="text-sm text-gray-500">Select which album groups to monitor for watched artists.</p>
<div className="grid grid-cols-2 gap-4 pt-2">
{ALBUM_GROUPS.map((group) => (
<Controller
key={group}
name="watchedArtistAlbumGroup"
control={control}
render={({ field }) => (
<label className="flex items-center gap-2">
<input
type="checkbox"
className="h-5 w-5 rounded"
checked={field.value?.includes(group) ?? false}
onChange={(e) => {
const value = field.value || [];
const newValues = e.target.checked
? [...value, group]
: value.filter((v) => v !== group);
field.onChange(newValues);
}}
/>
<span className="capitalize">{group.replace('_', ' ')}</span>
</label>
)}
/>
))}
</div>
</div>
<button type="submit" disabled={mutation.isPending} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
{mutation.isPending ? 'Saving...' : 'Save Watch Settings'}
</button>
</form>
);
}

View File

@@ -0,0 +1,117 @@
import { useState, useCallback, type ReactNode, useEffect, useRef } from 'react';
import apiClient from '../lib/api-client';
import { QueueContext, type QueueItem } from './queue-context';
// --- Helper Types ---
interface TaskStatus {
status: 'downloading' | 'completed' | 'error' | 'queued';
progress?: number;
speed?: string;
size?: string;
eta?: string;
message?: string;
}
export function QueueProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<QueueItem[]>([]);
const [isVisible, setIsVisible] = useState(false);
const pollingIntervals = useRef<Record<string, number>>({});
// --- Core Action: Add Item ---
const addItem = useCallback(async (item: Omit<QueueItem, 'status'>) => {
const newItem: QueueItem = { ...item, status: 'queued' };
setItems(prev => [...prev, newItem]);
toggleVisibility();
try {
// This endpoint should initiate the download and return a task ID
const response = await apiClient.post<{ taskId: string }>(`/download/${item.type}`, { id: item.id });
const { taskId } = response.data;
// Update item with taskId and start polling
setItems(prev => prev.map(i => i.id === item.id ? { ...i, taskId, status: 'pending' } : i));
startPolling(taskId);
} catch (error) {
console.error(`Failed to start download for ${item.name}:`, error);
setItems(prev => prev.map(i => i.id === item.id ? { ...i, status: 'error', error: 'Failed to start download' } : i));
}
}, []);
// --- Polling Logic ---
const startPolling = (taskId: string) => {
if (pollingIntervals.current[taskId]) return; // Already polling
const intervalId = window.setInterval(async () => {
try {
const response = await apiClient.get<TaskStatus>(`/download/status/${taskId}`);
const statusUpdate = response.data;
setItems(prev => prev.map(item => {
if (item.taskId === taskId) {
const updatedItem = {
...item,
status: statusUpdate.status,
progress: statusUpdate.progress,
speed: statusUpdate.speed,
size: statusUpdate.size,
eta: statusUpdate.eta,
error: statusUpdate.status === 'error' ? statusUpdate.message : undefined,
};
if (statusUpdate.status === 'completed' || statusUpdate.status === 'error') {
stopPolling(taskId);
}
return updatedItem;
}
return item;
}));
} catch (error) {
console.error(`Polling failed for task ${taskId}:`, error);
stopPolling(taskId);
setItems(prev => prev.map(i => i.taskId === taskId ? { ...i, status: 'error', error: 'Connection lost' } : i));
}
}, 2000); // Poll every 2 seconds
pollingIntervals.current[taskId] = intervalId;
};
const stopPolling = (taskId: string) => {
clearInterval(pollingIntervals.current[taskId]);
delete pollingIntervals.current[taskId];
};
// Cleanup on unmount
useEffect(() => {
return () => {
Object.values(pollingIntervals.current).forEach(clearInterval);
};
}, []);
// --- Other Actions ---
const removeItem = useCallback((id: string) => {
const itemToRemove = items.find(i => i.id === id);
if (itemToRemove && itemToRemove.taskId) {
stopPolling(itemToRemove.taskId);
// Optionally, call an API to cancel the backend task
// apiClient.post(`/download/cancel/${itemToRemove.taskId}`);
}
setItems(prev => prev.filter(item => item.id !== id));
}, [items]);
const clearQueue = useCallback(() => {
Object.values(pollingIntervals.current).forEach(clearInterval);
pollingIntervals.current = {};
setItems([]);
// Optionally, call an API to cancel all tasks
}, []);
const toggleVisibility = useCallback(() => setIsVisible(prev => !prev), []);
const value = { items, isVisible, addItem, removeItem, clearQueue, toggleVisibility };
return (
<QueueContext.Provider value={value}>
{children}
</QueueContext.Provider>
);
}

View File

@@ -0,0 +1,118 @@
import { type ReactNode } from 'react';
import apiClient from '../lib/api-client';
import { SettingsContext, type AppSettings } from './settings-context';
import { useQuery } from '@tanstack/react-query';
// --- Case Conversion Utility ---
// This is added here to simplify the fix and avoid module resolution issues.
function snakeToCamel(str: string): string {
return str.replace(/(_\w)/g, m => m[1].toUpperCase());
}
function convertKeysToCamelCase(obj: unknown): unknown {
if (Array.isArray(obj)) {
return obj.map(v => convertKeysToCamelCase(v));
}
if (typeof obj === 'object' && obj !== null) {
return Object.keys(obj).reduce((acc: Record<string, unknown>, key: string) => {
const camelKey = snakeToCamel(key);
acc[camelKey] = convertKeysToCamelCase((obj as Record<string, unknown>)[key]);
return acc;
}, {});
}
return obj;
}
// Redefine AppSettings to match the flat structure of the API response
export type FlatAppSettings = {
service: 'spotify' | 'deezer';
spotify: string;
spotifyQuality: 'NORMAL' | 'HIGH' | 'VERY_HIGH';
deezer: string;
deezerQuality: 'MP3_128' | 'MP3_320' | 'FLAC';
maxConcurrentDownloads: number;
realTime: boolean;
fallback: boolean;
convertTo: 'MP3' | 'AAC' | 'OGG' | 'OPUS' | 'FLAC' | 'WAV' | 'ALAC' | '';
bitrate: string;
maxRetries: number;
retryDelaySeconds: number;
retryDelayIncrease: number;
customDirFormat: string;
customTrackFormat: string;
tracknumPadding: boolean;
saveCover: boolean;
explicitFilter: boolean;
// Add other fields from the old AppSettings as needed by other parts of the app
watch: AppSettings['watch'];
// Add defaults for the new download properties
threads: number;
path: string;
skipExisting: boolean;
m3u: boolean;
hlsThreads: number;
// Add defaults for the new formatting properties
track: string;
album: string;
playlist: string;
compilation: string;
};
const defaultSettings: FlatAppSettings = {
service: 'spotify',
spotify: '',
spotifyQuality: 'NORMAL',
deezer: '',
deezerQuality: 'MP3_128',
maxConcurrentDownloads: 3,
realTime: false,
fallback: false,
convertTo: '',
bitrate: '',
maxRetries: 3,
retryDelaySeconds: 5,
retryDelayIncrease: 5,
customDirFormat: '%ar_album%/%album%',
customTrackFormat: '%tracknum%. %music%',
tracknumPadding: true,
saveCover: true,
explicitFilter: false,
// Add defaults for the new download properties
threads: 4,
path: '/downloads',
skipExisting: true,
m3u: false,
hlsThreads: 8,
// Add defaults for the new formatting properties
track: '{artist_name}/{album_name}/{track_number} - {track_name}',
album: '{artist_name}/{album_name}',
playlist: 'Playlists/{playlist_name}',
compilation: 'Compilations/{album_name}',
watch: {
enabled: false,
},
};
const fetchSettings = async (): Promise<FlatAppSettings> => {
const { data } = await apiClient.get('/config');
// Transform the keys before returning the data
return convertKeysToCamelCase(data) as FlatAppSettings;
};
export function SettingsProvider({ children }: { children: ReactNode }) {
const { data: settings, isLoading, isError } = useQuery({
queryKey: ['config'],
queryFn: fetchSettings,
staleTime: 1000 * 60 * 5, // 5 minutes
refetchOnWindowFocus: false,
});
// Use default settings on error to prevent app crash
const value = { settings: isError ? defaultSettings : (settings || null), isLoading };
return (
<SettingsContext.Provider value={value}>
{children}
</SettingsContext.Provider>
);
}

View File

@@ -0,0 +1,34 @@
import { createContext, useContext } from 'react';
export interface QueueItem {
id: string; // This is the Spotify ID
type: 'track' | 'album' | 'artist' | 'playlist';
name: string;
// --- Real-time progress fields ---
status: 'pending' | 'downloading' | 'completed' | 'error' | 'queued';
taskId?: string; // The backend task ID for polling
progress?: number;
speed?: string;
size?: string;
eta?: string;
error?: string;
}
export interface QueueContextType {
items: QueueItem[];
isVisible: boolean;
addItem: (item: Omit<QueueItem, 'status'>) => void;
removeItem: (id: string) => void;
clearQueue: () => void;
toggleVisibility: () => void;
}
export const QueueContext = createContext<QueueContextType | undefined>(undefined);
export function useQueue() {
const context = useContext(QueueContext);
if (context === undefined) {
throw new Error('useQueue must be used within a QueueProvider');
}
return context;
}

View File

@@ -0,0 +1,54 @@
import { createContext, useContext } from 'react';
// This new type reflects the flat structure of the /api/config response
export interface AppSettings {
service: 'spotify' | 'deezer';
spotify: string;
spotifyQuality: 'NORMAL' | 'HIGH' | 'VERY_HIGH';
deezer: string;
deezerQuality: 'MP3_128' | 'MP3_320' | 'FLAC';
maxConcurrentDownloads: number;
realTime: boolean;
fallback: boolean;
convertTo: 'MP3' | 'AAC' | 'OGG' | 'OPUS' | 'FLAC' | 'WAV' | 'ALAC' | '';
bitrate: string;
maxRetries: number;
retryDelaySeconds: number;
retryDelayIncrease: number;
customDirFormat: string;
customTrackFormat: string;
tracknumPadding: boolean;
saveCover: boolean;
explicitFilter: boolean;
// Properties from the old 'downloads' object
threads: number;
path: string;
skipExisting: boolean;
m3u: boolean;
hlsThreads: number;
// Properties from the old 'formatting' object
track: string;
album: string;
playlist: string;
compilation: string;
watch: {
enabled: boolean;
// Add other watch properties from the old type if they still exist in the API response
};
// Add other root-level properties from the API if they exist
}
export interface SettingsContextType {
settings: AppSettings | null;
isLoading: boolean;
}
export const SettingsContext = createContext<SettingsContextType | undefined>(undefined);
export function useSettings() {
const context = useContext(SettingsContext);
if (context === undefined) {
throw new Error('useSettings must be used within a SettingsProvider');
}
return context;
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,41 @@
import axios from 'axios';
import { toast } from 'sonner';
const apiClient = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
timeout: 10000, // 10 seconds timeout
});
// Response interceptor for error handling
apiClient.interceptors.response.use(
(response) => {
const contentType = response.headers['content-type'];
if (contentType && contentType.includes('application/json')) {
return response;
}
// If the response is not JSON, reject it to trigger the error handling
const error = new Error('Invalid response type. Expected JSON.');
toast.error('API Error', {
description: 'Received an invalid response from the server. Expected JSON data.',
});
return Promise.reject(error);
},
(error) => {
if (error.code === 'ECONNABORTED') {
toast.error('Request Timed Out', {
description: 'The server did not respond in time. Please try again later.',
});
} else {
const errorMessage = error.response?.data?.error || error.message || 'An unknown error occurred.';
toast.error('API Error', {
description: errorMessage,
});
}
return Promise.reject(error);
}
);
export default apiClient;

11
spotizerr-ui/src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { RouterProvider } from '@tanstack/react-router';
import { router } from './router';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>,
);

View File

@@ -0,0 +1,81 @@
import { createRouter, createRootRoute, createRoute } from '@tanstack/react-router';
import { Root } from './routes/root';
import { Album } from './routes/album';
import { Artist } from './routes/artist';
import { Track } from './routes/track';
import { Home } from './routes/home';
import { Config } from './routes/config';
import { Playlist } from './routes/playlist';
import { History } from './routes/history';
import { Watchlist } from './routes/watchlist';
const rootRoute = createRootRoute({
component: Root,
});
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: Home,
});
const albumRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/album/$albumId',
component: Album,
});
const artistRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/artist/$artistId',
component: Artist,
});
const trackRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/track/$trackId',
component: Track,
});
const configRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/config',
component: Config,
});
const playlistRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/playlist/$playlistId',
component: Playlist,
});
const historyRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/history',
component: History,
});
const watchlistRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/watchlist',
component: Watchlist,
});
const routeTree = rootRoute.addChildren([
indexRoute,
albumRoute,
artistRoute,
trackRoute,
configRoute,
playlistRoute,
historyRoute,
watchlistRoute,
]);
export const router = createRouter({ routeTree });
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}

View File

@@ -0,0 +1,168 @@
import { Link, useParams } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
import apiClient from '../lib/api-client';
import { useQueue } from '../contexts/queue-context';
import { useSettings } from '../contexts/settings-context';
import type { AlbumType, TrackType } from '../types/spotify';
export const Album = () => {
const { albumId } = useParams({ from: '/album/$albumId' });
const [album, setAlbum] = useState<AlbumType | null>(null);
const [error, setError] = useState<string | null>(null);
const { addItem, toggleVisibility } = useQueue();
const { settings } = useSettings();
useEffect(() => {
const fetchAlbum = async () => {
try {
const response = await apiClient.get(`/album/info?id=${albumId}`);
setAlbum(response.data);
} catch (err) {
setError('Failed to load album');
console.error('Error fetching album:', err);
}
};
if (albumId) {
fetchAlbum();
}
}, [albumId]);
const handleDownloadTrack = (track: TrackType) => {
addItem({ id: track.id, type: 'track', name: track.name });
toggleVisibility();
};
const handleDownloadAlbum = () => {
if (!album) return;
addItem({ id: album.id, type: 'album', name: album.name });
toggleVisibility();
};
if (error) {
return <div className="text-red-500">{error}</div>;
}
if (!album) {
return <div>Loading...</div>;
}
const isExplicitFilterEnabled = settings?.explicitFilter ?? false;
// Show placeholder for an entirely explicit album
if (isExplicitFilterEnabled && album.explicit) {
return (
<div className="p-8 text-center border rounded-lg">
<h2 className="text-2xl font-bold">Explicit Content Filtered</h2>
<p className="mt-2 text-gray-500">This album has been filtered based on your settings.</p>
</div>
);
}
const hasExplicitTrack = album.tracks.items.some(track => track.explicit);
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row items-start gap-6">
<img
src={album.images[0]?.url || '/placeholder.jpg'}
alt={album.name}
className="w-48 h-48 object-cover rounded-lg shadow-lg"
/>
<div className="flex-grow space-y-2">
<h1 className="text-3xl font-bold">{album.name}</h1>
<p className="text-lg text-gray-500 dark:text-gray-400">
By{' '}
{album.artists.map((artist, index) => (
<span key={artist.id}>
<Link
to="/artist/$artistId"
params={{ artistId: artist.id }}
className="hover:underline"
>
{artist.name}
</Link>
{index < album.artists.length - 1 && ', '}
</span>
))}
</p>
<p className="text-sm text-gray-400 dark:text-gray-500">
{new Date(album.release_date).getFullYear()} {album.total_tracks} songs
</p>
<p className="text-xs text-gray-400 dark:text-gray-600">
{album.label}
</p>
</div>
<div className="flex flex-col items-center gap-2">
<button
onClick={handleDownloadAlbum}
disabled={isExplicitFilterEnabled && hasExplicitTrack}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
title={isExplicitFilterEnabled && hasExplicitTrack ? 'Album contains explicit tracks' : 'Download Full Album'}
>
Download Album
</button>
</div>
</div>
<div className="space-y-4">
<h2 className="text-xl font-semibold">Tracks</h2>
<div className="space-y-2">
{album.tracks.items.map((track, index) => {
if (isExplicitFilterEnabled && track.explicit) {
return (
<div key={index} className="flex items-center justify-between p-3 bg-gray-100 dark:bg-gray-800 rounded-lg opacity-50">
<div className="flex items-center gap-4">
<span className="text-gray-500 dark:text-gray-400 w-8 text-right">{index + 1}</span>
<p className="font-medium text-gray-500">Explicit track filtered</p>
</div>
<span className="text-gray-500">--:--</span>
</div>
)
}
return (
<div
key={track.id}
className="flex items-center justify-between p-3 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
<div className="flex items-center gap-4">
<span className="text-gray-500 dark:text-gray-400 w-8 text-right">{index + 1}</span>
<div>
<p className="font-medium">{track.name}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{track.artists.map((artist, index) => (
<span key={artist.id}>
<Link
to="/artist/$artistId"
params={{ artistId: artist.id }}
className="hover:underline"
>
{artist.name}
</Link>
{index < track.artists.length - 1 && ', '}
</span>
))}
</p>
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-gray-500 dark:text-gray-400">
{Math.floor(track.duration_ms / 60000)}:
{((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, '0')}
</span>
<button
onClick={() => handleDownloadTrack(track)}
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-full"
title="Download"
>
<img src="/download.svg" alt="Download" className="w-5 h-5" />
</button>
</div>
</div>
)
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,253 @@
import { Link, useParams } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import apiClient from '../lib/api-client';
import { useQueue } from '../contexts/queue-context';
import type { AlbumType } from '../types/spotify';
interface ArtistInfo {
artist: {
name: string;
images: { url: string }[];
followers: { total: number };
};
topTracks: Track[];
albums: AlbumGroup;
}
interface Track {
id: string;
name:string;
duration_ms: number;
album: {
id: string;
name: string;
images: { url: string }[];
};
}
interface UAlbum extends AlbumType {
is_known?: boolean;
}
interface AlbumGroup {
album: UAlbum[];
single: UAlbum[];
appears_on: UAlbum[];
}
export const Artist = () => {
const { artistId } = useParams({ from: '/artist/$artistId' });
const [artistInfo, setArtistInfo] = useState<ArtistInfo | null>(null);
const [isWatched, setIsWatched] = useState(false);
const [isWatchEnabled, setIsWatchEnabled] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const { addItem, toggleVisibility } = useQueue();
useEffect(() => {
const fetchAllData = async () => {
if (!artistId) return;
setIsLoading(true);
try {
const [infoRes, watchConfigRes, watchStatusRes] = await Promise.all([
apiClient.get<ArtistInfo>(`/artist/info?id=${artistId}`),
apiClient.get('/config/watch'),
apiClient.get(`/artist/watch/status?id=${artistId}`),
]);
setArtistInfo(infoRes.data);
setIsWatchEnabled(watchConfigRes.data.enabled);
setIsWatched(watchStatusRes.data.is_watched);
} catch {
// The API client interceptor will now handle showing the error toast
} finally {
setIsLoading(false);
}
};
fetchAllData();
}, [artistId]);
const handleDownloadTrack = (track: Track) => {
addItem({ id: track.id, type: 'track', name: track.name });
toggleVisibility();
};
const handleDownloadAll = () => {
if (!artistId || !artistInfo) return;
addItem({ id: artistId, type: 'artist', name: artistInfo.artist.name });
toggleVisibility();
};
const handleWatch = async () => {
if (!artistId) return;
const originalState = isWatched;
setIsWatched(!originalState); // Optimistic update
try {
await apiClient.post(originalState ? '/artist/unwatch' : '/artist/watch', { artistId });
toast.success(`Artist ${originalState ? 'unwatched' : 'watched'} successfully.`);
} catch {
setIsWatched(originalState); // Revert on error
}
};
const handleSync = async () => {
if (!artistId) return;
toast.info('Syncing artist...', { id: 'sync-artist' });
try {
await apiClient.post('/artist/sync', { artistId });
toast.success('Artist sync completed.', { id: 'sync-artist' });
} catch {
toast.error('Artist sync failed.', { id: 'sync-artist' });
}
};
const handleMarkAsKnown = async (albumId: string, known: boolean) => {
if (!artistId) return;
try {
await apiClient.post('/artist/album/mark', { artistId, albumId, known });
setArtistInfo(prev => {
if (!prev) return null;
const updateAlbums = (albums: UAlbum[]) => albums.map(a => a.id === albumId ? { ...a, is_known: known } : a);
return {
...prev,
albums: {
album: updateAlbums(prev.albums.album),
single: updateAlbums(prev.albums.single),
appears_on: updateAlbums(prev.albums.appears_on),
}
}
});
toast.success(`Album marked as ${known ? 'seen' : 'unseen'}.`);
} catch {
// Error toast handled by interceptor
}
};
if (isLoading) return <div>Loading artist...</div>;
if (!artistInfo) return <div className="p-4 text-center">Could not load artist details.</div>;
const { artist, topTracks, albums } = artistInfo;
const renderAlbumCard = (album: UAlbum) => (
<div key={album.id} className="w-40 flex-shrink-0 group relative">
<Link to="/album/$albumId" params={{ albumId: album.id }}>
<img
src={album.images[0]?.url || '/placeholder.jpg'}
alt={album.name}
className={`w-full h-40 object-cover rounded-lg shadow-md group-hover:shadow-lg transition-shadow ${album.is_known ? 'opacity-50' : ''}`}
/>
<p className="mt-2 text-sm font-semibold truncate">{album.name}</p>
<p className="text-xs text-gray-500">{new Date(album.release_date).getFullYear()}</p>
</Link>
{isWatched && (
<button
onClick={() => handleMarkAsKnown(album.id, !album.is_known)}
title={album.is_known ? 'Mark as not seen' : 'Mark as seen'}
className="absolute top-1 right-1 bg-white/70 dark:bg-black/70 p-1.5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<img src={album.is_known ? '/check.svg' : '/plus-circle.svg'} alt="Mark" className="w-5 h-5" />
</button>
)}
</div>
);
return (
<div className="space-y-8">
{/* Artist Header */}
<div className="flex flex-col md:flex-row items-center gap-8">
<img
src={artist.images[0]?.url || '/placeholder.jpg'}
alt={artist.name}
className="w-48 h-48 rounded-full object-cover shadow-2xl"
/>
<div className="text-center md:text-left flex-grow">
<h1 className="text-5xl font-extrabold">{artist.name}</h1>
<p className="text-gray-500 mt-2">{artist.followers.total.toLocaleString()} followers</p>
<div className="mt-4 flex flex-wrap gap-2 justify-center md:justify-start">
<button
onClick={handleDownloadAll}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center gap-2"
>
<img src="/download.svg" alt="" className="w-5 h-5" />
Download All
</button>
{isWatchEnabled && (
<>
<button
onClick={handleWatch}
className={`px-4 py-2 rounded-lg transition-colors flex items-center justify-center gap-2 ${
isWatched
? 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
<img src={isWatched ? '/eye-crossed.svg' : '/eye.svg'} alt="" className="w-5 h-5" />
{isWatched ? 'Unwatch' : 'Watch'}
</button>
{isWatched && (
<button
onClick={handleSync}
className="p-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
title="Sync Artist"
>
<img src="/refresh-cw.svg" alt="Sync" className="w-5 h-5" />
</button>
)}
</>
)}
</div>
</div>
</div>
{/* Top Tracks */}
<section>
<h2 className="text-2xl font-bold mb-4">Top Tracks</h2>
<div className="space-y-2">
{topTracks.map((track) => (
<div key={track.id} className="flex items-center justify-between p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
<div className="flex items-center gap-4">
<img src={track.album.images[2]?.url || '/placeholder.jpg'} alt={track.album.name} className="w-12 h-12 rounded-md" />
<div>
<p className="font-semibold">{track.name}</p>
<p className="text-sm text-gray-500">
{Math.floor(track.duration_ms / 60000)}:
{((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, '0')}
</p>
</div>
</div>
<button onClick={() => handleDownloadTrack(track)} className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-full">
<img src="/download.svg" alt="Download" className="w-5 h-5" />
</button>
</div>
))}
</div>
</section>
{/* Albums */}
<section>
<h2 className="text-2xl font-bold mb-4">Albums</h2>
<div className="flex gap-4 overflow-x-auto pb-4">
{albums.album.map(renderAlbumCard)}
</div>
</section>
{/* Singles */}
<section>
<h2 className="text-2xl font-bold mb-4">Singles & EPs</h2>
<div className="flex gap-4 overflow-x-auto pb-4">
{albums.single.map(renderAlbumCard)}
</div>
</section>
{/* Appears On */}
<section>
<h2 className="text-2xl font-bold mb-4">Appears On</h2>
<div className="flex gap-4 overflow-x-auto pb-4">
{albums.appears_on.map(renderAlbumCard)}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { useState } from 'react';
import { GeneralTab } from '../components/config/GeneralTab';
import { DownloadsTab } from '../components/config/DownloadsTab';
import { FormattingTab } from '../components/config/FormattingTab';
import { AccountsTab } from '../components/config/AccountsTab';
import { WatchTab } from '../components/config/WatchTab';
import { ServerTab } from '../components/config/ServerTab';
import { useSettings } from '../contexts/settings-context';
const ConfigComponent = () => {
const [activeTab, setActiveTab] = useState('general');
// Get settings from the context instead of fetching here
const { settings: config, isLoading } = useSettings();
const renderTabContent = () => {
if (isLoading) return <p className="text-center">Loading configuration...</p>;
if (!config) return <p className="text-center text-red-500">Error loading configuration.</p>;
switch (activeTab) {
case 'general':
return <GeneralTab config={config} isLoading={isLoading} />;
case 'downloads':
return <DownloadsTab config={config} isLoading={isLoading} />;
case 'formatting':
return <FormattingTab config={config} isLoading={isLoading} />;
case 'accounts':
return <AccountsTab />;
case 'watch':
return <WatchTab />;
case 'server':
return <ServerTab />;
default:
return null;
}
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Configuration</h1>
<p className="text-gray-500">Manage application settings and services.</p>
</div>
<div className="flex gap-8">
<aside className="w-1/4">
<nav className="flex flex-col space-y-1">
<button onClick={() => setActiveTab('general')} className={`p-2 rounded-md text-left ${activeTab === 'general' ? 'bg-gray-100 dark:bg-gray-800 font-semibold' : ''}`}>General</button>
<button onClick={() => setActiveTab('downloads')} className={`p-2 rounded-md text-left ${activeTab === 'downloads' ? 'bg-gray-100 dark:bg-gray-800 font-semibold' : ''}`}>Downloads</button>
<button onClick={() => setActiveTab('formatting')} className={`p-2 rounded-md text-left ${activeTab === 'formatting' ? 'bg-gray-100 dark:bg-gray-800 font-semibold' : ''}`}>Formatting</button>
<button onClick={() => setActiveTab('accounts')} className={`p-2 rounded-md text-left ${activeTab === 'accounts' ? 'bg-gray-100 dark:bg-gray-800 font-semibold' : ''}`}>Accounts</button>
<button onClick={() => setActiveTab('watch')} className={`p-2 rounded-md text-left ${activeTab === 'watch' ? 'bg-gray-100 dark:bg-gray-800 font-semibold' : ''}`}>Watch</button>
<button onClick={() => setActiveTab('server')} className={`p-2 rounded-md text-left ${activeTab === 'server' ? 'bg-gray-100 dark:bg-gray-800 font-semibold' : ''}`}>Server</button>
</nav>
</aside>
<main className="w-3/4">
{renderTabContent()}
</main>
</div>
</div>
);
};
export const Config = () => {
return (
<ConfigComponent />
)
};

View File

@@ -0,0 +1,203 @@
import { useEffect, useState, useMemo } from 'react';
import apiClient from '../lib/api-client';
import { toast } from 'sonner';
import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
getSortedRowModel,
type SortingState,
} from '@tanstack/react-table';
// --- Type Definitions ---
type HistoryEntry = {
item_name: string;
item_artist: string;
download_type: 'track' | 'album' | 'playlist' | 'artist';
service_used: string;
quality_profile: string;
status_final: 'COMPLETED' | 'ERROR' | 'CANCELLED';
timestamp_completed: number;
error_message?: string;
};
// --- Column Definitions ---
const columnHelper = createColumnHelper<HistoryEntry>();
const columns = [
columnHelper.accessor('item_name', { header: 'Name' }),
columnHelper.accessor('item_artist', { header: 'Artist' }),
columnHelper.accessor('download_type', { header: 'Type', cell: info => <span className="capitalize">{info.getValue()}</span> }),
columnHelper.accessor('status_final', {
header: 'Status',
cell: info => {
const status = info.getValue();
const statusClass = {
COMPLETED: 'text-green-500',
ERROR: 'text-red-500',
CANCELLED: 'text-yellow-500',
}[status];
return <span className={`font-semibold ${statusClass}`}>{status}</span>;
},
}),
columnHelper.accessor('timestamp_completed', {
header: 'Date Completed',
cell: info => new Date(info.getValue() * 1000).toLocaleString(),
}),
columnHelper.accessor('error_message', {
header: 'Details',
cell: info => info.getValue() ? (
<button onClick={() => toast.info('Error Details', { description: info.getValue() })} className="text-blue-500 hover:underline">
Show Error
</button>
) : null,
})
];
export const History = () => {
const [data, setData] = useState<HistoryEntry[]>([]);
const [totalEntries, setTotalEntries] = useState(0);
const [isLoading, setIsLoading] = useState(true);
// State for TanStack Table
const [sorting, setSorting] = useState<SortingState>([{ id: 'timestamp_completed', desc: true }]);
const [{ pageIndex, pageSize }, setPagination] = useState({ pageIndex: 0, pageSize: 25 });
// State for filters
const [statusFilter, setStatusFilter] = useState('');
const [typeFilter, setTypeFilter] = useState('');
const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]);
useEffect(() => {
const fetchHistory = async () => {
setIsLoading(true);
try {
const params = new URLSearchParams({
limit: `${pageSize}`,
offset: `${pageIndex * pageSize}`,
sort_by: sorting[0]?.id ?? 'timestamp_completed',
sort_order: sorting[0]?.desc ? 'DESC' : 'ASC',
});
if (statusFilter) params.append('status_final', statusFilter);
if (typeFilter) params.append('download_type', typeFilter);
const response = await apiClient.get<{ entries: HistoryEntry[], total_count: number }>(`/history?${params.toString()}`);
setData(response.data.entries);
setTotalEntries(response.data.total_count);
} catch {
toast.error('Failed to load history.');
} finally {
setIsLoading(false);
}
};
fetchHistory();
}, [pageIndex, pageSize, sorting, statusFilter, typeFilter]);
const table = useReactTable({
data,
columns,
pageCount: Math.ceil(totalEntries / pageSize),
state: { sorting, pagination },
onPaginationChange: setPagination,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
manualPagination: true,
manualSorting: true,
});
return (
<div className="space-y-4">
<h1 className="text-3xl font-bold">Download History</h1>
{/* Filter Controls */}
<div className="flex gap-4">
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700">
<option value="">All Statuses</option>
<option value="COMPLETED">Completed</option>
<option value="ERROR">Error</option>
<option value="CANCELLED">Cancelled</option>
</select>
<select value={typeFilter} onChange={e => setTypeFilter(e.target.value)} className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700">
<option value="">All Types</option>
<option value="track">Track</option>
<option value="album">Album</option>
<option value="playlist">Playlist</option>
<option value="artist">Artist</option>
</select>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="min-w-full">
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th key={header.id} className="p-2 text-left">
{header.isPlaceholder ? null : (
<div
{...{
className: header.column.getCanSort() ? 'cursor-pointer select-none' : '',
onClick: header.column.getToggleSortingHandler(),
}}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{{ asc: ' ▲', desc: ' ▼'}[header.column.getIsSorted() as string] ?? null}
</div>
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={columns.length} className="text-center p-4">Loading...</td></tr>
) : table.getRowModel().rows.length === 0 ? (
<tr><td colSpan={columns.length} className="text-center p-4">No history entries found.</td></tr>
) : (
table.getRowModel().rows.map(row => (
<tr key={row.id} className="border-b dark:border-gray-700">
{row.getVisibleCells().map(cell => (
<td key={cell.id} className="p-2">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination Controls */}
<div className="flex items-center justify-between gap-2">
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} className="p-2 border rounded-md disabled:opacity-50">
Previous
</button>
<span>
Page{' '}
<strong>
{table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</strong>
</span>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} className="p-2 border rounded-md disabled:opacity-50">
Next
</button>
<select
value={table.getState().pagination.pageSize}
onChange={e => table.setPageSize(Number(e.target.value))}
className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700"
>
{[10, 25, 50, 100].map(size => (
<option key={size} value={size}>
Show {size}
</option>
))}
</select>
</div>
</div>
);
}

View File

@@ -0,0 +1,150 @@
import { useState, useEffect, useMemo } from 'react';
import { Link } from '@tanstack/react-router';
import { useDebounce } from 'use-debounce';
import apiClient from '../lib/api-client';
import { useQueue } from '../contexts/queue-context';
// --- Type Definitions ---
interface Image { url: string; }
interface BaseItem { id: string; name: string; }
interface Artist extends BaseItem { images?: Image[]; }
interface Album extends BaseItem { images?: Image[]; artists: Artist[]; }
interface Track extends BaseItem { album: Album; artists: Artist[]; }
interface Playlist extends BaseItem { images?: Image[]; owner: { display_name: string }; }
type SearchResultItem = Artist | Album | Track | Playlist;
type SearchType = 'artist' | 'album' | 'track' | 'playlist';
// --- Component ---
export function Home() {
const [query, setQuery] = useState('');
const [searchType, setSearchType] = useState<SearchType>('track');
const [results, setResults] = useState<SearchResultItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [debouncedQuery] = useDebounce(query, 500);
const { addItem, toggleVisibility } = useQueue();
useEffect(() => {
const performSearch = async () => {
if (debouncedQuery.trim().length < 2) {
setResults([]);
return;
}
setIsLoading(true);
try {
const response = await apiClient.get<{ items: SearchResultItem[] }>('/search', {
params: { q: debouncedQuery, search_type: searchType, limit: 40 },
});
setResults(response.data.items);
} catch (error) {
console.error('Search failed:', error);
setResults([]);
} finally {
setIsLoading(false);
}
};
performSearch();
}, [debouncedQuery, searchType]);
const handleDownloadTrack = (track: Track) => {
addItem({ id: track.id, type: 'track', name: track.name });
toggleVisibility();
};
const renderResult = (item: SearchResultItem) => {
switch (searchType) {
case 'track': {
const track = item as Track;
return (
<div key={track.id} className="p-2 flex items-center gap-4 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
<img src={track.album.images?.[0]?.url || '/placeholder.jpg'} alt={track.album.name} className="w-12 h-12 rounded" />
<div className="flex-grow">
<p className="font-semibold">{track.name}</p>
<p className="text-sm text-gray-500">{track.artists.map(a => a.name).join(', ')}</p>
</div>
<button onClick={() => handleDownloadTrack(track)} className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-full">
<img src="/download.svg" alt="Download" className="w-5 h-5" />
</button>
</div>
);
}
case 'album': {
const album = item as Album;
return (
<Link to="/album/$albumId" params={{ albumId: album.id }} key={album.id} className="block p-2 text-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
<img src={album.images?.[0]?.url || '/placeholder.jpg'} alt={album.name} className="w-full h-auto object-cover rounded shadow-md aspect-square" />
<p className="mt-2 font-semibold truncate">{album.name}</p>
<p className="text-sm text-gray-500">{album.artists.map(a => a.name).join(', ')}</p>
</Link>
);
}
case 'artist': {
const artist = item as Artist;
return (
<Link to="/artist/$artistId" params={{ artistId: artist.id }} key={artist.id} className="block p-2 text-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
<img src={artist.images?.[0]?.url || '/placeholder.jpg'} alt={artist.name} className="w-full h-auto object-cover rounded-full shadow-md aspect-square" />
<p className="mt-2 font-semibold truncate">{artist.name}</p>
</Link>
);
}
case 'playlist': {
const playlist = item as Playlist;
return (
<Link to="/playlist/$playlistId" params={{ playlistId: playlist.id }} key={playlist.id} className="block p-2 text-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
<img src={playlist.images?.[0]?.url || '/placeholder.jpg'} alt={playlist.name} className="w-full h-auto object-cover rounded shadow-md aspect-square" />
<p className="mt-2 font-semibold truncate">{playlist.name}</p>
<p className="text-sm text-gray-500">by {playlist.owner.display_name}</p>
</Link>
);
}
default:
return null;
}
};
const gridClass = useMemo(() => {
switch(searchType) {
case 'album':
case 'artist':
case 'playlist':
return "grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4";
case 'track':
return "flex flex-col gap-1";
default:
return "";
}
}, [searchType]);
return (
<div className="space-y-6">
<div className="relative">
<img src="/search.svg" alt="" className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for songs, albums, artists..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-700 rounded-full bg-gray-100 dark:bg-gray-800"
/>
<select
value={searchType}
onChange={(e) => setSearchType(e.target.value as SearchType)}
className="absolute right-2 top-1/2 -translate-y-1/2 bg-transparent border-none text-gray-500"
>
<option value="track">Tracks</option>
<option value="album">Albums</option>
<option value="artist">Artists</option>
<option value="playlist">Playlists</option>
</select>
</div>
<div>
{isLoading && <p>Loading...</p>}
{!isLoading && debouncedQuery && results.length === 0 && <p>No results found.</p>}
<div className={gridClass}>
{results.map(renderResult)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { Link, useParams } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
import apiClient from '../lib/api-client';
import { useQueue } from '../contexts/queue-context';
import { useSettings } from '../contexts/settings-context';
import { toast } from 'sonner';
import type { ImageType, TrackType } from '../types/spotify';
// --- Type Definitions ---
interface SimplifiedAlbumType {
id: string;
name: string;
images: ImageType[];
}
interface PlaylistTrackType extends TrackType {
album: SimplifiedAlbumType;
}
interface PlaylistItemType { track: PlaylistTrackType | null; }
interface PlaylistDetailsType {
id:string;
name: string;
description: string | null;
images: ImageType[];
owner: { display_name?: string };
followers?: { total: number };
tracks: { items: PlaylistItemType[]; total: number; };
}
export const Playlist = () => {
const { playlistId } = useParams({ from: '/playlist/$playlistId' });
const [playlist, setPlaylist] = useState<PlaylistDetailsType | null>(null);
const [isLoading, setIsLoading] = useState(true);
const { addItem, toggleVisibility } = useQueue();
const { settings } = useSettings();
useEffect(() => {
const fetchPlaylist = async () => {
if (!playlistId) return;
setIsLoading(true);
try {
const response = await apiClient.get<PlaylistDetailsType>(`/playlist/info?id=${playlistId}`);
setPlaylist(response.data);
} catch {
toast.error('Failed to load playlist details.');
} finally {
setIsLoading(false);
}
};
fetchPlaylist();
}, [playlistId]);
const handleDownloadTrack = (track: PlaylistTrackType) => {
addItem({ id: track.id, type: 'track', name: track.name });
toggleVisibility();
};
const handleDownloadPlaylist = () => {
if (!playlist) return;
// This assumes a backend endpoint that can handle a whole playlist download by its ID
addItem({ id: playlist.id, type: 'playlist', name: playlist.name });
toggleVisibility();
toast.success(`Queued playlist: ${playlist.name}`);
}
if (isLoading) return <div>Loading playlist...</div>;
if (!playlist) return <div>Playlist not found.</div>;
const isExplicitFilterEnabled = settings?.explicitFilter ?? false;
const hasExplicitTrack = playlist.tracks.items.some(item => item.track?.explicit);
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row items-start gap-8">
<img src={playlist.images[0]?.url || '/placeholder.jpg'} alt={playlist.name} className="w-48 h-48 object-cover rounded-lg shadow-lg"/>
<div className="flex-grow space-y-2">
<h1 className="text-4xl font-bold">{playlist.name}</h1>
<p className="text-gray-500">By {playlist.owner.display_name}</p>
{playlist.description && <p className="text-sm text-gray-400" dangerouslySetInnerHTML={{ __html: playlist.description }} />}
<p className="text-sm text-gray-500">{playlist.followers?.total.toLocaleString()} followers {playlist.tracks.total} songs</p>
<div className="pt-2">
<button
onClick={handleDownloadPlaylist}
disabled={isExplicitFilterEnabled && hasExplicitTrack}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-500"
title={isExplicitFilterEnabled && hasExplicitTrack ? "Playlist contains explicit tracks and can't be downloaded" : 'Download all tracks in playlist'}
>
Download Playlist
</button>
</div>
</div>
</div>
<div>
<div className="flex flex-col">
{playlist.tracks.items.map(({ track }, index) => {
if (!track) return null; // Handle cases where a track might be unavailable
if (isExplicitFilterEnabled && track.explicit) {
return (
<div key={index} className="flex items-center p-3 text-sm bg-gray-100 dark:bg-gray-800 rounded-lg opacity-60">
<span className="w-8 text-gray-500">{index + 1}</span>
<span className="font-medium text-gray-500">Explicit track filtered</span>
</div>
);
}
return (
<div key={track.id} className="flex items-center gap-4 p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
<span className="w-6 text-right text-gray-500">{index + 1}</span>
<img src={track.album.images[track.album.images.length - 1]?.url || '/placeholder.jpg'} alt="" className="w-10 h-10 rounded" />
<div className="flex-grow">
<p className="font-semibold">{track.name}</p>
<p className="text-xs text-gray-500">
{track.artists.map(a => <Link key={a.id} to="/artist/$artistId" params={{artistId: a.id}} className="hover:underline">{a.name}</Link>).reduce((prev, curr) => <>{prev}, {curr}</>)}
{' • '}
<Link to="/album/$albumId" params={{albumId: track.album.id}} className="hover:underline">{track.album.name}</Link>
</p>
</div>
<span className="text-sm text-gray-500 hidden md:block">
{Math.floor(track.duration_ms / 60000)}:{((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, '0')}
</span>
<button onClick={() => handleDownloadTrack(track)} className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-full">
<img src="/download.svg" alt="Download" className="w-5 h-5" />
</button>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { Outlet } from '@tanstack/react-router';
import { QueueProvider } from '../contexts/QueueProvider';
import { useQueue } from '../contexts/queue-context';
import { Queue } from '../components/Queue';
import { Link } from '@tanstack/react-router';
import { SettingsProvider } from '../contexts/SettingsProvider';
import { Toaster } from 'sonner';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Create a client
const queryClient = new QueryClient();
function AppLayout() {
const { toggleVisibility } = useQueue();
return (
<>
<div className="min-h-screen bg-background text-foreground">
<header className="sticky top-0 z-40 w-full border-b bg-background/95 backdrop-blur-sm">
<div className="container mx-auto h-14 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2">
<img src="/music.svg" alt="Logo" className="w-6 h-6" />
<h1 className="text-xl font-bold">Spotizerr</h1>
</Link>
<div className="flex items-center gap-2">
<Link to="/watchlist" className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700">
<img src="/binoculars.svg" alt="Watchlist" className="w-6 h-6" />
</Link>
<Link to="/history" className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700">
<img src="/history.svg" alt="History" className="w-6 h-6" />
</Link>
<Link to="/config" className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700">
<img src="/settings.svg" alt="Settings" className="w-6 h-6" />
</Link>
<button onClick={toggleVisibility} className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700">
<img src="/queue.svg" alt="Queue" className="w-6 h-6" />
</button>
</div>
</div>
</header>
<main className="container mx-auto px-4 py-8">
<Outlet />
</main>
</div>
<Queue />
<Toaster richColors />
</>
);
}
export function Root() {
return (
<QueryClientProvider client={queryClient}>
<SettingsProvider>
<QueueProvider>
<AppLayout />
</QueueProvider>
</SettingsProvider>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,92 @@
import { Link, useParams } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
import apiClient from '../lib/api-client';
import { useQueue } from '../contexts/queue-context';
import type { TrackType, ImageType } from '../types/spotify';
interface SimplifiedAlbum {
id: string;
name: string;
images: ImageType[];
album_type: string;
}
interface TrackDetails extends TrackType {
album: SimplifiedAlbum;
}
export const Track = () => {
const { trackId } = useParams({ from: '/track/$trackId' });
const [track, setTrack] = useState<TrackDetails | null>(null);
const [error, setError] = useState<string | null>(null);
const { addItem, toggleVisibility } = useQueue();
useEffect(() => {
const fetchTrack = async () => {
if (!trackId) return;
try {
const response = await apiClient.get<TrackDetails>(`/track/info?id=${trackId}`);
setTrack(response.data);
} catch (err) {
setError('Failed to load track details.');
console.error(err);
}
};
fetchTrack();
}, [trackId]);
const handleDownload = () => {
if (!track) return;
addItem({ id: track.id, type: 'track', name: track.name });
toggleVisibility();
};
if (error) return <div className="text-red-500">{error}</div>;
if (!track) return <div>Loading...</div>;
const minutes = Math.floor(track.duration_ms / 60000);
const seconds = ((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, '0');
return (
<div className="flex flex-col md:flex-row items-center gap-8 p-4">
<img
src={track.album.images[0]?.url || '/placeholder.jpg'}
alt={track.album.name}
className="w-64 h-64 object-cover rounded-lg shadow-2xl"
/>
<div className="flex-grow space-y-3 text-center md:text-left">
<h1 className="text-4xl font-extrabold">{track.name}</h1>
<p className="text-xl text-gray-500">
By{' '}
{track.artists.map((artist, index) => (
<span key={artist.id}>
<Link to="/artist/$artistId" params={{ artistId: artist.id }} className="hover:underline">
{artist.name}
</Link>
{index < track.artists.length - 1 && ', '}
</span>
))}
</p>
<p className="text-lg text-gray-400">
From the {track.album.album_type}{' '}
<Link to="/album/$albumId" params={{ albumId: track.album.id }} className="hover:underline font-semibold">
{track.album.name}
</Link>
</p>
<div className="flex items-center justify-center md:justify-start gap-4 text-sm text-gray-500">
<span>{minutes}:{seconds}</span>
{track.explicit && <span className="px-2 py-0.5 bg-gray-200 dark:bg-gray-700 text-xs font-semibold rounded-full">EXPLICIT</span>}
</div>
<div className="pt-4">
<button
onClick={handleDownload}
className="px-6 py-3 bg-green-600 text-white rounded-full hover:bg-green-700 transition-colors flex items-center justify-center gap-2 text-lg"
>
<img src="/download.svg" alt="" className="w-6 h-6" />
Download
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,147 @@
import { useState, useEffect, useCallback } from 'react';
import apiClient from '../lib/api-client';
import { toast } from 'sonner';
import { useSettings } from '../contexts/settings-context';
import { Link } from '@tanstack/react-router';
// --- Type Definitions ---
interface Image {
url: string;
}
interface WatchedArtist {
itemType: 'artist';
spotify_id: string;
name: string;
images?: Image[];
total_albums?: number;
}
interface WatchedPlaylist {
itemType: 'playlist';
spotify_id: string;
name: string;
images?: Image[];
owner?: { display_name?: string };
total_tracks?: number;
}
type WatchedItem = WatchedArtist | WatchedPlaylist;
export const Watchlist = () => {
const { settings, isLoading: settingsLoading } = useSettings();
const [items, setItems] = useState<WatchedItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const fetchWatchlist = useCallback(async () => {
setIsLoading(true);
try {
const [artistsRes, playlistsRes] = await Promise.all([
apiClient.get<Omit<WatchedArtist, 'itemType'>[]>('/artist/watch/list'),
apiClient.get<Omit<WatchedPlaylist, 'itemType'>[]>('/playlist/watch/list'),
]);
const artists: WatchedItem[] = artistsRes.data.map(a => ({ ...a, itemType: 'artist' }));
const playlists: WatchedItem[] = playlistsRes.data.map(p => ({ ...p, itemType: 'playlist' }));
setItems([...artists, ...playlists]);
} catch {
toast.error('Failed to load watchlist.');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
if (!settingsLoading && settings?.watch?.enabled) {
fetchWatchlist();
} else if (!settingsLoading) {
setIsLoading(false);
}
}, [settings, settingsLoading, fetchWatchlist]);
const handleUnwatch = async (item: WatchedItem) => {
toast.promise(
apiClient.delete(`/${item.itemType}/watch/${item.spotify_id}`), {
loading: `Unwatching ${item.name}...`,
success: () => {
setItems(prev => prev.filter(i => i.spotify_id !== item.spotify_id));
return `${item.name} has been unwatched.`;
},
error: `Failed to unwatch ${item.name}.`
});
};
const handleCheck = async (item: WatchedItem) => {
toast.promise(
apiClient.post(`/${item.itemType}/watch/trigger_check/${item.spotify_id}`), {
loading: `Checking ${item.name} for updates...`,
success: (res: { data: { message?: string }}) => res.data.message || `Check triggered for ${item.name}.`,
error: `Failed to trigger check for ${item.name}.`,
});
};
const handleCheckAll = () => {
toast.promise(Promise.all([
apiClient.post('/artist/watch/trigger_check'),
apiClient.post('/playlist/watch/trigger_check'),
]), {
loading: 'Triggering checks for all watched items...',
success: 'Successfully triggered checks for all items.',
error: 'Failed to trigger one or more checks.'
});
};
if (isLoading || settingsLoading) {
return <div className="text-center">Loading Watchlist...</div>;
}
if (!settings?.watch?.enabled) {
return (
<div className="text-center p-8">
<h2 className="text-2xl font-bold mb-2">Watchlist Disabled</h2>
<p>The watchlist feature is currently disabled. You can enable it in the settings.</p>
<Link to="/config" className="text-blue-500 hover:underline mt-4 inline-block">Go to Settings</Link>
</div>
);
}
if (items.length === 0) {
return (
<div className="text-center p-8">
<h2 className="text-2xl font-bold mb-2">Watchlist is Empty</h2>
<p>Start watching artists or playlists to see them here.</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Watched Artists & Playlists</h1>
<button onClick={handleCheckAll} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Check All
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{items.map(item => (
<div key={item.spotify_id} className="bg-card p-4 rounded-lg shadow space-y-2 flex flex-col">
<a href={`/${item.itemType}/${item.spotify_id}`} className="flex-grow">
<img
src={item.images?.[0]?.url || '/images/placeholder.jpg'}
alt={item.name}
className="w-full h-auto object-cover rounded-md aspect-square"
/>
<h3 className="font-bold pt-2 truncate">{item.name}</h3>
<p className="text-sm text-muted-foreground capitalize">{item.itemType}</p>
</a>
<div className="flex gap-2 pt-2">
<button onClick={() => handleUnwatch(item)} className="w-full px-3 py-1.5 text-sm bg-red-600 text-white rounded-md hover:bg-red-700">Unwatch</button>
<button onClick={() => handleCheck(item)} className="w-full px-3 py-1.5 text-sm bg-gray-600 text-white rounded-md hover:bg-gray-700">Check</button>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
// This new type reflects the flat structure of the /api/config response
export interface AppSettings {
service: 'spotify' | 'deezer';
spotify: string;
spotifyQuality: 'NORMAL' | 'HIGH' | 'VERY_HIGH';
deezer: string;
deezerQuality: 'MP3_128' | 'MP3_320' | 'FLAC';
maxConcurrentDownloads: number;
realTime: boolean;
fallback: boolean;
convertTo: 'MP3' | 'AAC' | 'OGG' | 'OPUS' | 'FLAC' | 'WAV' | 'ALAC' | '';
bitrate: string;
maxRetries: number;
retryDelaySeconds: number;
retryDelayIncrease: number;
customDirFormat: string;
customTrackFormat: string;
tracknumPadding: boolean;
saveCover: boolean;
explicitFilter: boolean;
// Properties from the old 'downloads' object
threads: number;
path: string;
skipExisting: boolean;
m3u: boolean;
hlsThreads: number;
// Properties from the old 'formatting' object
track: string;
album: string;
playlist: string;
compilation: string;
watch: {
enabled: boolean;
// Add other watch properties from the old type if they still exist in the API response
};
// Add other root-level properties from the API if they exist
}

View File

@@ -0,0 +1,33 @@
export interface ImageType {
url: string;
height?: number;
width?: number;
}
export interface ArtistType {
id: string;
name: string;
}
export interface TrackType {
id: string;
name: string;
artists: ArtistType[];
duration_ms: number;
explicit: boolean;
}
export interface AlbumType {
id: string;
name: string;
artists: ArtistType[];
images: ImageType[];
release_date: string;
total_tracks: number;
label: string;
copyrights: Array<{ text: string; type: string }>;
explicit: boolean;
tracks: {
items: TrackType[];
};
}

1
spotizerr-ui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path Aliases */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitAny": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitAny": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,27 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { fileURLToPath } from 'url'
import { dirname, resolve } from 'path'
import tailwindcss from '@tailwindcss/vite'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:7171',
changeOrigin: true,
},
},
},
})