add new features and format
This commit is contained in:
9
spotizerr-ui/.prettierignore
Normal file
9
spotizerr-ui/.prettierignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
|
coverage
|
||||||
|
.pnpm-store
|
||||||
|
.vite
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
7
spotizerr-ui/.prettierrc.json
Normal file
7
spotizerr-ui/.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 120,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
@@ -24,31 +24,31 @@ export default tseslint.config({
|
|||||||
languageOptions: {
|
languageOptions: {
|
||||||
// other options...
|
// other options...
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
|
||||||
tsconfigRootDir: import.meta.dirname,
|
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:
|
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
|
```js
|
||||||
// eslint.config.js
|
// eslint.config.js
|
||||||
import reactX from 'eslint-plugin-react-x'
|
import reactX from "eslint-plugin-react-x";
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
import reactDom from "eslint-plugin-react-dom";
|
||||||
|
|
||||||
export default tseslint.config({
|
export default tseslint.config({
|
||||||
plugins: {
|
plugins: {
|
||||||
// Add the react-x and react-dom plugins
|
// Add the react-x and react-dom plugins
|
||||||
'react-x': reactX,
|
"react-x": reactX,
|
||||||
'react-dom': reactDom,
|
"react-dom": reactDom,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
// other rules...
|
// other rules...
|
||||||
// Enable its recommended typescript rules
|
// Enable its recommended typescript rules
|
||||||
...reactX.configs['recommended-typescript'].rules,
|
...reactX.configs["recommended-typescript"].rules,
|
||||||
...reactDom.configs.recommended.rules,
|
...reactDom.configs.recommended.rules,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,28 +1,33 @@
|
|||||||
import js from '@eslint/js'
|
import js from "@eslint/js";
|
||||||
import globals from 'globals'
|
import globals from "globals";
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from "typescript-eslint";
|
||||||
|
import prettier from "eslint-plugin-prettier";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
export default tseslint.config(
|
// Read Prettier configuration from .prettierrc.json
|
||||||
{ ignores: ['dist'] },
|
const prettierOptions = JSON.parse(readFileSync("./.prettierrc.json", "utf8"));
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{ ignores: ["dist"] },
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
{
|
{
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
files: ["**/*.{ts,tsx}"],
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
'react-hooks': reactHooks,
|
"react-hooks": reactHooks,
|
||||||
'react-refresh': reactRefresh,
|
"react-refresh": reactRefresh,
|
||||||
|
prettier: prettier,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
'react-refresh/only-export-components': [
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
'warn',
|
"prettier/prettier": ["error", prettierOptions],
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
];
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint . --fix",
|
||||||
|
"format": "prettier --write .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -16,13 +17,15 @@
|
|||||||
"@tanstack/react-router": "^1.120.18",
|
"@tanstack/react-router": "^1.120.18",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/router-devtools": "^1.120.18",
|
"@tanstack/router-devtools": "^1.120.18",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.57.0",
|
"react-hook-form": "^7.57.0",
|
||||||
"sonner": "^2.0.5",
|
"sonner": "^2.0.5",
|
||||||
"tailwindcss": "^4.1.8",
|
"tailwindcss": "^4.1.8",
|
||||||
"use-debounce": "^10.0.5"
|
"use-debounce": "^10.0.5",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
@@ -31,9 +34,12 @@
|
|||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
|
"eslint-config-prettier": "^10.1.5",
|
||||||
|
"eslint-plugin-prettier": "^5.4.1",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.19",
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
|
"prettier": "^3.5.3",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.30.1",
|
"typescript-eslint": "^8.30.1",
|
||||||
"vite": "^6.3.5"
|
"vite": "^6.3.5"
|
||||||
|
|||||||
2573
spotizerr-ui/pnpm-lock.yaml
generated
2573
spotizerr-ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import tailwindcss from '@tailwindcss/postcss';
|
import tailwindcss from "@tailwindcss/postcss";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
plugins: [tailwindcss],
|
plugins: [tailwindcss],
|
||||||
|
|||||||
@@ -1,73 +1,162 @@
|
|||||||
import { useQueue, type QueueItem } from '../contexts/queue-context';
|
import { useQueue, type QueueItem } from "../contexts/queue-context";
|
||||||
|
|
||||||
export function Queue() {
|
export function Queue() {
|
||||||
const { items, isVisible, removeItem, clearQueue, toggleVisibility } = useQueue();
|
const { items, isVisible, removeItem, retryItem, clearQueue, toggleVisibility, clearCompleted } = useQueue();
|
||||||
|
|
||||||
if (!isVisible) return null;
|
if (!isVisible) return null;
|
||||||
|
|
||||||
const renderStatus = (item: QueueItem) => {
|
const handleClearQueue = () => {
|
||||||
switch (item.status) {
|
if (confirm("Are you sure you want to cancel all downloads and clear the queue?")) {
|
||||||
case 'downloading':
|
clearQueue();
|
||||||
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) => {
|
const renderProgress = (item: QueueItem) => {
|
||||||
if (item.status !== 'downloading' || !item.progress) return null;
|
if (item.status === "downloading" || item.status === "processing") {
|
||||||
return (
|
const isMultiTrack = item.totalTracks && item.totalTracks > 1;
|
||||||
<div className="text-xs text-gray-400 flex justify-between w-full">
|
const overallProgress =
|
||||||
<span>{item.progress.toFixed(0)}%</span>
|
isMultiTrack && item.totalTracks
|
||||||
<span>{item.speed}</span>
|
? ((item.currentTrackNumber || 0) / item.totalTracks) * 100
|
||||||
<span>{item.size}</span>
|
: item.progress || 0;
|
||||||
<span>{item.eta}</span>
|
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-gray-700 rounded-full h-2.5 mt-1">
|
||||||
|
<div className="bg-green-600 h-2.5 rounded-full" style={{ width: `${overallProgress}%` }}></div>
|
||||||
|
{isMultiTrack && (
|
||||||
|
<div className="w-full bg-gray-600 rounded-full h-1.5 mt-1">
|
||||||
|
<div className="bg-blue-500 h-1.5 rounded-full" style={{ width: `${item.progress || 0}%` }}></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStatusDetails = (item: QueueItem) => {
|
||||||
|
const statusClass = {
|
||||||
|
initializing: "text-gray-400",
|
||||||
|
pending: "text-gray-400",
|
||||||
|
downloading: "text-blue-400",
|
||||||
|
processing: "text-purple-400",
|
||||||
|
completed: "text-green-500 font-semibold",
|
||||||
|
error: "text-red-500 font-semibold",
|
||||||
|
skipped: "text-yellow-500",
|
||||||
|
cancelled: "text-gray-500",
|
||||||
|
queued: "text-gray-400",
|
||||||
|
}[item.status];
|
||||||
|
|
||||||
|
const isMultiTrack = item.totalTracks && item.totalTracks > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-gray-400 flex justify-between w-full mt-1">
|
||||||
|
<span className={statusClass}>{item.status.toUpperCase()}</span>
|
||||||
|
{item.status === "downloading" && (
|
||||||
|
<>
|
||||||
|
<span>{item.progress?.toFixed(0)}%</span>
|
||||||
|
<span>{item.speed}</span>
|
||||||
|
<span>{item.eta}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isMultiTrack && (
|
||||||
|
<span>
|
||||||
|
{item.currentTrackNumber}/{item.totalTracks}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSummary = (item: QueueItem) => {
|
||||||
|
if (item.status !== "completed" || !item.summary) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-gray-300 mt-1">
|
||||||
|
<span>
|
||||||
|
Success: <span className="text-green-500">{item.summary.successful}</span>
|
||||||
|
</span>{" "}
|
||||||
|
|{" "}
|
||||||
|
<span>
|
||||||
|
Skipped: <span className="text-yellow-500">{item.summary.skipped}</span>
|
||||||
|
</span>{" "}
|
||||||
|
|{" "}
|
||||||
|
<span>
|
||||||
|
Failed: <span className="text-red-500">{item.summary.failed}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<aside className="fixed top-0 right-0 h-full w-96 bg-gray-900 border-l border-gray-700 z-50 flex flex-col shadow-2xl">
|
||||||
<div className="flex justify-between items-center p-3 border-b border-gray-200 dark:border-gray-700">
|
<header className="flex justify-between items-center p-4 border-b border-gray-700 flex-shrink-0">
|
||||||
<h3 className="font-semibold">Download Queue</h3>
|
<h3 className="font-semibold text-lg">Download Queue ({items.length})</h3>
|
||||||
<div className="flex items-center gap-2">
|
<button onClick={() => toggleVisibility()} className="text-gray-400 hover:text-white" title="Close">
|
||||||
<button onClick={clearQueue} className="text-sm text-gray-500 hover:text-red-500" title="Clear All">Clear</button>
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<button onClick={() => toggleVisibility()} className="text-gray-500 hover:text-white" title="Close">
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
<img src="/cross.svg" alt="Close" className="w-4 h-4" />
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</header>
|
||||||
</div>
|
|
||||||
<div className="p-3 max-h-96 overflow-y-auto space-y-3">
|
<main className="p-3 flex-grow overflow-y-auto space-y-4">
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-center py-4">Queue is empty.</p>
|
<div className="text-gray-400 text-center py-10">
|
||||||
|
<p>The queue is empty.</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
items.map((item) => (
|
items.map((item) => (
|
||||||
<div key={item.id} className="text-sm">
|
<div key={item.id} className="text-sm bg-gray-800 p-3 rounded-md border border-gray-700">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-start">
|
||||||
<span className="font-medium truncate pr-2">{item.name}</span>
|
<span className="font-medium truncate pr-2 flex-grow">{item.name}</span>
|
||||||
<button onClick={() => removeItem(item.id)} className="text-gray-400 hover:text-red-500 flex-shrink-0">
|
<button
|
||||||
<img src="/cross.svg" alt="Remove" className="w-4 h-4" />
|
onClick={() => removeItem(item.id)}
|
||||||
</button>
|
className="text-gray-500 hover:text-red-500 flex-shrink-0"
|
||||||
|
title="Cancel Download"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 space-y-1">
|
<div className="mt-2 space-y-1">
|
||||||
{renderStatus(item)}
|
{renderProgress(item)}
|
||||||
{renderItemDetails(item)}
|
{renderStatusDetails(item)}
|
||||||
|
{renderSummary(item)}
|
||||||
|
{item.status === "error" && (
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<p className="text-red-500 text-xs truncate" title={item.error}>
|
||||||
|
{item.error || "An unknown error occurred."}
|
||||||
|
</p>
|
||||||
|
{item.canRetry && (
|
||||||
|
<button
|
||||||
|
onClick={() => retryItem(item.id)}
|
||||||
|
className="text-xs bg-blue-600 hover:bg-blue-700 text-white py-1 px-2 rounded"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</main>
|
||||||
</div>
|
|
||||||
|
<footer className="p-3 border-t border-gray-700 flex-shrink-0 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleClearQueue}
|
||||||
|
className="text-sm bg-red-800 hover:bg-red-700 text-white py-2 px-4 rounded w-full"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={clearCompleted}
|
||||||
|
className="text-sm bg-gray-700 hover:bg-gray-600 text-white py-2 px-4 rounded w-full"
|
||||||
|
>
|
||||||
|
Clear Completed
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { useForm, type SubmitHandler } from 'react-hook-form';
|
import { useForm, type SubmitHandler } from "react-hook-form";
|
||||||
import apiClient from '../../lib/api-client';
|
import apiClient from "../../lib/api-client";
|
||||||
import { toast } from 'sonner';
|
import { toast } from "sonner";
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
// --- Type Definitions ---
|
// --- Type Definitions ---
|
||||||
type Service = 'spotify' | 'deezer';
|
type Service = "spotify" | "deezer";
|
||||||
|
|
||||||
interface Credential {
|
interface Credential {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -16,25 +16,26 @@ interface AccountFormData {
|
|||||||
accountName: string;
|
accountName: string;
|
||||||
accountRegion?: string;
|
accountRegion?: string;
|
||||||
authBlob?: string; // Spotify specific
|
authBlob?: string; // Spotify specific
|
||||||
arl?: string; // Deezer specific
|
arl?: string; // Deezer specific
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- API Functions ---
|
// --- API Functions ---
|
||||||
const fetchCredentials = async (service: Service): Promise<Credential[]> => {
|
const fetchCredentials = async (service: Service): Promise<Credential[]> => {
|
||||||
const { data } = await apiClient.get<string[]>(`/credentials/${service}`);
|
const { data } = await apiClient.get<string[]>(`/credentials/${service}`);
|
||||||
return data.map(name => ({ name }));
|
return data.map((name) => ({ name }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const addCredential = async ({ service, data }: { service: Service, data: AccountFormData }) => {
|
const addCredential = async ({ service, data }: { service: Service; data: AccountFormData }) => {
|
||||||
const payload = service === 'spotify'
|
const payload =
|
||||||
? { blob_content: data.authBlob, region: data.accountRegion }
|
service === "spotify"
|
||||||
: { arl: data.arl, region: data.accountRegion };
|
? { blob_content: data.authBlob, region: data.accountRegion }
|
||||||
|
: { arl: data.arl, region: data.accountRegion };
|
||||||
|
|
||||||
const { data: response } = await apiClient.post(`/credentials/${service}/${data.accountName}`, payload);
|
const { data: response } = await apiClient.post(`/credentials/${service}/${data.accountName}`, payload);
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteCredential = async ({ service, name }: { service: Service, name:string }) => {
|
const deleteCredential = async ({ service, name }: { service: Service; name: string }) => {
|
||||||
const { data: response } = await apiClient.delete(`/credentials/${service}/${name}`);
|
const { data: response } = await apiClient.delete(`/credentials/${service}/${name}`);
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
@@ -42,21 +43,26 @@ const deleteCredential = async ({ service, name }: { service: Service, name:stri
|
|||||||
// --- Component ---
|
// --- Component ---
|
||||||
export function AccountsTab() {
|
export function AccountsTab() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [activeService, setActiveService] = useState<Service>('spotify');
|
const [activeService, setActiveService] = useState<Service>("spotify");
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
|
||||||
const { data: credentials, isLoading } = useQuery({
|
const { data: credentials, isLoading } = useQuery({
|
||||||
queryKey: ['credentials', activeService],
|
queryKey: ["credentials", activeService],
|
||||||
queryFn: () => fetchCredentials(activeService),
|
queryFn: () => fetchCredentials(activeService),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { register, handleSubmit, reset, formState: { errors } } = useForm<AccountFormData>();
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<AccountFormData>();
|
||||||
|
|
||||||
const addMutation = useMutation({
|
const addMutation = useMutation({
|
||||||
mutationFn: addCredential,
|
mutationFn: addCredential,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Account added successfully!');
|
toast.success("Account added successfully!");
|
||||||
queryClient.invalidateQueries({ queryKey: ['credentials', activeService] });
|
queryClient.invalidateQueries({ queryKey: ["credentials", activeService] });
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
reset();
|
reset();
|
||||||
},
|
},
|
||||||
@@ -69,7 +75,7 @@ export function AccountsTab() {
|
|||||||
mutationFn: deleteCredential,
|
mutationFn: deleteCredential,
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
toast.success(`Account "${variables.name}" deleted.`);
|
toast.success(`Account "${variables.name}" deleted.`);
|
||||||
queryClient.invalidateQueries({ queryKey: ['credentials', activeService] });
|
queryClient.invalidateQueries({ queryKey: ["credentials", activeService] });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(`Failed to delete account: ${error.message}`);
|
toast.error(`Failed to delete account: ${error.message}`);
|
||||||
@@ -82,35 +88,61 @@ export function AccountsTab() {
|
|||||||
|
|
||||||
const renderAddForm = () => (
|
const renderAddForm = () => (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="p-4 border rounded-lg mt-4 space-y-4">
|
<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>
|
<h4 className="font-semibold">Add New {activeService === "spotify" ? "Spotify" : "Deezer"} Account</h4>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="accountName">Account Name</label>
|
<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" />
|
<input
|
||||||
{errors.accountName && <p className="text-red-500 text-sm">{errors.accountName.message}</p>}
|
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>
|
</div>
|
||||||
{activeService === 'spotify' && (
|
{activeService === "spotify" && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="authBlob">Auth Blob (JSON)</label>
|
<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>
|
<textarea
|
||||||
{errors.authBlob && <p className="text-red-500 text-sm">{errors.authBlob.message}</p>}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeService === 'deezer' && (
|
{activeService === "deezer" && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="arl">ARL Token</label>
|
<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" />
|
<input
|
||||||
{errors.arl && <p className="text-red-500 text-sm">{errors.arl.message}</p>}
|
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>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="accountRegion">Region (Optional)</label>
|
<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" />
|
<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>
|
||||||
<div className="flex gap-2">
|
<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">
|
<button
|
||||||
{addMutation.isPending ? 'Saving...' : 'Save Account'}
|
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>
|
||||||
<button type="button" onClick={() => setIsAdding(false)} className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsAdding(false)}
|
||||||
|
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700"
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,29 +152,46 @@ export function AccountsTab() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex gap-2 border-b">
|
<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
|
||||||
<button onClick={() => setActiveService('deezer')} className={`p-2 ${activeService === 'deezer' ? 'border-b-2 border-blue-500 font-semibold' : ''}`}>Deezer</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>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p>Loading accounts...</p>
|
<p>Loading accounts...</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{credentials?.map(cred => (
|
{credentials?.map((cred) => (
|
||||||
<div key={cred.name} className="flex justify-between items-center p-3 bg-gray-800 rounded-md">
|
<div key={cred.name} className="flex justify-between items-center p-3 bg-gray-800 rounded-md">
|
||||||
<span>{cred.name}</span>
|
<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">
|
<button
|
||||||
Delete
|
onClick={() => deleteMutation.mutate({ service: activeService, name: cred.name })}
|
||||||
</button>
|
disabled={deleteMutation.isPending && deleteMutation.variables?.name === cred.name}
|
||||||
</div>
|
className="text-red-500 hover:text-red-400"
|
||||||
))}
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isAdding && (
|
{!isAdding && (
|
||||||
<button onClick={() => setIsAdding(true)} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
|
<button
|
||||||
Add Account
|
onClick={() => setIsAdding(true)}
|
||||||
</button>
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Add Account
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
{isAdding && renderAddForm()}
|
{isAdding && renderAddForm()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useForm, type SubmitHandler } from 'react-hook-form';
|
import { useForm, type SubmitHandler } from "react-hook-form";
|
||||||
import apiClient from '../../lib/api-client';
|
import apiClient from "../../lib/api-client";
|
||||||
import { toast } from 'sonner';
|
import { toast } from "sonner";
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
// --- Type Definitions ---
|
// --- Type Definitions ---
|
||||||
interface DownloadSettings {
|
interface DownloadSettings {
|
||||||
maxConcurrentDownloads: number;
|
maxConcurrentDownloads: number;
|
||||||
realTime: boolean;
|
realTime: boolean;
|
||||||
fallback: boolean;
|
fallback: boolean;
|
||||||
convertTo: 'MP3' | 'AAC' | 'OGG' | 'OPUS' | 'FLAC' | 'WAV' | 'ALAC' | '';
|
convertTo: "MP3" | "AAC" | "OGG" | "OPUS" | "FLAC" | "WAV" | "ALAC" | "";
|
||||||
bitrate: string;
|
bitrate: string;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
retryDelaySeconds: number;
|
retryDelaySeconds: number;
|
||||||
@@ -26,18 +26,18 @@ interface DownloadsTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CONVERSION_FORMATS: Record<string, string[]> = {
|
const CONVERSION_FORMATS: Record<string, string[]> = {
|
||||||
MP3: ['32k', '64k', '96k', '128k', '192k', '256k', '320k'],
|
MP3: ["32k", "64k", "96k", "128k", "192k", "256k", "320k"],
|
||||||
AAC: ['32k', '64k', '96k', '128k', '192k', '256k'],
|
AAC: ["32k", "64k", "96k", "128k", "192k", "256k"],
|
||||||
OGG: ['64k', '96k', '128k', '192k', '256k', '320k'],
|
OGG: ["64k", "96k", "128k", "192k", "256k", "320k"],
|
||||||
OPUS: ['32k', '64k', '96k', '128k', '192k', '256k'],
|
OPUS: ["32k", "64k", "96k", "128k", "192k", "256k"],
|
||||||
FLAC: [],
|
FLAC: [],
|
||||||
WAV: [],
|
WAV: [],
|
||||||
ALAC: []
|
ALAC: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- API Functions ---
|
// --- API Functions ---
|
||||||
const saveDownloadConfig = async (data: Partial<DownloadSettings>) => {
|
const saveDownloadConfig = async (data: Partial<DownloadSettings>) => {
|
||||||
const { data: response } = await apiClient.post('/config', data);
|
const { data: response } = await apiClient.post("/config", data);
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -48,8 +48,8 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: saveDownloadConfig,
|
mutationFn: saveDownloadConfig,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Download settings saved successfully!');
|
toast.success("Download settings saved successfully!");
|
||||||
queryClient.invalidateQueries({ queryKey: ['config'] });
|
queryClient.invalidateQueries({ queryKey: ["config"] });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(`Failed to save settings: ${error.message}`);
|
toast.error(`Failed to save settings: ${error.message}`);
|
||||||
@@ -60,15 +60,15 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
|||||||
values: config,
|
values: config,
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedFormat = watch('convertTo');
|
const selectedFormat = watch("convertTo");
|
||||||
|
|
||||||
const onSubmit: SubmitHandler<DownloadSettings> = (data) => {
|
const onSubmit: SubmitHandler<DownloadSettings> = (data) => {
|
||||||
mutation.mutate({
|
mutation.mutate({
|
||||||
...data,
|
...data,
|
||||||
maxConcurrentDownloads: Number(data.maxConcurrentDownloads),
|
maxConcurrentDownloads: Number(data.maxConcurrentDownloads),
|
||||||
maxRetries: Number(data.maxRetries),
|
maxRetries: Number(data.maxRetries),
|
||||||
retryDelaySeconds: Number(data.retryDelaySeconds),
|
retryDelaySeconds: Number(data.retryDelaySeconds),
|
||||||
retryDelayIncrease: Number(data.retryDelayIncrease),
|
retryDelayIncrease: Number(data.retryDelayIncrease),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,16 +82,22 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-xl font-semibold">Download Behavior</h3>
|
<h3 className="text-xl font-semibold">Download Behavior</h3>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="maxConcurrentDownloads">Max Concurrent Downloads</label>
|
<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" />
|
<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>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label htmlFor="realTimeToggle">Real-time downloading</label>
|
<label htmlFor="realTimeToggle">Real-time downloading</label>
|
||||||
<input id="realTimeToggle" type="checkbox" {...register('realTime')} className="h-6 w-6 rounded" />
|
<input id="realTimeToggle" type="checkbox" {...register("realTime")} className="h-6 w-6 rounded" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label htmlFor="fallbackToggle">Download Fallback</label>
|
<label htmlFor="fallbackToggle">Download Fallback</label>
|
||||||
<input id="fallbackToggle" type="checkbox" {...register('fallback')} className="h-6 w-6 rounded" />
|
<input id="fallbackToggle" type="checkbox" {...register("fallback")} className="h-6 w-6 rounded" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -99,22 +105,35 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-xl font-semibold">Conversion</h3>
|
<h3 className="text-xl font-semibold">Conversion</h3>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="convertToSelect">Convert To Format</label>
|
<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">
|
<select
|
||||||
<option value="">No Conversion</option>
|
id="convertToSelect"
|
||||||
{Object.keys(CONVERSION_FORMATS).map(format => (
|
{...register("convertTo")}
|
||||||
<option key={format} value={format}>{format}</option>
|
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"
|
||||||
))}
|
>
|
||||||
</select>
|
<option value="">No Conversion</option>
|
||||||
|
{Object.keys(CONVERSION_FORMATS).map((format) => (
|
||||||
|
<option key={format} value={format}>
|
||||||
|
{format}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="bitrateSelect">Bitrate</label>
|
<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}>
|
<select
|
||||||
<option value="">Auto</option>
|
id="bitrateSelect"
|
||||||
{(CONVERSION_FORMATS[selectedFormat] || []).map(rate => (
|
{...register("bitrate")}
|
||||||
<option key={rate} value={rate}>{rate}</option>
|
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}
|
||||||
</select>
|
>
|
||||||
|
<option value="">Auto</option>
|
||||||
|
{(CONVERSION_FORMATS[selectedFormat] || []).map((rate) => (
|
||||||
|
<option key={rate} value={rate}>
|
||||||
|
{rate}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,21 +141,43 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-xl font-semibold">Retries</h3>
|
<h3 className="text-xl font-semibold">Retries</h3>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="maxRetries">Max Retry Attempts</label>
|
<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" />
|
<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>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="retryDelaySeconds">Initial Retry Delay (s)</label>
|
<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" />
|
<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>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="retryDelayIncrease">Retry Delay Increase (s)</label>
|
<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" />
|
<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>
|
||||||
</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">
|
<button
|
||||||
{mutation.isPending ? 'Saving...' : 'Save Download Settings'}
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from "react";
|
||||||
import { useForm, type SubmitHandler } from 'react-hook-form';
|
import { useForm, type SubmitHandler } from "react-hook-form";
|
||||||
import apiClient from '../../lib/api-client';
|
import apiClient from "../../lib/api-client";
|
||||||
import { toast } from 'sonner';
|
import { toast } from "sonner";
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
// --- Type Definitions ---
|
// --- Type Definitions ---
|
||||||
interface FormattingSettings {
|
interface FormattingSettings {
|
||||||
@@ -23,47 +23,46 @@ interface FormattingTabProps {
|
|||||||
|
|
||||||
// --- API Functions ---
|
// --- API Functions ---
|
||||||
const saveFormattingConfig = async (data: Partial<FormattingSettings>) => {
|
const saveFormattingConfig = async (data: Partial<FormattingSettings>) => {
|
||||||
const { data: response } = await apiClient.post('/config', data);
|
const { data: response } = await apiClient.post("/config", data);
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Placeholders ---
|
// --- Placeholders ---
|
||||||
const placeholders = {
|
const placeholders = {
|
||||||
"Common": {
|
Common: {
|
||||||
"%music%": "Track title",
|
"%music%": "Track title",
|
||||||
"%artist%": "Track artist",
|
"%artist%": "Track artist",
|
||||||
"%album%": "Album name",
|
"%album%": "Album name",
|
||||||
"%ar_album%": "Album artist",
|
"%ar_album%": "Album artist",
|
||||||
"%tracknum%": "Track number",
|
"%tracknum%": "Track number",
|
||||||
"%year%": "Year of release",
|
"%year%": "Year of release",
|
||||||
},
|
},
|
||||||
"Additional": {
|
Additional: {
|
||||||
"%discnum%": "Disc number",
|
"%discnum%": "Disc number",
|
||||||
"%date%": "Release date",
|
"%date%": "Release date",
|
||||||
"%genre%": "Music genre",
|
"%genre%": "Music genre",
|
||||||
"%isrc%": "ISRC",
|
"%isrc%": "ISRC",
|
||||||
"%explicit%": "Explicit flag",
|
"%explicit%": "Explicit flag",
|
||||||
"%duration%": "Track duration (s)",
|
"%duration%": "Track duration (s)",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const PlaceholderSelector = ({ onSelect }: { onSelect: (value: string) => void }) => (
|
const PlaceholderSelector = ({ onSelect }: { onSelect: (value: string) => void }) => (
|
||||||
<select
|
<select
|
||||||
onChange={(e) => onSelect(e.target.value)}
|
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"
|
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>
|
<option value="">-- Insert Placeholder --</option>
|
||||||
{Object.entries(placeholders).map(([group, options]) => (
|
{Object.entries(placeholders).map(([group, options]) => (
|
||||||
<optgroup label={group} key={group}>
|
<optgroup label={group} key={group}>
|
||||||
{Object.entries(options).map(([value, label]) => (
|
{Object.entries(options).map(([value, label]) => (
|
||||||
<option key={value} value={value}>{`${value} - ${label}`}</option>
|
<option key={value} value={value}>{`${value} - ${label}`}</option>
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
// --- Component ---
|
// --- Component ---
|
||||||
export function FormattingTab({ config, isLoading }: FormattingTabProps) {
|
export function FormattingTab({ config, isLoading }: FormattingTabProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -73,8 +72,8 @@ export function FormattingTab({ config, isLoading }: FormattingTabProps) {
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: saveFormattingConfig,
|
mutationFn: saveFormattingConfig,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Formatting settings saved!');
|
toast.success("Formatting settings saved!");
|
||||||
queryClient.invalidateQueries({ queryKey: ['config'] });
|
queryClient.invalidateQueries({ queryKey: ["config"] });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(`Failed to save settings: ${error.message}`);
|
toast.error(`Failed to save settings: ${error.message}`);
|
||||||
@@ -86,17 +85,19 @@ export function FormattingTab({ config, isLoading }: FormattingTabProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Correctly register the refs for react-hook-form while also holding a local ref.
|
// Correctly register the refs for react-hook-form while also holding a local ref.
|
||||||
const { ref: dirFormatRef, ...dirFormatRest } = register('customDirFormat');
|
const { ref: dirFormatRef, ...dirFormatRest } = register("customDirFormat");
|
||||||
const { ref: trackFormatRef, ...trackFormatRest } = register('customTrackFormat');
|
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 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) => {
|
const onSubmit: SubmitHandler<FormattingSettings> = (data) => {
|
||||||
mutation.mutate(data);
|
mutation.mutate(data);
|
||||||
@@ -111,45 +112,54 @@ export function FormattingTab({ config, isLoading }: FormattingTabProps) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-xl font-semibold">File Naming</h3>
|
<h3 className="text-xl font-semibold">File Naming</h3>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="customDirFormat">Custom Directory Format</label>
|
<label htmlFor="customDirFormat">Custom Directory Format</label>
|
||||||
<input
|
<input
|
||||||
id="customDirFormat"
|
id="customDirFormat"
|
||||||
type="text"
|
type="text"
|
||||||
{...dirFormatRest}
|
{...dirFormatRest}
|
||||||
ref={(e) => {
|
ref={(e) => {
|
||||||
dirFormatRef(e);
|
dirFormatRef(e);
|
||||||
dirInputRef.current = 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"
|
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)} />
|
<PlaceholderSelector onSelect={handlePlaceholderSelect("customDirFormat", dirInputRef)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="customTrackFormat">Custom Track Format</label>
|
<label htmlFor="customTrackFormat">Custom Track Format</label>
|
||||||
<input
|
<input
|
||||||
id="customTrackFormat"
|
id="customTrackFormat"
|
||||||
type="text"
|
type="text"
|
||||||
{...trackFormatRest}
|
{...trackFormatRest}
|
||||||
ref={(e) => {
|
ref={(e) => {
|
||||||
trackFormatRef(e);
|
trackFormatRef(e);
|
||||||
trackInputRef.current = 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"
|
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)} />
|
<PlaceholderSelector onSelect={handlePlaceholderSelect("customTrackFormat", trackInputRef)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label htmlFor="tracknumPaddingToggle">Track Number Padding</label>
|
<label htmlFor="tracknumPaddingToggle">Track Number Padding</label>
|
||||||
<input id="tracknumPaddingToggle" type="checkbox" {...register('tracknumPadding')} className="h-6 w-6 rounded" />
|
<input
|
||||||
|
id="tracknumPaddingToggle"
|
||||||
|
type="checkbox"
|
||||||
|
{...register("tracknumPadding")}
|
||||||
|
className="h-6 w-6 rounded"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label htmlFor="saveCoverToggle">Save Album Cover</label>
|
<label htmlFor="saveCoverToggle">Save Album Cover</label>
|
||||||
<input id="saveCoverToggle" type="checkbox" {...register('saveCover')} className="h-6 w-6 rounded" />
|
<input id="saveCoverToggle" type="checkbox" {...register("saveCover")} className="h-6 w-6 rounded" />
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<button
|
||||||
{mutation.isPending ? 'Saving...' : 'Save Formatting Settings'}
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from "react-hook-form";
|
||||||
import apiClient from '../../lib/api-client';
|
import apiClient from "../../lib/api-client";
|
||||||
import { toast } from 'sonner';
|
import { toast } from "sonner";
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useSettings } from '../../contexts/settings-context';
|
import { useSettings } from "../../contexts/settings-context";
|
||||||
|
|
||||||
// --- Type Definitions ---
|
// --- Type Definitions ---
|
||||||
interface Credential {
|
interface Credential {
|
||||||
@@ -10,11 +10,11 @@ interface Credential {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface GeneralSettings {
|
interface GeneralSettings {
|
||||||
service: 'spotify' | 'deezer';
|
service: "spotify" | "deezer";
|
||||||
spotify: string;
|
spotify: string;
|
||||||
spotifyQuality: 'NORMAL' | 'HIGH' | 'VERY_HIGH';
|
spotifyQuality: "NORMAL" | "HIGH" | "VERY_HIGH";
|
||||||
deezer: string;
|
deezer: string;
|
||||||
deezerQuality: 'MP3_128' | 'MP3_320' | 'FLAC';
|
deezerQuality: "MP3_128" | "MP3_320" | "FLAC";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GeneralTabProps {
|
interface GeneralTabProps {
|
||||||
@@ -23,20 +23,26 @@ interface GeneralTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- API Functions ---
|
// --- API Functions ---
|
||||||
const fetchCredentials = async (service: 'spotify' | 'deezer'): Promise<Credential[]> => {
|
const fetchCredentials = async (service: "spotify" | "deezer"): Promise<Credential[]> => {
|
||||||
const { data } = await apiClient.get<string[]>(`/credentials/${service}`);
|
const { data } = await apiClient.get<string[]>(`/credentials/${service}`);
|
||||||
return data.map(name => ({ name }));
|
return data.map((name) => ({ name }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveGeneralConfig = (data: Partial<GeneralSettings>) => apiClient.post('/config', data);
|
const saveGeneralConfig = (data: Partial<GeneralSettings>) => apiClient.post("/config", data);
|
||||||
|
|
||||||
// --- Component ---
|
// --- Component ---
|
||||||
export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabProps) {
|
export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { settings: globalSettings, isLoading: settingsLoading } = useSettings();
|
const { settings: globalSettings, isLoading: settingsLoading } = useSettings();
|
||||||
|
|
||||||
const { data: spotifyAccounts, isLoading: spotifyLoading } = useQuery({ queryKey: ['credentials', 'spotify'], queryFn: () => fetchCredentials('spotify') });
|
const { data: spotifyAccounts, isLoading: spotifyLoading } = useQuery({
|
||||||
const { data: deezerAccounts, isLoading: deezerLoading } = useQuery({ queryKey: ['credentials', 'deezer'], queryFn: () => fetchCredentials('deezer') });
|
queryKey: ["credentials", "spotify"],
|
||||||
|
queryFn: () => fetchCredentials("spotify"),
|
||||||
|
});
|
||||||
|
const { data: deezerAccounts, isLoading: deezerLoading } = useQuery({
|
||||||
|
queryKey: ["credentials", "deezer"],
|
||||||
|
queryFn: () => fetchCredentials("deezer"),
|
||||||
|
});
|
||||||
|
|
||||||
const { register, handleSubmit } = useForm<GeneralSettings>({
|
const { register, handleSubmit } = useForm<GeneralSettings>({
|
||||||
values: config,
|
values: config,
|
||||||
@@ -45,8 +51,8 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: saveGeneralConfig,
|
mutationFn: saveGeneralConfig,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('General settings saved!');
|
toast.success("General settings saved!");
|
||||||
queryClient.invalidateQueries({ queryKey: ['config'] });
|
queryClient.invalidateQueries({ queryKey: ["config"] });
|
||||||
},
|
},
|
||||||
onError: (e: Error) => toast.error(`Failed to save: ${e.message}`),
|
onError: (e: Error) => toast.error(`Failed to save: ${e.message}`),
|
||||||
});
|
});
|
||||||
@@ -58,91 +64,99 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-xl font-semibold">Service Defaults</h3>
|
<h3 className="text-xl font-semibold">Service Defaults</h3>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="service">Default Service</label>
|
<label htmlFor="service">Default Service</label>
|
||||||
<select
|
<select
|
||||||
id="service"
|
id="service"
|
||||||
{...register('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"
|
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="spotify">Spotify</option>
|
||||||
<option value="deezer">Deezer</option>
|
<option value="deezer">Deezer</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-xl font-semibold">Spotify Settings</h3>
|
<h3 className="text-xl font-semibold">Spotify Settings</h3>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="spotifyAccount">Active Spotify Account</label>
|
<label htmlFor="spotifyAccount">Active Spotify Account</label>
|
||||||
<select
|
<select
|
||||||
id="spotifyAccount"
|
id="spotifyAccount"
|
||||||
{...register('spotify')}
|
{...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"
|
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>)}
|
{spotifyAccounts?.map((acc) => (
|
||||||
</select>
|
<option key={acc.name} value={acc.name}>
|
||||||
</div>
|
{acc.name}
|
||||||
<div className="flex flex-col gap-2">
|
</option>
|
||||||
<label htmlFor="spotifyQuality">Spotify Quality</label>
|
))}
|
||||||
<select
|
</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>
|
||||||
|
<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">
|
<div className="space-y-4">
|
||||||
<h3 className="text-xl font-semibold">Deezer Settings</h3>
|
<h3 className="text-xl font-semibold">Deezer Settings</h3>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="deezerAccount">Active Deezer Account</label>
|
<label htmlFor="deezerAccount">Active Deezer Account</label>
|
||||||
<select
|
<select
|
||||||
id="deezerAccount"
|
id="deezerAccount"
|
||||||
{...register('deezer')}
|
{...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"
|
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>)}
|
{deezerAccounts?.map((acc) => (
|
||||||
</select>
|
<option key={acc.name} value={acc.name}>
|
||||||
</div>
|
{acc.name}
|
||||||
<div className="flex flex-col gap-2">
|
</option>
|
||||||
<label htmlFor="deezerQuality">Deezer Quality</label>
|
))}
|
||||||
<select
|
</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>
|
||||||
|
<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">
|
<div className="space-y-4">
|
||||||
<h3 className="text-xl font-semibold">Content Filters</h3>
|
<h3 className="text-xl font-semibold">Content Filters</h3>
|
||||||
<div className="form-item--row">
|
<div className="form-item--row">
|
||||||
<label>Filter Explicit Content</label>
|
<label>Filter Explicit Content</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`font-semibold ${globalSettings?.explicitFilter ? 'text-green-400' : 'text-red-400'}`}>
|
<span className={`font-semibold ${globalSettings?.explicitFilter ? "text-green-400" : "text-red-400"}`}>
|
||||||
{globalSettings?.explicitFilter ? 'Enabled' : 'Disabled'}
|
{globalSettings?.explicitFilter ? "Enabled" : "Disabled"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs bg-gray-600 text-white px-2 py-1 rounded-full">ENV</span>
|
<span className="text-xs bg-gray-600 text-white px-2 py-1 rounded-full">ENV</span>
|
||||||
</div>
|
</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>
|
</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">
|
<button type="submit" disabled={mutation.isPending} className="btn-primary">
|
||||||
{mutation.isPending ? 'Saving...' : 'Save General Settings'}
|
{mutation.isPending ? "Saving..." : "Save General Settings"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from "react";
|
||||||
import { useForm, Controller } from 'react-hook-form';
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import apiClient from '../../lib/api-client';
|
import apiClient from "../../lib/api-client";
|
||||||
import { toast } from 'sonner';
|
import { toast } from "sonner";
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
// --- Type Definitions ---
|
// --- Type Definitions ---
|
||||||
interface SpotifyApiSettings {
|
interface SpotifyApiSettings {
|
||||||
@@ -18,16 +18,16 @@ interface WebhookSettings {
|
|||||||
|
|
||||||
// --- API Functions ---
|
// --- API Functions ---
|
||||||
const fetchSpotifyApiConfig = async (): Promise<SpotifyApiSettings> => {
|
const fetchSpotifyApiConfig = async (): Promise<SpotifyApiSettings> => {
|
||||||
const { data } = await apiClient.get('/credentials/spotify_api_config');
|
const { data } = await apiClient.get("/credentials/spotify_api_config");
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
const saveSpotifyApiConfig = (data: SpotifyApiSettings) => apiClient.put('/credentials/spotify_api_config', data);
|
const saveSpotifyApiConfig = (data: SpotifyApiSettings) => apiClient.put("/credentials/spotify_api_config", data);
|
||||||
|
|
||||||
const fetchWebhookConfig = async (): Promise<WebhookSettings> => {
|
const fetchWebhookConfig = async (): Promise<WebhookSettings> => {
|
||||||
// Mock a response since backend endpoint doesn't exist
|
// Mock a response since backend endpoint doesn't exist
|
||||||
// This will prevent the UI from crashing.
|
// This will prevent the UI from crashing.
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
url: '',
|
url: "",
|
||||||
events: [],
|
events: [],
|
||||||
available_events: ["download_start", "download_complete", "download_failed", "watch_added"],
|
available_events: ["download_start", "download_complete", "download_failed", "watch_added"],
|
||||||
});
|
});
|
||||||
@@ -39,120 +39,153 @@ const saveWebhookConfig = (data: Partial<WebhookSettings>) => {
|
|||||||
const testWebhook = (url: string) => {
|
const testWebhook = (url: string) => {
|
||||||
toast.info("Webhook testing is not available.");
|
toast.info("Webhook testing is not available.");
|
||||||
return Promise.resolve(url);
|
return Promise.resolve(url);
|
||||||
}
|
};
|
||||||
|
|
||||||
// --- Components ---
|
// --- Components ---
|
||||||
function SpotifyApiForm() {
|
function SpotifyApiForm() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data, isLoading } = useQuery({ queryKey: ['spotifyApiConfig'], queryFn: fetchSpotifyApiConfig });
|
const { data, isLoading } = useQuery({ queryKey: ["spotifyApiConfig"], queryFn: fetchSpotifyApiConfig });
|
||||||
const { register, handleSubmit, reset } = useForm<SpotifyApiSettings>();
|
const { register, handleSubmit, reset } = useForm<SpotifyApiSettings>();
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: saveSpotifyApiConfig,
|
mutationFn: saveSpotifyApiConfig,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Spotify API settings saved!');
|
toast.success("Spotify API settings saved!");
|
||||||
queryClient.invalidateQueries({ queryKey: ['spotifyApiConfig'] });
|
queryClient.invalidateQueries({ queryKey: ["spotifyApiConfig"] });
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(`Failed to save: ${e.message}`),
|
onError: (e) => toast.error(`Failed to save: ${e.message}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => { if (data) reset(data); }, [data, reset]);
|
useEffect(() => {
|
||||||
|
if (data) reset(data);
|
||||||
|
}, [data, reset]);
|
||||||
|
|
||||||
const onSubmit = (formData: SpotifyApiSettings) => mutation.mutate(formData);
|
const onSubmit = (formData: SpotifyApiSettings) => mutation.mutate(formData);
|
||||||
|
|
||||||
if (isLoading) return <p>Loading Spotify API settings...</p>;
|
if (isLoading) return <p>Loading Spotify API settings...</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="client_id">Client ID</label>
|
<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"/>
|
<input
|
||||||
</div>
|
id="client_id"
|
||||||
<div className="flex flex-col gap-2">
|
type="password"
|
||||||
<label htmlFor="client_secret">Client Secret</label>
|
{...register("client_id")}
|
||||||
<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" />
|
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>
|
placeholder="Optional"
|
||||||
<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'}
|
</div>
|
||||||
</button>
|
<div className="flex flex-col gap-2">
|
||||||
</form>
|
<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() {
|
function WebhookForm() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data, isLoading } = useQuery({ queryKey: ['webhookConfig'], queryFn: fetchWebhookConfig });
|
const { data, isLoading } = useQuery({ queryKey: ["webhookConfig"], queryFn: fetchWebhookConfig });
|
||||||
const { register, handleSubmit, control, reset, watch } = useForm<WebhookSettings>();
|
const { register, handleSubmit, control, reset, watch } = useForm<WebhookSettings>();
|
||||||
const currentUrl = watch('url');
|
const currentUrl = watch("url");
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: saveWebhookConfig,
|
mutationFn: saveWebhookConfig,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// No toast needed since the function shows one
|
// No toast needed since the function shows one
|
||||||
queryClient.invalidateQueries({ queryKey: ['webhookConfig'] });
|
queryClient.invalidateQueries({ queryKey: ["webhookConfig"] });
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(`Failed to save: ${e.message}`),
|
onError: (e) => toast.error(`Failed to save: ${e.message}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const testMutation = useMutation({
|
const testMutation = useMutation({
|
||||||
mutationFn: testWebhook,
|
mutationFn: testWebhook,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// No toast needed
|
// No toast needed
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(`Webhook test failed: ${e.message}`),
|
onError: (e) => toast.error(`Webhook test failed: ${e.message}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => { if (data) reset(data); }, [data, reset]);
|
useEffect(() => {
|
||||||
|
if (data) reset(data);
|
||||||
|
}, [data, reset]);
|
||||||
|
|
||||||
const onSubmit = (formData: WebhookSettings) => mutation.mutate(formData);
|
const onSubmit = (formData: WebhookSettings) => mutation.mutate(formData);
|
||||||
|
|
||||||
if (isLoading) return <p>Loading Webhook settings...</p>;
|
if (isLoading) return <p>Loading Webhook settings...</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="webhookUrl">Webhook URL</label>
|
<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" />
|
<input
|
||||||
</div>
|
id="webhookUrl"
|
||||||
<div className="flex flex-col gap-2">
|
type="url"
|
||||||
<label>Webhook Events</label>
|
{...register("url")}
|
||||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
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"
|
||||||
{data?.available_events.map((event) => (
|
placeholder="https://example.com/webhook"
|
||||||
<Controller
|
/>
|
||||||
key={event}
|
</div>
|
||||||
name="events"
|
<div className="flex flex-col gap-2">
|
||||||
control={control}
|
<label>Webhook Events</label>
|
||||||
render={({ field }) => (
|
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||||
<label className="flex items-center gap-2">
|
{data?.available_events.map((event) => (
|
||||||
<input
|
<Controller
|
||||||
type="checkbox"
|
key={event}
|
||||||
className="h-5 w-5 rounded"
|
name="events"
|
||||||
checked={field.value?.includes(event) ?? false}
|
control={control}
|
||||||
onChange={(e) => {
|
render={({ field }) => (
|
||||||
const value = field.value || [];
|
<label className="flex items-center gap-2">
|
||||||
const newValues = e.target.checked
|
<input
|
||||||
? [...value, event]
|
type="checkbox"
|
||||||
: value.filter((v) => v !== event);
|
className="h-5 w-5 rounded"
|
||||||
field.onChange(newValues);
|
checked={field.value?.includes(event) ?? false}
|
||||||
}}
|
onChange={(e) => {
|
||||||
/>
|
const value = field.value || [];
|
||||||
<span className="capitalize">{event.replace(/_/g, ' ')}</span>
|
const newValues = e.target.checked ? [...value, event] : value.filter((v) => v !== event);
|
||||||
</label>
|
field.onChange(newValues);
|
||||||
)}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
<span className="capitalize">{event.replace(/_/g, " ")}</span>
|
||||||
</div>
|
</label>
|
||||||
</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'}
|
</div>
|
||||||
</button>
|
</div>
|
||||||
<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">
|
<div className="flex gap-2">
|
||||||
Test
|
<button
|
||||||
</button>
|
type="submit"
|
||||||
</div>
|
disabled={mutation.isPending}
|
||||||
</form>
|
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() {
|
export function ServerTab() {
|
||||||
@@ -166,7 +199,9 @@ export function ServerTab() {
|
|||||||
<hr className="border-gray-600" />
|
<hr className="border-gray-600" />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-semibold">Webhooks</h3>
|
<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>
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Get notifications for events like download completion. (Currently disabled)
|
||||||
|
</p>
|
||||||
<WebhookForm />
|
<WebhookForm />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from "react";
|
||||||
import { useForm, type SubmitHandler, Controller } from 'react-hook-form';
|
import { useForm, type SubmitHandler, Controller } from "react-hook-form";
|
||||||
import apiClient from '../../lib/api-client';
|
import apiClient from "../../lib/api-client";
|
||||||
import { toast } from 'sonner';
|
import { toast } from "sonner";
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
// --- Type Definitions ---
|
// --- Type Definitions ---
|
||||||
const ALBUM_GROUPS = ["album", "single", "compilation", "appears_on"] as const;
|
const ALBUM_GROUPS = ["album", "single", "compilation", "appears_on"] as const;
|
||||||
|
|
||||||
type AlbumGroup = typeof ALBUM_GROUPS[number];
|
type AlbumGroup = (typeof ALBUM_GROUPS)[number];
|
||||||
|
|
||||||
interface WatchSettings {
|
interface WatchSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -17,12 +17,12 @@ interface WatchSettings {
|
|||||||
|
|
||||||
// --- API Functions ---
|
// --- API Functions ---
|
||||||
const fetchWatchConfig = async (): Promise<WatchSettings> => {
|
const fetchWatchConfig = async (): Promise<WatchSettings> => {
|
||||||
const { data } = await apiClient.get('/config/watch');
|
const { data } = await apiClient.get("/config/watch");
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveWatchConfig = async (data: Partial<WatchSettings>) => {
|
const saveWatchConfig = async (data: Partial<WatchSettings>) => {
|
||||||
const { data: response } = await apiClient.post('/config/watch', data);
|
const { data: response } = await apiClient.post("/config/watch", data);
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,15 +31,15 @@ export function WatchTab() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: config, isLoading } = useQuery({
|
const { data: config, isLoading } = useQuery({
|
||||||
queryKey: ['watchConfig'],
|
queryKey: ["watchConfig"],
|
||||||
queryFn: fetchWatchConfig,
|
queryFn: fetchWatchConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: saveWatchConfig,
|
mutationFn: saveWatchConfig,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Watch settings saved successfully!');
|
toast.success("Watch settings saved successfully!");
|
||||||
queryClient.invalidateQueries({ queryKey: ['watchConfig'] });
|
queryClient.invalidateQueries({ queryKey: ["watchConfig"] });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(`Failed to save settings: ${error.message}`);
|
toast.error(`Failed to save settings: ${error.message}`);
|
||||||
@@ -56,8 +56,8 @@ export function WatchTab() {
|
|||||||
|
|
||||||
const onSubmit: SubmitHandler<WatchSettings> = (data) => {
|
const onSubmit: SubmitHandler<WatchSettings> = (data) => {
|
||||||
mutation.mutate({
|
mutation.mutate({
|
||||||
...data,
|
...data,
|
||||||
watchPollIntervalSeconds: Number(data.watchPollIntervalSeconds),
|
watchPollIntervalSeconds: Number(data.watchPollIntervalSeconds),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,54 +67,60 @@ export function WatchTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-xl font-semibold">Watchlist Behavior</h3>
|
<h3 className="text-xl font-semibold">Watchlist Behavior</h3>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label htmlFor="watchEnabledToggle">Enable Watchlist</label>
|
<label htmlFor="watchEnabledToggle">Enable Watchlist</label>
|
||||||
<input id="watchEnabledToggle" type="checkbox" {...register('enabled')} className="h-6 w-6 rounded" />
|
<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>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
<div className="space-y-4">
|
<label htmlFor="watchPollIntervalSeconds">Watch Poll Interval (seconds)</label>
|
||||||
<h3 className="text-xl font-semibold">Artist Album Groups</h3>
|
<input
|
||||||
<p className="text-sm text-gray-500">Select which album groups to monitor for watched artists.</p>
|
id="watchPollIntervalSeconds"
|
||||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
type="number"
|
||||||
{ALBUM_GROUPS.map((group) => (
|
min="60"
|
||||||
<Controller
|
{...register("watchPollIntervalSeconds")}
|
||||||
key={group}
|
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"
|
||||||
name="watchedArtistAlbumGroup"
|
/>
|
||||||
control={control}
|
<p className="text-sm text-gray-500 mt-1">How often to check watched items for updates.</p>
|
||||||
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>
|
</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">
|
<div className="space-y-4">
|
||||||
{mutation.isPending ? 'Saving...' : 'Save Watch Settings'}
|
<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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,117 +1,282 @@
|
|||||||
import { useState, useCallback, type ReactNode, useEffect, useRef } from 'react';
|
import { useState, useCallback, type ReactNode, useEffect, useRef } from "react";
|
||||||
import apiClient from '../lib/api-client';
|
import apiClient from "../lib/api-client";
|
||||||
import { QueueContext, type QueueItem } from './queue-context';
|
import { QueueContext, type QueueItem, type DownloadType, type QueueStatus } from "./queue-context";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
// --- Helper Types ---
|
// --- Helper Types ---
|
||||||
interface TaskStatus {
|
// This represents the raw status object from the backend polling endpoint
|
||||||
status: 'downloading' | 'completed' | 'error' | 'queued';
|
interface TaskStatusDTO {
|
||||||
progress?: number;
|
status: QueueStatus;
|
||||||
speed?: string;
|
message?: string;
|
||||||
size?: string;
|
can_retry?: boolean;
|
||||||
eta?: string;
|
|
||||||
message?: string;
|
// Progress indicators
|
||||||
|
progress?: number;
|
||||||
|
speed?: string;
|
||||||
|
size?: string;
|
||||||
|
eta?: string;
|
||||||
|
|
||||||
|
// Multi-track progress
|
||||||
|
current_track?: number;
|
||||||
|
total_tracks?: number;
|
||||||
|
summary?: {
|
||||||
|
successful_tracks: number;
|
||||||
|
skipped_tracks: number;
|
||||||
|
failed_tracks: number;
|
||||||
|
failed_track_details: { name: string; reason: string }[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isTerminalStatus = (status: QueueStatus) => ["completed", "error", "cancelled", "skipped"].includes(status);
|
||||||
|
|
||||||
export function QueueProvider({ children }: { children: ReactNode }) {
|
export function QueueProvider({ children }: { children: ReactNode }) {
|
||||||
const [items, setItems] = useState<QueueItem[]>([]);
|
const [items, setItems] = useState<QueueItem[]>(() => {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
try {
|
||||||
const pollingIntervals = useRef<Record<string, number>>({});
|
const storedItems = localStorage.getItem("queueItems");
|
||||||
|
return storedItems ? JSON.parse(storedItems) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const pollingIntervals = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
// --- Core Action: Add Item ---
|
// --- Persistence ---
|
||||||
const addItem = useCallback(async (item: Omit<QueueItem, 'status'>) => {
|
useEffect(() => {
|
||||||
const newItem: QueueItem = { ...item, status: 'queued' };
|
localStorage.setItem("queueItems", JSON.stringify(items));
|
||||||
setItems(prev => [...prev, newItem]);
|
}, [items]);
|
||||||
toggleVisibility();
|
|
||||||
|
|
||||||
|
const stopPolling = useCallback((internalId: string) => {
|
||||||
|
if (pollingIntervals.current[internalId]) {
|
||||||
|
clearInterval(pollingIntervals.current[internalId]);
|
||||||
|
delete pollingIntervals.current[internalId];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// --- Polling Logic ---
|
||||||
|
const startPolling = useCallback(
|
||||||
|
(internalId: string, taskId: string) => {
|
||||||
|
if (pollingIntervals.current[internalId]) return;
|
||||||
|
|
||||||
|
const intervalId = window.setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
// This endpoint should initiate the download and return a task ID
|
const response = await apiClient.get<TaskStatusDTO>(`/download/status/${taskId}`);
|
||||||
const response = await apiClient.post<{ taskId: string }>(`/download/${item.type}`, { id: item.id });
|
const statusUpdate = response.data;
|
||||||
const { taskId } = response.data;
|
|
||||||
|
|
||||||
// Update item with taskId and start polling
|
setItems((prev) =>
|
||||||
setItems(prev => prev.map(i => i.id === item.id ? { ...i, taskId, status: 'pending' } : i));
|
prev.map((item) => {
|
||||||
startPolling(taskId);
|
if (item.id === internalId) {
|
||||||
|
const updatedItem: QueueItem = {
|
||||||
|
...item,
|
||||||
|
status: statusUpdate.status,
|
||||||
|
progress: statusUpdate.progress,
|
||||||
|
speed: statusUpdate.speed,
|
||||||
|
size: statusUpdate.size,
|
||||||
|
eta: statusUpdate.eta,
|
||||||
|
error: statusUpdate.status === "error" ? statusUpdate.message : undefined,
|
||||||
|
canRetry: statusUpdate.can_retry,
|
||||||
|
currentTrackNumber: statusUpdate.current_track,
|
||||||
|
totalTracks: statusUpdate.total_tracks,
|
||||||
|
summary: statusUpdate.summary
|
||||||
|
? {
|
||||||
|
successful: statusUpdate.summary.successful_tracks,
|
||||||
|
skipped: statusUpdate.summary.skipped_tracks,
|
||||||
|
failed: statusUpdate.summary.failed_tracks,
|
||||||
|
failedTracks: statusUpdate.summary.failed_track_details,
|
||||||
|
}
|
||||||
|
: item.summary,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isTerminalStatus(statusUpdate.status)) {
|
||||||
|
stopPolling(internalId);
|
||||||
|
}
|
||||||
|
return updatedItem;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to start download for ${item.name}:`, error);
|
console.error(`Polling failed for task ${taskId}:`, error);
|
||||||
setItems(prev => prev.map(i => i.id === item.id ? { ...i, status: 'error', error: 'Failed to start download' } : i));
|
stopPolling(internalId);
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((i) =>
|
||||||
|
i.id === internalId
|
||||||
|
? {
|
||||||
|
...i,
|
||||||
|
status: "error",
|
||||||
|
error: "Connection lost",
|
||||||
|
}
|
||||||
|
: i,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, []);
|
}, 2000); // Poll every 2 seconds
|
||||||
|
|
||||||
// --- Polling Logic ---
|
pollingIntervals.current[internalId] = intervalId;
|
||||||
const startPolling = (taskId: string) => {
|
},
|
||||||
if (pollingIntervals.current[taskId]) return; // Already polling
|
[stopPolling],
|
||||||
|
);
|
||||||
|
|
||||||
const intervalId = window.setInterval(async () => {
|
// --- Core Action: Add Item ---
|
||||||
try {
|
const addItem = useCallback(
|
||||||
const response = await apiClient.get<TaskStatus>(`/download/status/${taskId}`);
|
async (item: { name: string; type: DownloadType; spotifyId: string }) => {
|
||||||
const statusUpdate = response.data;
|
const internalId = uuidv4();
|
||||||
|
const newItem: QueueItem = {
|
||||||
|
...item,
|
||||||
|
id: internalId,
|
||||||
|
status: "queued",
|
||||||
|
};
|
||||||
|
setItems((prev) => [...prev, newItem]);
|
||||||
|
if (!isVisible) setIsVisible(true);
|
||||||
|
|
||||||
setItems(prev => prev.map(item => {
|
try {
|
||||||
if (item.taskId === taskId) {
|
const response = await apiClient.post<{ task_id: string }>(`/download`, {
|
||||||
const updatedItem = {
|
url: `https://open.spotify.com/${item.type}/${item.spotifyId}`,
|
||||||
...item,
|
});
|
||||||
status: statusUpdate.status,
|
const { task_id } = response.data;
|
||||||
progress: statusUpdate.progress,
|
setItems((prev) =>
|
||||||
speed: statusUpdate.speed,
|
prev.map((i) => (i.id === internalId ? { ...i, taskId: task_id, status: "initializing" } : i)),
|
||||||
size: statusUpdate.size,
|
);
|
||||||
eta: statusUpdate.eta,
|
startPolling(internalId, task_id);
|
||||||
error: statusUpdate.status === 'error' ? statusUpdate.message : undefined,
|
} catch (error) {
|
||||||
};
|
console.error(`Failed to start download for ${item.name}:`, error);
|
||||||
|
toast.error(`Failed to start download for ${item.name}`);
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((i) =>
|
||||||
|
i.id === internalId
|
||||||
|
? {
|
||||||
|
...i,
|
||||||
|
status: "error",
|
||||||
|
error: "Failed to start download task.",
|
||||||
|
}
|
||||||
|
: i,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isVisible, startPolling],
|
||||||
|
);
|
||||||
|
|
||||||
if (statusUpdate.status === 'completed' || statusUpdate.status === 'error') {
|
const clearAllPolls = useCallback(() => {
|
||||||
stopPolling(taskId);
|
Object.values(pollingIntervals.current).forEach(clearInterval);
|
||||||
}
|
}, []);
|
||||||
return updatedItem;
|
|
||||||
}
|
// --- Load existing tasks on startup ---
|
||||||
return item;
|
useEffect(() => {
|
||||||
}));
|
const syncActiveTasks = async () => {
|
||||||
} catch (error) {
|
try {
|
||||||
console.error(`Polling failed for task ${taskId}:`, error);
|
const response = await apiClient.get<QueueItem[]>("/download/active");
|
||||||
stopPolling(taskId);
|
const activeTasks = response.data;
|
||||||
setItems(prev => prev.map(i => i.taskId === taskId ? { ...i, status: 'error', error: 'Connection lost' } : i));
|
|
||||||
|
// Basic reconciliation
|
||||||
|
setItems((prevItems) => {
|
||||||
|
const newItems = [...prevItems];
|
||||||
|
activeTasks.forEach((task) => {
|
||||||
|
if (!newItems.some((item) => item.taskId === task.taskId)) {
|
||||||
|
newItems.push({
|
||||||
|
...task,
|
||||||
|
id: task.taskId || uuidv4(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, 2000); // Poll every 2 seconds
|
});
|
||||||
|
return newItems;
|
||||||
|
});
|
||||||
|
|
||||||
pollingIntervals.current[taskId] = intervalId;
|
activeTasks.forEach((item) => {
|
||||||
|
if (item.id && item.taskId && !isTerminalStatus(item.status)) {
|
||||||
|
startPolling(item.id, item.taskId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to sync active tasks:", error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
syncActiveTasks();
|
||||||
|
|
||||||
const stopPolling = (taskId: string) => {
|
// restart polling for any non-terminal items from localStorage
|
||||||
clearInterval(pollingIntervals.current[taskId]);
|
items.forEach((item) => {
|
||||||
delete pollingIntervals.current[taskId];
|
if (item.id && item.taskId && !isTerminalStatus(item.status)) {
|
||||||
};
|
startPolling(item.id, item.taskId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Cleanup on unmount
|
return clearAllPolls;
|
||||||
useEffect(() => {
|
// This effect should only run once on mount to initialize the queue.
|
||||||
return () => {
|
// We are intentionally omitting 'items' as a dependency to prevent re-runs.
|
||||||
Object.values(pollingIntervals.current).forEach(clearInterval);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
};
|
}, [clearAllPolls, startPolling]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
// --- Other Actions ---
|
// --- Other Actions ---
|
||||||
const removeItem = useCallback((id: string) => {
|
const removeItem = useCallback(
|
||||||
const itemToRemove = items.find(i => i.id === id);
|
async (id: string) => {
|
||||||
if (itemToRemove && itemToRemove.taskId) {
|
const itemToRemove = items.find((i) => i.id === id);
|
||||||
stopPolling(itemToRemove.taskId);
|
if (itemToRemove) {
|
||||||
// Optionally, call an API to cancel the backend task
|
stopPolling(itemToRemove.id);
|
||||||
// apiClient.post(`/download/cancel/${itemToRemove.taskId}`);
|
if (itemToRemove.taskId) {
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/download/cancel/${itemToRemove.taskId}`);
|
||||||
|
toast.success(`Cancelled download: ${itemToRemove.name}`);
|
||||||
|
} catch {
|
||||||
|
toast.error(`Failed to cancel download: ${itemToRemove.name}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setItems(prev => prev.filter(item => item.id !== id));
|
}
|
||||||
}, [items]);
|
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||||
|
},
|
||||||
|
[items, stopPolling],
|
||||||
|
);
|
||||||
|
|
||||||
const clearQueue = useCallback(() => {
|
const retryItem = useCallback(
|
||||||
Object.values(pollingIntervals.current).forEach(clearInterval);
|
async (id: string) => {
|
||||||
pollingIntervals.current = {};
|
const itemToRetry = items.find((i) => i.id === id);
|
||||||
setItems([]);
|
if (!itemToRetry || !itemToRetry.spotifyId) return;
|
||||||
// Optionally, call an API to cancel all tasks
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleVisibility = useCallback(() => setIsVisible(prev => !prev), []);
|
// Remove the old item
|
||||||
|
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||||
|
|
||||||
const value = { items, isVisible, addItem, removeItem, clearQueue, toggleVisibility };
|
// Add it again
|
||||||
|
await addItem({
|
||||||
|
name: itemToRetry.name,
|
||||||
|
type: itemToRetry.type,
|
||||||
|
spotifyId: itemToRetry.spotifyId,
|
||||||
|
});
|
||||||
|
toast.info(`Retrying download: ${itemToRetry.name}`);
|
||||||
|
},
|
||||||
|
[items, addItem],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
const clearQueue = useCallback(async () => {
|
||||||
<QueueContext.Provider value={value}>
|
for (const item of items) {
|
||||||
{children}
|
if (item.taskId) {
|
||||||
</QueueContext.Provider>
|
stopPolling(item.id);
|
||||||
);
|
try {
|
||||||
|
await apiClient.post(`/download/cancel/${item.taskId}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to cancel task ${item.taskId}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setItems([]);
|
||||||
|
toast.info("Queue cleared.");
|
||||||
|
}, [items, stopPolling]);
|
||||||
|
|
||||||
|
const clearCompleted = useCallback(() => {
|
||||||
|
setItems((prev) => prev.filter((item) => !isTerminalStatus(item.status)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleVisibility = useCallback(() => setIsVisible((prev) => !prev), []);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
items,
|
||||||
|
isVisible,
|
||||||
|
addItem,
|
||||||
|
removeItem,
|
||||||
|
retryItem,
|
||||||
|
clearQueue,
|
||||||
|
toggleVisibility,
|
||||||
|
clearCompleted,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <QueueContext.Provider value={value}>{children}</QueueContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from "react";
|
||||||
import apiClient from '../lib/api-client';
|
import apiClient from "../lib/api-client";
|
||||||
import { SettingsContext, type AppSettings } from './settings-context';
|
import { SettingsContext, type AppSettings } from "./settings-context";
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
// --- Case Conversion Utility ---
|
// --- Case Conversion Utility ---
|
||||||
// This is added here to simplify the fix and avoid module resolution issues.
|
// This is added here to simplify the fix and avoid module resolution issues.
|
||||||
function snakeToCamel(str: string): string {
|
function snakeToCamel(str: string): string {
|
||||||
return str.replace(/(_\w)/g, m => m[1].toUpperCase());
|
return str.replace(/(_\w)/g, (m) => m[1].toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertKeysToCamelCase(obj: unknown): unknown {
|
function convertKeysToCamelCase(obj: unknown): unknown {
|
||||||
if (Array.isArray(obj)) {
|
if (Array.isArray(obj)) {
|
||||||
return obj.map(v => convertKeysToCamelCase(v));
|
return obj.map((v) => convertKeysToCamelCase(v));
|
||||||
}
|
}
|
||||||
if (typeof obj === 'object' && obj !== null) {
|
if (typeof obj === "object" && obj !== null) {
|
||||||
return Object.keys(obj).reduce((acc: Record<string, unknown>, key: string) => {
|
return Object.keys(obj).reduce((acc: Record<string, unknown>, key: string) => {
|
||||||
const camelKey = snakeToCamel(key);
|
const camelKey = snakeToCamel(key);
|
||||||
acc[camelKey] = convertKeysToCamelCase((obj as Record<string, unknown>)[key]);
|
acc[camelKey] = convertKeysToCamelCase((obj as Record<string, unknown>)[key]);
|
||||||
@@ -25,15 +25,15 @@ function convertKeysToCamelCase(obj: unknown): unknown {
|
|||||||
|
|
||||||
// Redefine AppSettings to match the flat structure of the API response
|
// Redefine AppSettings to match the flat structure of the API response
|
||||||
export type FlatAppSettings = {
|
export type FlatAppSettings = {
|
||||||
service: 'spotify' | 'deezer';
|
service: "spotify" | "deezer";
|
||||||
spotify: string;
|
spotify: string;
|
||||||
spotifyQuality: 'NORMAL' | 'HIGH' | 'VERY_HIGH';
|
spotifyQuality: "NORMAL" | "HIGH" | "VERY_HIGH";
|
||||||
deezer: string;
|
deezer: string;
|
||||||
deezerQuality: 'MP3_128' | 'MP3_320' | 'FLAC';
|
deezerQuality: "MP3_128" | "MP3_320" | "FLAC";
|
||||||
maxConcurrentDownloads: number;
|
maxConcurrentDownloads: number;
|
||||||
realTime: boolean;
|
realTime: boolean;
|
||||||
fallback: boolean;
|
fallback: boolean;
|
||||||
convertTo: 'MP3' | 'AAC' | 'OGG' | 'OPUS' | 'FLAC' | 'WAV' | 'ALAC' | '';
|
convertTo: "MP3" | "AAC" | "OGG" | "OPUS" | "FLAC" | "WAV" | "ALAC" | "";
|
||||||
bitrate: string;
|
bitrate: string;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
retryDelaySeconds: number;
|
retryDelaySeconds: number;
|
||||||
@@ -44,7 +44,7 @@ export type FlatAppSettings = {
|
|||||||
saveCover: boolean;
|
saveCover: boolean;
|
||||||
explicitFilter: boolean;
|
explicitFilter: boolean;
|
||||||
// Add other fields from the old AppSettings as needed by other parts of the app
|
// Add other fields from the old AppSettings as needed by other parts of the app
|
||||||
watch: AppSettings['watch'];
|
watch: AppSettings["watch"];
|
||||||
// Add defaults for the new download properties
|
// Add defaults for the new download properties
|
||||||
threads: number;
|
threads: number;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -59,60 +59,60 @@ export type FlatAppSettings = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const defaultSettings: FlatAppSettings = {
|
const defaultSettings: FlatAppSettings = {
|
||||||
service: 'spotify',
|
service: "spotify",
|
||||||
spotify: '',
|
spotify: "",
|
||||||
spotifyQuality: 'NORMAL',
|
spotifyQuality: "NORMAL",
|
||||||
deezer: '',
|
deezer: "",
|
||||||
deezerQuality: 'MP3_128',
|
deezerQuality: "MP3_128",
|
||||||
maxConcurrentDownloads: 3,
|
maxConcurrentDownloads: 3,
|
||||||
realTime: false,
|
realTime: false,
|
||||||
fallback: false,
|
fallback: false,
|
||||||
convertTo: '',
|
convertTo: "",
|
||||||
bitrate: '',
|
bitrate: "",
|
||||||
maxRetries: 3,
|
maxRetries: 3,
|
||||||
retryDelaySeconds: 5,
|
retryDelaySeconds: 5,
|
||||||
retryDelayIncrease: 5,
|
retryDelayIncrease: 5,
|
||||||
customDirFormat: '%ar_album%/%album%',
|
customDirFormat: "%ar_album%/%album%",
|
||||||
customTrackFormat: '%tracknum%. %music%',
|
customTrackFormat: "%tracknum%. %music%",
|
||||||
tracknumPadding: true,
|
tracknumPadding: true,
|
||||||
saveCover: true,
|
saveCover: true,
|
||||||
explicitFilter: false,
|
explicitFilter: false,
|
||||||
// Add defaults for the new download properties
|
// Add defaults for the new download properties
|
||||||
threads: 4,
|
threads: 4,
|
||||||
path: '/downloads',
|
path: "/downloads",
|
||||||
skipExisting: true,
|
skipExisting: true,
|
||||||
m3u: false,
|
m3u: false,
|
||||||
hlsThreads: 8,
|
hlsThreads: 8,
|
||||||
// Add defaults for the new formatting properties
|
// Add defaults for the new formatting properties
|
||||||
track: '{artist_name}/{album_name}/{track_number} - {track_name}',
|
track: "{artist_name}/{album_name}/{track_number} - {track_name}",
|
||||||
album: '{artist_name}/{album_name}',
|
album: "{artist_name}/{album_name}",
|
||||||
playlist: 'Playlists/{playlist_name}',
|
playlist: "Playlists/{playlist_name}",
|
||||||
compilation: 'Compilations/{album_name}',
|
compilation: "Compilations/{album_name}",
|
||||||
watch: {
|
watch: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchSettings = async (): Promise<FlatAppSettings> => {
|
const fetchSettings = async (): Promise<FlatAppSettings> => {
|
||||||
const { data } = await apiClient.get('/config');
|
const { data } = await apiClient.get("/config");
|
||||||
// Transform the keys before returning the data
|
// Transform the keys before returning the data
|
||||||
return convertKeysToCamelCase(data) as FlatAppSettings;
|
return convertKeysToCamelCase(data) as FlatAppSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SettingsProvider({ children }: { children: ReactNode }) {
|
export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||||
const { data: settings, isLoading, isError } = useQuery({
|
const {
|
||||||
queryKey: ['config'],
|
data: settings,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["config"],
|
||||||
queryFn: fetchSettings,
|
queryFn: fetchSettings,
|
||||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use default settings on error to prevent app crash
|
// Use default settings on error to prevent app crash
|
||||||
const value = { settings: isError ? defaultSettings : (settings || null), isLoading };
|
const value = { settings: isError ? defaultSettings : settings || null, isLoading };
|
||||||
|
|
||||||
return (
|
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
|
||||||
<SettingsContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</SettingsContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,55 @@
|
|||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
export type DownloadType = "track" | "album" | "artist" | "playlist";
|
||||||
|
export type QueueStatus =
|
||||||
|
| "initializing"
|
||||||
|
| "pending"
|
||||||
|
| "downloading"
|
||||||
|
| "processing"
|
||||||
|
| "completed"
|
||||||
|
| "error"
|
||||||
|
| "skipped"
|
||||||
|
| "cancelled"
|
||||||
|
| "queued";
|
||||||
|
|
||||||
export interface QueueItem {
|
export interface QueueItem {
|
||||||
id: string; // This is the Spotify ID
|
id: string; // Unique ID for the queue item (can be task_id from backend)
|
||||||
type: 'track' | 'album' | 'artist' | 'playlist';
|
|
||||||
name: string;
|
name: string;
|
||||||
// --- Real-time progress fields ---
|
type: DownloadType;
|
||||||
status: 'pending' | 'downloading' | 'completed' | 'error' | 'queued';
|
spotifyId: string; // Original Spotify ID
|
||||||
|
|
||||||
|
// --- Status and Progress ---
|
||||||
|
status: QueueStatus;
|
||||||
taskId?: string; // The backend task ID for polling
|
taskId?: string; // The backend task ID for polling
|
||||||
progress?: number;
|
error?: string;
|
||||||
|
canRetry?: boolean;
|
||||||
|
|
||||||
|
// --- Single Track Progress ---
|
||||||
|
progress?: number; // 0-100
|
||||||
speed?: string;
|
speed?: string;
|
||||||
size?: string;
|
size?: string;
|
||||||
eta?: string;
|
eta?: string;
|
||||||
error?: string;
|
|
||||||
|
// --- Multi-Track (Album/Playlist) Progress ---
|
||||||
|
currentTrackNumber?: number;
|
||||||
|
totalTracks?: number;
|
||||||
|
summary?: {
|
||||||
|
successful: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
failedTracks?: { name: string; reason: string }[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueueContextType {
|
export interface QueueContextType {
|
||||||
items: QueueItem[];
|
items: QueueItem[];
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
addItem: (item: Omit<QueueItem, 'status'>) => void;
|
addItem: (item: { name: string; type: DownloadType; spotifyId: string }) => void;
|
||||||
removeItem: (id: string) => void;
|
removeItem: (id: string) => void;
|
||||||
|
retryItem: (id: string) => void;
|
||||||
clearQueue: () => void;
|
clearQueue: () => void;
|
||||||
toggleVisibility: () => void;
|
toggleVisibility: () => void;
|
||||||
|
clearCompleted: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const QueueContext = createContext<QueueContextType | undefined>(undefined);
|
export const QueueContext = createContext<QueueContextType | undefined>(undefined);
|
||||||
@@ -28,7 +57,7 @@ export const QueueContext = createContext<QueueContextType | undefined>(undefine
|
|||||||
export function useQueue() {
|
export function useQueue() {
|
||||||
const context = useContext(QueueContext);
|
const context = useContext(QueueContext);
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
throw new Error('useQueue must be used within a QueueProvider');
|
throw new Error("useQueue must be used within a QueueProvider");
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
// This new type reflects the flat structure of the /api/config response
|
// This new type reflects the flat structure of the /api/config response
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
service: 'spotify' | 'deezer';
|
service: "spotify" | "deezer";
|
||||||
spotify: string;
|
spotify: string;
|
||||||
spotifyQuality: 'NORMAL' | 'HIGH' | 'VERY_HIGH';
|
spotifyQuality: "NORMAL" | "HIGH" | "VERY_HIGH";
|
||||||
deezer: string;
|
deezer: string;
|
||||||
deezerQuality: 'MP3_128' | 'MP3_320' | 'FLAC';
|
deezerQuality: "MP3_128" | "MP3_320" | "FLAC";
|
||||||
maxConcurrentDownloads: number;
|
maxConcurrentDownloads: number;
|
||||||
realTime: boolean;
|
realTime: boolean;
|
||||||
fallback: boolean;
|
fallback: boolean;
|
||||||
convertTo: 'MP3' | 'AAC' | 'OGG' | 'OPUS' | 'FLAC' | 'WAV' | 'ALAC' | '';
|
convertTo: "MP3" | "AAC" | "OGG" | "OPUS" | "FLAC" | "WAV" | "ALAC" | "";
|
||||||
bitrate: string;
|
bitrate: string;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
retryDelaySeconds: number;
|
retryDelaySeconds: number;
|
||||||
@@ -48,7 +48,7 @@ export const SettingsContext = createContext<SettingsContextType | undefined>(un
|
|||||||
export function useSettings() {
|
export function useSettings() {
|
||||||
const context = useContext(SettingsContext);
|
const context = useContext(SettingsContext);
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
throw new Error('useSettings must be used within a SettingsProvider');
|
throw new Error("useSettings must be used within a SettingsProvider");
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import axios from 'axios';
|
import axios from "axios";
|
||||||
import { toast } from 'sonner';
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: "/api",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
timeout: 10000, // 10 seconds timeout
|
timeout: 10000, // 10 seconds timeout
|
||||||
});
|
});
|
||||||
@@ -12,30 +12,30 @@ const apiClient = axios.create({
|
|||||||
// Response interceptor for error handling
|
// Response interceptor for error handling
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
const contentType = response.headers['content-type'];
|
const contentType = response.headers["content-type"];
|
||||||
if (contentType && contentType.includes('application/json')) {
|
if (contentType && contentType.includes("application/json")) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
// If the response is not JSON, reject it to trigger the error handling
|
// If the response is not JSON, reject it to trigger the error handling
|
||||||
const error = new Error('Invalid response type. Expected JSON.');
|
const error = new Error("Invalid response type. Expected JSON.");
|
||||||
toast.error('API Error', {
|
toast.error("API Error", {
|
||||||
description: 'Received an invalid response from the server. Expected JSON data.',
|
description: "Received an invalid response from the server. Expected JSON data.",
|
||||||
});
|
});
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.code === 'ECONNABORTED') {
|
if (error.code === "ECONNABORTED") {
|
||||||
toast.error('Request Timed Out', {
|
toast.error("Request Timed Out", {
|
||||||
description: 'The server did not respond in time. Please try again later.',
|
description: "The server did not respond in time. Please try again later.",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const errorMessage = error.response?.data?.error || error.message || 'An unknown error occurred.';
|
const errorMessage = error.response?.data?.error || error.message || "An unknown error occurred.";
|
||||||
toast.error('API Error', {
|
toast.error("API Error", {
|
||||||
description: errorMessage,
|
description: errorMessage,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export default apiClient;
|
export default apiClient;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from "react-dom/client";
|
||||||
import { RouterProvider } from '@tanstack/react-router';
|
import { RouterProvider } from "@tanstack/react-router";
|
||||||
import { router } from './router';
|
import { router } from "./router";
|
||||||
import './index.css';
|
import "./index.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { createRouter, createRootRoute, createRoute } from '@tanstack/react-router';
|
import { createRouter, createRootRoute, createRoute } from "@tanstack/react-router";
|
||||||
import { Root } from './routes/root';
|
import { Root } from "./routes/root";
|
||||||
import { Album } from './routes/album';
|
import { Album } from "./routes/album";
|
||||||
import { Artist } from './routes/artist';
|
import { Artist } from "./routes/artist";
|
||||||
import { Track } from './routes/track';
|
import { Track } from "./routes/track";
|
||||||
import { Home } from './routes/home';
|
import { Home } from "./routes/home";
|
||||||
import { Config } from './routes/config';
|
import { Config } from "./routes/config";
|
||||||
import { Playlist } from './routes/playlist';
|
import { Playlist } from "./routes/playlist";
|
||||||
import { History } from './routes/history';
|
import { History } from "./routes/history";
|
||||||
import { Watchlist } from './routes/watchlist';
|
import { Watchlist } from "./routes/watchlist";
|
||||||
|
|
||||||
const rootRoute = createRootRoute({
|
const rootRoute = createRootRoute({
|
||||||
component: Root,
|
component: Root,
|
||||||
@@ -15,49 +15,49 @@ const rootRoute = createRootRoute({
|
|||||||
|
|
||||||
const indexRoute = createRoute({
|
const indexRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/',
|
path: "/",
|
||||||
component: Home,
|
component: Home,
|
||||||
});
|
});
|
||||||
|
|
||||||
const albumRoute = createRoute({
|
const albumRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/album/$albumId',
|
path: "/album/$albumId",
|
||||||
component: Album,
|
component: Album,
|
||||||
});
|
});
|
||||||
|
|
||||||
const artistRoute = createRoute({
|
const artistRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/artist/$artistId',
|
path: "/artist/$artistId",
|
||||||
component: Artist,
|
component: Artist,
|
||||||
});
|
});
|
||||||
|
|
||||||
const trackRoute = createRoute({
|
const trackRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/track/$trackId',
|
path: "/track/$trackId",
|
||||||
component: Track,
|
component: Track,
|
||||||
});
|
});
|
||||||
|
|
||||||
const configRoute = createRoute({
|
const configRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/config',
|
path: "/config",
|
||||||
component: Config,
|
component: Config,
|
||||||
});
|
});
|
||||||
|
|
||||||
const playlistRoute = createRoute({
|
const playlistRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/playlist/$playlistId',
|
path: "/playlist/$playlistId",
|
||||||
component: Playlist,
|
component: Playlist,
|
||||||
});
|
});
|
||||||
|
|
||||||
const historyRoute = createRoute({
|
const historyRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/history',
|
path: "/history",
|
||||||
component: History,
|
component: History,
|
||||||
});
|
});
|
||||||
|
|
||||||
const watchlistRoute = createRoute({
|
const watchlistRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/watchlist',
|
path: "/watchlist",
|
||||||
component: Watchlist,
|
component: Watchlist,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
|
|
||||||
export const router = createRouter({ routeTree });
|
export const router = createRouter({ routeTree });
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module "@tanstack/react-router" {
|
||||||
interface Register {
|
interface Register {
|
||||||
router: typeof router;
|
router: typeof router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,31 @@
|
|||||||
import { Link, useParams } from '@tanstack/react-router';
|
import { Link, useParams } from "@tanstack/react-router";
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useContext } from "react";
|
||||||
import apiClient from '../lib/api-client';
|
import apiClient from "../lib/api-client";
|
||||||
import { useQueue } from '../contexts/queue-context';
|
import { QueueContext } from "../contexts/queue-context";
|
||||||
import { useSettings } from '../contexts/settings-context';
|
import { useSettings } from "../contexts/settings-context";
|
||||||
import type { AlbumType, TrackType } from '../types/spotify';
|
import type { AlbumType, TrackType } from "../types/spotify";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const Album = () => {
|
export const Album = () => {
|
||||||
const { albumId } = useParams({ from: '/album/$albumId' });
|
const { albumId } = useParams({ from: "/album/$albumId" });
|
||||||
const [album, setAlbum] = useState<AlbumType | null>(null);
|
const [album, setAlbum] = useState<AlbumType | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { addItem, toggleVisibility } = useQueue();
|
const context = useContext(QueueContext);
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useQueue must be used within a QueueProvider");
|
||||||
|
}
|
||||||
|
const { addItem } = context;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchAlbum = async () => {
|
const fetchAlbum = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/album/info?id=${albumId}`);
|
const response = await apiClient.get(`/album/info?id=${albumId}`);
|
||||||
setAlbum(response.data);
|
setAlbum(response.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to load album');
|
setError("Failed to load album");
|
||||||
console.error('Error fetching album:', err);
|
console.error("Error fetching album:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,14 +35,15 @@ export const Album = () => {
|
|||||||
}, [albumId]);
|
}, [albumId]);
|
||||||
|
|
||||||
const handleDownloadTrack = (track: TrackType) => {
|
const handleDownloadTrack = (track: TrackType) => {
|
||||||
addItem({ id: track.id, type: 'track', name: track.name });
|
if (!track.id) return;
|
||||||
toggleVisibility();
|
toast.info(`Adding ${track.name} to queue...`);
|
||||||
|
addItem({ spotifyId: track.id, type: "track", name: track.name });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadAlbum = () => {
|
const handleDownloadAlbum = () => {
|
||||||
if (!album) return;
|
if (!album) return;
|
||||||
addItem({ id: album.id, type: 'album', name: album.name });
|
toast.info(`Adding ${album.name} to queue...`);
|
||||||
toggleVisibility();
|
addItem({ spotifyId: album.id, type: "album", name: album.name });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -59,46 +66,42 @@ export const Album = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasExplicitTrack = album.tracks.items.some(track => track.explicit);
|
const hasExplicitTrack = album.tracks.items.some((track) => track.explicit);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col md:flex-row items-start gap-6">
|
<div className="flex flex-col md:flex-row items-start gap-6">
|
||||||
<img
|
<img
|
||||||
src={album.images[0]?.url || '/placeholder.jpg'}
|
src={album.images[0]?.url || "/placeholder.jpg"}
|
||||||
alt={album.name}
|
alt={album.name}
|
||||||
className="w-48 h-48 object-cover rounded-lg shadow-lg"
|
className="w-48 h-48 object-cover rounded-lg shadow-lg"
|
||||||
/>
|
/>
|
||||||
<div className="flex-grow space-y-2">
|
<div className="flex-grow space-y-2">
|
||||||
<h1 className="text-3xl font-bold">{album.name}</h1>
|
<h1 className="text-3xl font-bold">{album.name}</h1>
|
||||||
<p className="text-lg text-gray-500 dark:text-gray-400">
|
<p className="text-lg text-gray-500 dark:text-gray-400">
|
||||||
By{' '}
|
By{" "}
|
||||||
{album.artists.map((artist, index) => (
|
{album.artists.map((artist, index) => (
|
||||||
<span key={artist.id}>
|
<span key={artist.id}>
|
||||||
<Link
|
<Link to="/artist/$artistId" params={{ artistId: artist.id }} className="hover:underline">
|
||||||
to="/artist/$artistId"
|
|
||||||
params={{ artistId: artist.id }}
|
|
||||||
className="hover:underline"
|
|
||||||
>
|
|
||||||
{artist.name}
|
{artist.name}
|
||||||
</Link>
|
</Link>
|
||||||
{index < album.artists.length - 1 && ', '}
|
{index < album.artists.length - 1 && ", "}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||||
{new Date(album.release_date).getFullYear()} • {album.total_tracks} songs
|
{new Date(album.release_date).getFullYear()} • {album.total_tracks} songs
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-600">
|
<p className="text-xs text-gray-400 dark:text-gray-600">{album.label}</p>
|
||||||
{album.label}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleDownloadAlbum}
|
onClick={handleDownloadAlbum}
|
||||||
disabled={isExplicitFilterEnabled && hasExplicitTrack}
|
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"
|
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'}
|
title={
|
||||||
|
isExplicitFilterEnabled && hasExplicitTrack ? "Album contains explicit tracks" : "Download Full Album"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Download Album
|
Download Album
|
||||||
</button>
|
</button>
|
||||||
@@ -111,14 +114,17 @@ export const Album = () => {
|
|||||||
{album.tracks.items.map((track, index) => {
|
{album.tracks.items.map((track, index) => {
|
||||||
if (isExplicitFilterEnabled && track.explicit) {
|
if (isExplicitFilterEnabled && track.explicit) {
|
||||||
return (
|
return (
|
||||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-100 dark:bg-gray-800 rounded-lg opacity-50">
|
<div
|
||||||
<div className="flex items-center gap-4">
|
key={index}
|
||||||
<span className="text-gray-500 dark:text-gray-400 w-8 text-right">{index + 1}</span>
|
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>
|
<p className="font-medium text-gray-500">Explicit track filtered</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-gray-500">--:--</span>
|
<span className="text-gray-500">--:--</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -131,15 +137,17 @@ export const Album = () => {
|
|||||||
<p className="font-medium">{track.name}</p>
|
<p className="font-medium">{track.name}</p>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{track.artists.map((artist, index) => (
|
{track.artists.map((artist, index) => (
|
||||||
<span key={artist.id}>
|
<span key={artist.id}>
|
||||||
<Link
|
<Link
|
||||||
to="/artist/$artistId"
|
to="/artist/$artistId"
|
||||||
params={{ artistId: artist.id }}
|
params={{
|
||||||
|
artistId: artist.id,
|
||||||
|
}}
|
||||||
className="hover:underline"
|
className="hover:underline"
|
||||||
>
|
>
|
||||||
{artist.name}
|
{artist.name}
|
||||||
</Link>
|
</Link>
|
||||||
{index < track.artists.length - 1 && ', '}
|
{index < track.artists.length - 1 && ", "}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</p>
|
</p>
|
||||||
@@ -148,7 +156,7 @@ export const Album = () => {
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-gray-500 dark:text-gray-400">
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
{Math.floor(track.duration_ms / 60000)}:
|
{Math.floor(track.duration_ms / 60000)}:
|
||||||
{((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, '0')}
|
{((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, "0")}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDownloadTrack(track)}
|
onClick={() => handleDownloadTrack(track)}
|
||||||
@@ -159,10 +167,10 @@ export const Album = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,253 +1,107 @@
|
|||||||
import { Link, useParams } from '@tanstack/react-router';
|
import { Link, useParams } from "@tanstack/react-router";
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useContext } from "react";
|
||||||
import { toast } from 'sonner';
|
import { toast } from "sonner";
|
||||||
import apiClient from '../lib/api-client';
|
import apiClient from "../lib/api-client";
|
||||||
import { useQueue } from '../contexts/queue-context';
|
import type { AlbumType, ArtistType, TrackType } from "../types/spotify";
|
||||||
import type { AlbumType } from '../types/spotify';
|
import { QueueContext } from "../contexts/queue-context";
|
||||||
|
import { useSettings } from "../contexts/settings-context";
|
||||||
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 = () => {
|
export const Artist = () => {
|
||||||
const { artistId } = useParams({ from: '/artist/$artistId' });
|
const { artistId } = useParams({ from: "/artist/$artistId" });
|
||||||
const [artistInfo, setArtistInfo] = useState<ArtistInfo | null>(null);
|
const [artistInfo, setArtistInfo] = useState<{
|
||||||
const [isWatched, setIsWatched] = useState(false);
|
artist: ArtistType;
|
||||||
const [isWatchEnabled, setIsWatchEnabled] = useState(false);
|
top_tracks: TrackType[];
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
albums: AlbumType[];
|
||||||
const { addItem, toggleVisibility } = useQueue();
|
} | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const context = useContext(QueueContext);
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useQueue must be used within a QueueProvider");
|
||||||
|
}
|
||||||
|
const { addItem } = context;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchAllData = async () => {
|
const fetchArtistInfo = async () => {
|
||||||
if (!artistId) return;
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const [infoRes, watchConfigRes, watchStatusRes] = await Promise.all([
|
const response = await apiClient.get(`/artist/info?id=${artistId}`);
|
||||||
apiClient.get<ArtistInfo>(`/artist/info?id=${artistId}`),
|
setArtistInfo(response.data);
|
||||||
apiClient.get('/config/watch'),
|
} catch (err) {
|
||||||
apiClient.get(`/artist/watch/status?id=${artistId}`),
|
setError("Failed to load artist");
|
||||||
]);
|
console.error(err);
|
||||||
|
|
||||||
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();
|
|
||||||
|
if (artistId) {
|
||||||
|
fetchArtistInfo();
|
||||||
|
}
|
||||||
}, [artistId]);
|
}, [artistId]);
|
||||||
|
|
||||||
const handleDownloadTrack = (track: Track) => {
|
const handleDownloadTrack = (track: TrackType) => {
|
||||||
addItem({ id: track.id, type: 'track', name: track.name });
|
if (!track.id) return;
|
||||||
toggleVisibility();
|
toast.info(`Adding ${track.name} to queue...`);
|
||||||
|
addItem({ spotifyId: track.id, type: "track", name: track.name });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadAll = () => {
|
const handleDownloadArtist = () => {
|
||||||
if (!artistId || !artistInfo) return;
|
if (!artistId || !artistInfo) return;
|
||||||
addItem({ id: artistId, type: 'artist', name: artistInfo.artist.name });
|
toast.info(`Adding ${artistInfo.artist.name} to queue...`);
|
||||||
toggleVisibility();
|
addItem({
|
||||||
|
spotifyId: artistId,
|
||||||
|
type: "artist",
|
||||||
|
name: artistInfo.artist.name,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWatch = async () => {
|
if (error) {
|
||||||
if (!artistId) return;
|
return <div className="text-red-500">{error}</div>;
|
||||||
const originalState = isWatched;
|
}
|
||||||
setIsWatched(!originalState); // Optimistic update
|
|
||||||
try {
|
if (!artistInfo) {
|
||||||
await apiClient.post(originalState ? '/artist/unwatch' : '/artist/watch', { artistId });
|
return <div>Loading...</div>;
|
||||||
toast.success(`Artist ${originalState ? 'unwatched' : 'watched'} successfully.`);
|
}
|
||||||
} catch {
|
|
||||||
setIsWatched(originalState); // Revert on error
|
const filteredAlbums = artistInfo.albums.filter((album) => {
|
||||||
|
if (settings?.explicitFilter) {
|
||||||
|
return !album.name.toLowerCase().includes("remix");
|
||||||
}
|
}
|
||||||
};
|
return true;
|
||||||
|
});
|
||||||
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="artist-page">
|
||||||
{/* Artist Header */}
|
<div className="artist-header">
|
||||||
<div className="flex flex-col md:flex-row items-center gap-8">
|
<img src={artistInfo.artist.images[0]?.url} alt={artistInfo.artist.name} className="artist-image" />
|
||||||
<img
|
<h1>{artistInfo.artist.name}</h1>
|
||||||
src={artist.images[0]?.url || '/placeholder.jpg'}
|
<button onClick={handleDownloadArtist} className="download-all-btn">
|
||||||
alt={artist.name}
|
Download All
|
||||||
className="w-48 h-48 rounded-full object-cover shadow-2xl"
|
</button>
|
||||||
/>
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Top Tracks */}
|
<h2>Top Tracks</h2>
|
||||||
<section>
|
<div className="track-list">
|
||||||
<h2 className="text-2xl font-bold mb-4">Top Tracks</h2>
|
{artistInfo.top_tracks.map((track) => (
|
||||||
<div className="space-y-2">
|
<div key={track.id} className="track-item">
|
||||||
{topTracks.map((track) => (
|
<Link to="/track/$trackId" params={{ trackId: track.id }}>
|
||||||
<div key={track.id} className="flex items-center justify-between p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
|
{track.name}
|
||||||
<div className="flex items-center gap-4">
|
</Link>
|
||||||
<img src={track.album.images[2]?.url || '/placeholder.jpg'} alt={track.album.name} className="w-12 h-12 rounded-md" />
|
<button onClick={() => handleDownloadTrack(track)}>Download</button>
|
||||||
<div>
|
</div>
|
||||||
<p className="font-semibold">{track.name}</p>
|
))}
|
||||||
<p className="text-sm text-gray-500">
|
</div>
|
||||||
{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 */}
|
<h2>Albums</h2>
|
||||||
<section>
|
<div className="album-grid">
|
||||||
<h2 className="text-2xl font-bold mb-4">Albums</h2>
|
{filteredAlbums.map((album) => (
|
||||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
<div key={album.id} className="album-card">
|
||||||
{albums.album.map(renderAlbumCard)}
|
<Link to="/album/$albumId" params={{ albumId: album.id }}>
|
||||||
</div>
|
<img src={album.images[0]?.url} alt={album.name} />
|
||||||
</section>
|
<p>{album.name}</p>
|
||||||
|
</Link>
|
||||||
{/* Singles */}
|
</div>
|
||||||
<section>
|
))}
|
||||||
<h2 className="text-2xl font-bold mb-4">Singles & EPs</h2>
|
</div>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { GeneralTab } from '../components/config/GeneralTab';
|
import { GeneralTab } from "../components/config/GeneralTab";
|
||||||
import { DownloadsTab } from '../components/config/DownloadsTab';
|
import { DownloadsTab } from "../components/config/DownloadsTab";
|
||||||
import { FormattingTab } from '../components/config/FormattingTab';
|
import { FormattingTab } from "../components/config/FormattingTab";
|
||||||
import { AccountsTab } from '../components/config/AccountsTab';
|
import { AccountsTab } from "../components/config/AccountsTab";
|
||||||
import { WatchTab } from '../components/config/WatchTab';
|
import { WatchTab } from "../components/config/WatchTab";
|
||||||
import { ServerTab } from '../components/config/ServerTab';
|
import { ServerTab } from "../components/config/ServerTab";
|
||||||
import { useSettings } from '../contexts/settings-context';
|
import { useSettings } from "../contexts/settings-context";
|
||||||
|
|
||||||
const ConfigComponent = () => {
|
const ConfigComponent = () => {
|
||||||
const [activeTab, setActiveTab] = useState('general');
|
const [activeTab, setActiveTab] = useState("general");
|
||||||
|
|
||||||
// Get settings from the context instead of fetching here
|
// Get settings from the context instead of fetching here
|
||||||
const { settings: config, isLoading } = useSettings();
|
const { settings: config, isLoading } = useSettings();
|
||||||
@@ -18,24 +18,23 @@ const ConfigComponent = () => {
|
|||||||
if (!config) return <p className="text-center text-red-500">Error loading configuration.</p>;
|
if (!config) return <p className="text-center text-red-500">Error loading configuration.</p>;
|
||||||
|
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case 'general':
|
case "general":
|
||||||
return <GeneralTab config={config} isLoading={isLoading} />;
|
return <GeneralTab config={config} isLoading={isLoading} />;
|
||||||
case 'downloads':
|
case "downloads":
|
||||||
return <DownloadsTab config={config} isLoading={isLoading} />;
|
return <DownloadsTab config={config} isLoading={isLoading} />;
|
||||||
case 'formatting':
|
case "formatting":
|
||||||
return <FormattingTab config={config} isLoading={isLoading} />;
|
return <FormattingTab config={config} isLoading={isLoading} />;
|
||||||
case 'accounts':
|
case "accounts":
|
||||||
return <AccountsTab />;
|
return <AccountsTab />;
|
||||||
case 'watch':
|
case "watch":
|
||||||
return <WatchTab />;
|
return <WatchTab />;
|
||||||
case 'server':
|
case "server":
|
||||||
return <ServerTab />;
|
return <ServerTab />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -46,26 +45,51 @@ const ConfigComponent = () => {
|
|||||||
<div className="flex gap-8">
|
<div className="flex gap-8">
|
||||||
<aside className="w-1/4">
|
<aside className="w-1/4">
|
||||||
<nav className="flex flex-col space-y-1">
|
<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
|
||||||
<button onClick={() => setActiveTab('downloads')} className={`p-2 rounded-md text-left ${activeTab === 'downloads' ? 'bg-gray-100 dark:bg-gray-800 font-semibold' : ''}`}>Downloads</button>
|
onClick={() => setActiveTab("general")}
|
||||||
<button onClick={() => setActiveTab('formatting')} className={`p-2 rounded-md text-left ${activeTab === 'formatting' ? 'bg-gray-100 dark:bg-gray-800 font-semibold' : ''}`}>Formatting</button>
|
className={`p-2 rounded-md text-left ${activeTab === "general" ? "bg-gray-100 dark:bg-gray-800 font-semibold" : ""}`}
|
||||||
<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>
|
General
|
||||||
<button onClick={() => setActiveTab('server')} className={`p-2 rounded-md text-left ${activeTab === 'server' ? 'bg-gray-100 dark:bg-gray-800 font-semibold' : ''}`}>Server</button>
|
</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>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="w-3/4">
|
<main className="w-3/4">{renderTabContent()}</main>
|
||||||
{renderTabContent()}
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const Config = () => {
|
export const Config = () => {
|
||||||
return (
|
return <ConfigComponent />;
|
||||||
<ConfigComponent />
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import apiClient from '../lib/api-client';
|
import apiClient from "../lib/api-client";
|
||||||
import { toast } from 'sonner';
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
createColumnHelper,
|
createColumnHelper,
|
||||||
flexRender,
|
flexRender,
|
||||||
@@ -8,51 +8,30 @@ import {
|
|||||||
useReactTable,
|
useReactTable,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
} from '@tanstack/react-table';
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
// --- Type Definitions ---
|
// --- Type Definitions ---
|
||||||
type HistoryEntry = {
|
type HistoryEntry = {
|
||||||
|
task_id: string;
|
||||||
item_name: string;
|
item_name: string;
|
||||||
item_artist: string;
|
item_artist: string;
|
||||||
download_type: 'track' | 'album' | 'playlist' | 'artist';
|
download_type: "track" | "album" | "playlist" | "artist";
|
||||||
service_used: string;
|
service_used: string;
|
||||||
quality_profile: string;
|
quality_profile: string;
|
||||||
status_final: 'COMPLETED' | 'ERROR' | 'CANCELLED';
|
convert_to?: string;
|
||||||
|
bitrate?: string;
|
||||||
|
status_final: "COMPLETED" | "ERROR" | "CANCELLED" | "SKIPPED";
|
||||||
timestamp_completed: number;
|
timestamp_completed: number;
|
||||||
error_message?: string;
|
error_message?: string;
|
||||||
|
parent_task_id?: string;
|
||||||
|
track_status?: "SUCCESSFUL" | "SKIPPED" | "FAILED";
|
||||||
|
total_successful?: number;
|
||||||
|
total_skipped?: number;
|
||||||
|
total_failed?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Column Definitions ---
|
// --- Column Definitions ---
|
||||||
const columnHelper = createColumnHelper<HistoryEntry>();
|
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 = () => {
|
export const History = () => {
|
||||||
const [data, setData] = useState<HistoryEntry[]>([]);
|
const [data, setData] = useState<HistoryEntry[]>([]);
|
||||||
@@ -60,39 +39,165 @@ export const History = () => {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// State for TanStack Table
|
// State for TanStack Table
|
||||||
const [sorting, setSorting] = useState<SortingState>([{ id: 'timestamp_completed', desc: true }]);
|
const [sorting, setSorting] = useState<SortingState>([{ id: "timestamp_completed", desc: true }]);
|
||||||
const [{ pageIndex, pageSize }, setPagination] = useState({ pageIndex: 0, pageSize: 25 });
|
const [{ pageIndex, pageSize }, setPagination] = useState({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 25,
|
||||||
|
});
|
||||||
|
|
||||||
// State for filters
|
// State for filters
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
const [typeFilter, setTypeFilter] = useState('');
|
const [typeFilter, setTypeFilter] = useState("");
|
||||||
|
const [trackStatusFilter, setTrackStatusFilter] = useState("");
|
||||||
|
const [hideChildTracks, setHideChildTracks] = useState(true);
|
||||||
|
const [parentTaskId, setParentTaskId] = useState<string | null>(null);
|
||||||
|
|
||||||
const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]);
|
const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]);
|
||||||
|
|
||||||
|
const viewTracksForParent = (taskId: string) => {
|
||||||
|
setParentTaskId(taskId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
columnHelper.accessor("item_name", {
|
||||||
|
header: "Name",
|
||||||
|
cell: (info) =>
|
||||||
|
info.row.original.parent_task_id ? (
|
||||||
|
<span className="pl-4 text-gray-400">└─ {info.getValue()}</span>
|
||||||
|
) : (
|
||||||
|
<span className="font-semibold">{info.getValue()}</span>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("item_artist", { header: "Artist" }),
|
||||||
|
columnHelper.accessor("download_type", {
|
||||||
|
header: "Type",
|
||||||
|
cell: (info) => {
|
||||||
|
const entry = info.row.original;
|
||||||
|
if (entry.parent_task_id && entry.track_status) {
|
||||||
|
const statusClass = {
|
||||||
|
SUCCESSFUL: "text-green-500",
|
||||||
|
SKIPPED: "text-yellow-500",
|
||||||
|
FAILED: "text-red-500",
|
||||||
|
}[entry.track_status];
|
||||||
|
return (
|
||||||
|
<span className={`capitalize font-semibold ${statusClass}`}>{entry.track_status.toLowerCase()}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="capitalize">{info.getValue()}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("quality_profile", {
|
||||||
|
header: "Quality",
|
||||||
|
cell: (info) => {
|
||||||
|
const entry = info.row.original;
|
||||||
|
let qualityDisplay = entry.quality_profile || "N/A";
|
||||||
|
|
||||||
|
if (entry.convert_to && entry.convert_to !== "None") {
|
||||||
|
qualityDisplay = `${entry.convert_to.toUpperCase()}`;
|
||||||
|
if (entry.bitrate && entry.bitrate !== "None") {
|
||||||
|
qualityDisplay += ` ${entry.bitrate}k`;
|
||||||
|
}
|
||||||
|
qualityDisplay += ` (${entry.quality_profile || "Original"})`;
|
||||||
|
} else if (entry.bitrate && entry.bitrate !== "None") {
|
||||||
|
qualityDisplay = `${entry.bitrate}k (${entry.quality_profile || "Profile"})`;
|
||||||
|
}
|
||||||
|
return qualityDisplay;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("status_final", {
|
||||||
|
header: "Status",
|
||||||
|
cell: (info) => {
|
||||||
|
const status = info.getValue();
|
||||||
|
const statusClass = {
|
||||||
|
COMPLETED: "text-green-500",
|
||||||
|
ERROR: "text-red-500",
|
||||||
|
CANCELLED: "text-gray-500",
|
||||||
|
SKIPPED: "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,
|
||||||
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
id: "actions",
|
||||||
|
header: "Actions",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const entry = row.original;
|
||||||
|
if (!entry.parent_task_id && (entry.download_type === "album" || entry.download_type === "playlist")) {
|
||||||
|
const hasChildren =
|
||||||
|
(entry.total_successful ?? 0) > 0 || (entry.total_skipped ?? 0) > 0 || (entry.total_failed ?? 0) > 0;
|
||||||
|
if (hasChildren) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => viewTracksForParent(entry.task_id)} className="text-blue-500 hover:underline">
|
||||||
|
View Tracks
|
||||||
|
</button>
|
||||||
|
<span className="text-xs">
|
||||||
|
<span className="text-green-500">{entry.total_successful ?? 0}</span> /{" "}
|
||||||
|
<span className="text-yellow-500">{entry.total_skipped ?? 0}</span> /{" "}
|
||||||
|
<span className="text-red-500">{entry.total_failed ?? 0}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchHistory = async () => {
|
const fetchHistory = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
limit: `${pageSize}`,
|
limit: `${pageSize}`,
|
||||||
offset: `${pageIndex * pageSize}`,
|
offset: `${pageIndex * pageSize}`,
|
||||||
sort_by: sorting[0]?.id ?? 'timestamp_completed',
|
sort_by: sorting[0]?.id ?? "timestamp_completed",
|
||||||
sort_order: sorting[0]?.desc ? 'DESC' : 'ASC',
|
sort_order: sorting[0]?.desc ? "DESC" : "ASC",
|
||||||
});
|
});
|
||||||
if (statusFilter) params.append('status_final', statusFilter);
|
if (statusFilter) params.append("status_final", statusFilter);
|
||||||
if (typeFilter) params.append('download_type', typeFilter);
|
if (typeFilter) params.append("download_type", typeFilter);
|
||||||
|
if (trackStatusFilter) params.append("track_status", trackStatusFilter);
|
||||||
|
if (hideChildTracks) params.append("hide_child_tracks", "true");
|
||||||
|
if (parentTaskId) params.append("parent_task_id", parentTaskId);
|
||||||
|
|
||||||
const response = await apiClient.get<{ entries: HistoryEntry[], total_count: number }>(`/history?${params.toString()}`);
|
const response = await apiClient.get<{
|
||||||
|
entries: HistoryEntry[];
|
||||||
|
total_count: number;
|
||||||
|
}>(`/history?${params.toString()}`);
|
||||||
setData(response.data.entries);
|
setData(response.data.entries);
|
||||||
setTotalEntries(response.data.total_count);
|
setTotalEntries(response.data.total_count);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to load history.');
|
toast.error("Failed to load history.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchHistory();
|
fetchHistory();
|
||||||
}, [pageIndex, pageSize, sorting, statusFilter, typeFilter]);
|
}, [pageIndex, pageSize, sorting, statusFilter, typeFilter, trackStatusFilter, hideChildTracks, parentTaskId]);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
@@ -107,97 +212,161 @@ export const History = () => {
|
|||||||
manualSorting: true,
|
manualSorting: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setStatusFilter("");
|
||||||
|
setTypeFilter("");
|
||||||
|
setTrackStatusFilter("");
|
||||||
|
setHideChildTracks(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewParentTask = () => {
|
||||||
|
setParentTaskId(null);
|
||||||
|
clearFilters();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-3xl font-bold">Download History</h1>
|
<h1 className="text-3xl font-bold">Download History</h1>
|
||||||
|
{parentTaskId && (
|
||||||
|
<button onClick={viewParentTask} className="text-blue-500 hover:underline">
|
||||||
|
← Back to All History
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filter Controls */}
|
{/* Filter Controls */}
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4 items-center">
|
||||||
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700">
|
<select
|
||||||
<option value="">All Statuses</option>
|
value={statusFilter}
|
||||||
<option value="COMPLETED">Completed</option>
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
<option value="ERROR">Error</option>
|
className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700"
|
||||||
<option value="CANCELLED">Cancelled</option>
|
>
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="COMPLETED">Completed</option>
|
||||||
|
<option value="ERROR">Error</option>
|
||||||
|
<option value="CANCELLED">Cancelled</option>
|
||||||
|
<option value="SKIPPED">Skipped</option>
|
||||||
</select>
|
</select>
|
||||||
<select value={typeFilter} onChange={e => setTypeFilter(e.target.value)} className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700">
|
<select
|
||||||
<option value="">All Types</option>
|
value={typeFilter}
|
||||||
<option value="track">Track</option>
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
<option value="album">Album</option>
|
className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700"
|
||||||
<option value="playlist">Playlist</option>
|
>
|
||||||
<option value="artist">Artist</option>
|
<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>
|
</select>
|
||||||
|
<select
|
||||||
|
value={trackStatusFilter}
|
||||||
|
onChange={(e) => setTrackStatusFilter(e.target.value)}
|
||||||
|
className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<option value="">All Track Statuses</option>
|
||||||
|
<option value="SUCCESSFUL">Successful</option>
|
||||||
|
<option value="SKIPPED">Skipped</option>
|
||||||
|
<option value="FAILED">Failed</option>
|
||||||
|
</select>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" checked={hideChildTracks} onChange={(e) => setHideChildTracks(e.target.checked)} />
|
||||||
|
Hide Child Tracks
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full">
|
<table className="min-w-full">
|
||||||
<thead>
|
<thead>
|
||||||
{table.getHeaderGroups().map(headerGroup => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map(header => (
|
{headerGroup.headers.map((header) => (
|
||||||
<th key={header.id} className="p-2 text-left">
|
<th key={header.id} className="p-2 text-left">
|
||||||
{header.isPlaceholder ? null : (
|
{header.isPlaceholder ? null : (
|
||||||
<div
|
<div
|
||||||
{...{
|
{...{
|
||||||
className: header.column.getCanSort() ? 'cursor-pointer select-none' : '',
|
className: header.column.getCanSort() ? "cursor-pointer select-none" : "",
|
||||||
onClick: header.column.getToggleSortingHandler(),
|
onClick: header.column.getToggleSortingHandler(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
{{ asc: ' ▲', desc: ' ▼'}[header.column.getIsSorted() as string] ?? null}
|
{{ asc: " ▲", desc: " ▼" }[header.column.getIsSorted() as string] ?? null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<tr><td colSpan={columns.length} className="text-center p-4">Loading...</td></tr>
|
<tr>
|
||||||
|
<td colSpan={columns.length} className="text-center p-4">
|
||||||
|
Loading...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
) : table.getRowModel().rows.length === 0 ? (
|
) : table.getRowModel().rows.length === 0 ? (
|
||||||
<tr><td colSpan={columns.length} className="text-center p-4">No history entries found.</td></tr>
|
<tr>
|
||||||
|
<td colSpan={columns.length} className="text-center p-4">
|
||||||
|
No history entries found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
table.getRowModel().rows.map(row => (
|
table.getRowModel().rows.map((row) => {
|
||||||
<tr key={row.id} className="border-b dark:border-gray-700">
|
const isParent =
|
||||||
{row.getVisibleCells().map(cell => (
|
!row.original.parent_task_id &&
|
||||||
<td key={cell.id} className="p-2">
|
(row.original.download_type === "album" || row.original.download_type === "playlist");
|
||||||
|
const isChild = !!row.original.parent_task_id;
|
||||||
|
const rowClass = isParent ? "bg-gray-800 font-semibold" : isChild ? "bg-gray-900" : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={row.id} className={`border-b dark:border-gray-700 ${rowClass}`}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td key={cell.id} className="p-2">
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination Controls */}
|
{/* Pagination Controls */}
|
||||||
<div className="flex items-center justify-between gap-2">
|
<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">
|
<button
|
||||||
Previous
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
className="p-2 border rounded-md disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
</button>
|
</button>
|
||||||
<span>
|
<span>
|
||||||
Page{' '}
|
Page{" "}
|
||||||
<strong>
|
<strong>
|
||||||
{table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
{table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} className="p-2 border rounded-md disabled:opacity-50">
|
<button
|
||||||
Next
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
className="p-2 border rounded-md disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Next
|
||||||
</button>
|
</button>
|
||||||
<select
|
<select
|
||||||
value={table.getState().pagination.pageSize}
|
value={table.getState().pagination.pageSize}
|
||||||
onChange={e => table.setPageSize(Number(e.target.value))}
|
onChange={(e) => table.setPageSize(Number(e.target.value))}
|
||||||
className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700"
|
className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700"
|
||||||
>
|
>
|
||||||
{[10, 25, 50, 100].map(size => (
|
{[10, 25, 50, 100].map((size) => (
|
||||||
<option key={size} value={size}>
|
<option key={size} value={size}>
|
||||||
Show {size}
|
Show {size}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,44 +1,42 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo, useContext, useCallback } from "react";
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from "@tanstack/react-router";
|
||||||
import { useDebounce } from 'use-debounce';
|
import { useDebounce } from "use-debounce";
|
||||||
import apiClient from '../lib/api-client';
|
import apiClient from "../lib/api-client";
|
||||||
import { useQueue } from '../contexts/queue-context';
|
import { toast } from "sonner";
|
||||||
|
import type { TrackType, AlbumType, ArtistType } from "../types/spotify";
|
||||||
|
import { QueueContext } from "../contexts/queue-context";
|
||||||
|
|
||||||
// --- Type Definitions ---
|
type SearchResult = (TrackType | AlbumType | ArtistType) & {
|
||||||
interface Image { url: string; }
|
model: "track" | "album" | "artist";
|
||||||
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;
|
export const Home = () => {
|
||||||
type SearchType = 'artist' | 'album' | 'track' | 'playlist';
|
const [query, setQuery] = useState("");
|
||||||
|
const [searchType, setSearchType] = useState<"track" | "album" | "artist">("track");
|
||||||
// --- Component ---
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
export function Home() {
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
const [searchType, setSearchType] = useState<SearchType>('track');
|
|
||||||
const [results, setResults] = useState<SearchResultItem[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [debouncedQuery] = useDebounce(query, 500);
|
const [debouncedQuery] = useDebounce(query, 500);
|
||||||
const { addItem, toggleVisibility } = useQueue();
|
const context = useContext(QueueContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useQueue must be used within a QueueProvider");
|
||||||
|
}
|
||||||
|
const { addItem } = context;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const performSearch = async () => {
|
const performSearch = async () => {
|
||||||
if (debouncedQuery.trim().length < 2) {
|
if (debouncedQuery.length < 3) {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<{ items: SearchResultItem[] }>('/search', {
|
const response = await apiClient.get<{
|
||||||
params: { q: debouncedQuery, search_type: searchType, limit: 40 },
|
results: SearchResult[];
|
||||||
});
|
}>(`/search?q=${debouncedQuery}&type=${searchType}`);
|
||||||
setResults(response.data.items);
|
setResults(response.data.results);
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Search failed:', error);
|
toast.error("Search failed. Please try again.");
|
||||||
setResults([]);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -46,105 +44,86 @@ export function Home() {
|
|||||||
performSearch();
|
performSearch();
|
||||||
}, [debouncedQuery, searchType]);
|
}, [debouncedQuery, searchType]);
|
||||||
|
|
||||||
const handleDownloadTrack = (track: Track) => {
|
const handleDownloadTrack = useCallback(
|
||||||
addItem({ id: track.id, type: 'track', name: track.name });
|
(track: TrackType) => {
|
||||||
toggleVisibility();
|
addItem({ spotifyId: track.id, type: "track", name: track.name });
|
||||||
};
|
toast.info(`Adding ${track.name} to queue...`);
|
||||||
|
},
|
||||||
|
[addItem],
|
||||||
|
);
|
||||||
|
|
||||||
const renderResult = (item: SearchResultItem) => {
|
const resultComponent = useMemo(() => {
|
||||||
switch (searchType) {
|
switch (searchType) {
|
||||||
case 'track': {
|
case "track":
|
||||||
const track = item as Track;
|
|
||||||
return (
|
return (
|
||||||
<div key={track.id} className="p-2 flex items-center gap-4 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
|
<div className="track-list">
|
||||||
<img src={track.album.images?.[0]?.url || '/placeholder.jpg'} alt={track.album.name} className="w-12 h-12 rounded" />
|
{results.map(
|
||||||
<div className="flex-grow">
|
(item) =>
|
||||||
<p className="font-semibold">{track.name}</p>
|
item.model === "track" && (
|
||||||
<p className="text-sm text-gray-500">{track.artists.map(a => a.name).join(', ')}</p>
|
<div key={item.id} className="track-item">
|
||||||
</div>
|
<Link to="/track/$trackId" params={{ trackId: item.id }}>
|
||||||
<button onClick={() => handleDownloadTrack(track)} className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-full">
|
{item.name}
|
||||||
<img src="/download.svg" alt="Download" className="w-5 h-5" />
|
</Link>
|
||||||
</button>
|
<button onClick={() => handleDownloadTrack(item as TrackType)}>Download</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
case "album":
|
||||||
case 'album': {
|
|
||||||
const album = item as Album;
|
|
||||||
return (
|
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">
|
<div className="album-grid">
|
||||||
<img src={album.images?.[0]?.url || '/placeholder.jpg'} alt={album.name} className="w-full h-auto object-cover rounded shadow-md aspect-square" />
|
{results.map(
|
||||||
<p className="mt-2 font-semibold truncate">{album.name}</p>
|
(item) =>
|
||||||
<p className="text-sm text-gray-500">{album.artists.map(a => a.name).join(', ')}</p>
|
item.model === "album" && (
|
||||||
</Link>
|
<div key={item.id} className="album-card">
|
||||||
|
<Link to="/album/$albumId" params={{ albumId: item.id }}>
|
||||||
|
<img src={(item as AlbumType).images[0]?.url} alt={item.name} />
|
||||||
|
<p>{item.name}</p>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
case "artist":
|
||||||
case 'artist': {
|
|
||||||
const artist = item as Artist;
|
|
||||||
return (
|
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">
|
<div className="artist-list">
|
||||||
<img src={artist.images?.[0]?.url || '/placeholder.jpg'} alt={artist.name} className="w-full h-auto object-cover rounded-full shadow-md aspect-square" />
|
{results.map(
|
||||||
<p className="mt-2 font-semibold truncate">{artist.name}</p>
|
(item) =>
|
||||||
</Link>
|
item.model === "artist" && (
|
||||||
|
<div key={item.id} className="artist-item">
|
||||||
|
<Link to="/artist/$artistId" params={{ artistId: item.id }}>
|
||||||
|
<p>{item.name}</p>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
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:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
}, [results, searchType, handleDownloadTrack]);
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="home-page">
|
||||||
<div className="relative">
|
<h1>Search Spotify</h1>
|
||||||
<img src="/search.svg" alt="" className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
<div className="search-bar">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search for songs, albums, artists..."
|
placeholder="Search for a track, album, or artist"
|
||||||
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
|
<select value={searchType} onChange={(e) => setSearchType(e.target.value as "track" | "album" | "artist")}>
|
||||||
value={searchType}
|
<option value="track">Track</option>
|
||||||
onChange={(e) => setSearchType(e.target.value as SearchType)}
|
<option value="album">Album</option>
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 bg-transparent border-none text-gray-500"
|
<option value="artist">Artist</option>
|
||||||
>
|
|
||||||
<option value="track">Tracks</option>
|
|
||||||
<option value="album">Albums</option>
|
|
||||||
<option value="artist">Artists</option>
|
|
||||||
<option value="playlist">Playlists</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
{isLoading && <p>Loading...</p>}
|
||||||
<div>
|
<div className="search-results">{resultComponent}</div>
|
||||||
{isLoading && <p>Loading...</p>}
|
|
||||||
{!isLoading && debouncedQuery && results.length === 0 && <p>No results found.</p>}
|
|
||||||
<div className={gridClass}>
|
|
||||||
{results.map(renderResult)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,134 +1,106 @@
|
|||||||
import { Link, useParams } from '@tanstack/react-router';
|
import { Link, useParams } from "@tanstack/react-router";
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useContext } from "react";
|
||||||
import apiClient from '../lib/api-client';
|
import apiClient from "../lib/api-client";
|
||||||
import { useQueue } from '../contexts/queue-context';
|
import { useSettings } from "../contexts/settings-context";
|
||||||
import { useSettings } from '../contexts/settings-context';
|
import { toast } from "sonner";
|
||||||
import { toast } from 'sonner';
|
import type { ImageType, TrackType } from "../types/spotify";
|
||||||
import type { ImageType, TrackType } from '../types/spotify';
|
import { QueueContext } from "../contexts/queue-context";
|
||||||
|
|
||||||
// --- Type Definitions ---
|
interface PlaylistItemType {
|
||||||
interface SimplifiedAlbumType {
|
track: TrackType | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaylistType {
|
||||||
id: string;
|
id: string;
|
||||||
name: 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;
|
description: string | null;
|
||||||
images: ImageType[];
|
images: ImageType[];
|
||||||
owner: { display_name?: string };
|
tracks: {
|
||||||
followers?: { total: number };
|
items: PlaylistItemType[];
|
||||||
tracks: { items: PlaylistItemType[]; total: number; };
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Playlist = () => {
|
export const Playlist = () => {
|
||||||
const { playlistId } = useParams({ from: '/playlist/$playlistId' });
|
const { playlistId } = useParams({ from: "/playlist/$playlistId" });
|
||||||
const [playlist, setPlaylist] = useState<PlaylistDetailsType | null>(null);
|
const [playlist, setPlaylist] = useState<PlaylistType | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { addItem, toggleVisibility } = useQueue();
|
const context = useContext(QueueContext);
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useQueue must be used within a QueueProvider");
|
||||||
|
}
|
||||||
|
const { addItem } = context;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPlaylist = async () => {
|
const fetchPlaylist = async () => {
|
||||||
if (!playlistId) return;
|
if (!playlistId) return;
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<PlaylistDetailsType>(`/playlist/info?id=${playlistId}`);
|
const response = await apiClient.get<PlaylistType>(`/playlist/info?id=${playlistId}`);
|
||||||
setPlaylist(response.data);
|
setPlaylist(response.data);
|
||||||
} catch {
|
} catch (err) {
|
||||||
toast.error('Failed to load playlist details.');
|
setError("Failed to load playlist");
|
||||||
} finally {
|
console.error(err);
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchPlaylist();
|
fetchPlaylist();
|
||||||
}, [playlistId]);
|
}, [playlistId]);
|
||||||
|
|
||||||
const handleDownloadTrack = (track: PlaylistTrackType) => {
|
const handleDownloadTrack = (track: TrackType) => {
|
||||||
addItem({ id: track.id, type: 'track', name: track.name });
|
if (!track?.id) return;
|
||||||
toggleVisibility();
|
addItem({ spotifyId: track.id, type: "track", name: track.name });
|
||||||
|
toast.info(`Adding ${track.name} to queue...`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadPlaylist = () => {
|
const handleDownloadPlaylist = () => {
|
||||||
if (!playlist) return;
|
if (!playlist) return;
|
||||||
// This assumes a backend endpoint that can handle a whole playlist download by its ID
|
addItem({
|
||||||
addItem({ id: playlist.id, type: 'playlist', name: playlist.name });
|
spotifyId: playlist.id,
|
||||||
toggleVisibility();
|
type: "playlist",
|
||||||
toast.success(`Queued playlist: ${playlist.name}`);
|
name: playlist.name,
|
||||||
|
});
|
||||||
|
toast.info(`Adding ${playlist.name} to queue...`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="text-red-500">{error}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) return <div>Loading playlist...</div>;
|
if (!playlist) {
|
||||||
if (!playlist) return <div>Playlist not found.</div>;
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
const isExplicitFilterEnabled = settings?.explicitFilter ?? false;
|
const filteredTracks = playlist.tracks.items.filter(({ track }) => {
|
||||||
const hasExplicitTrack = playlist.tracks.items.some(item => item.track?.explicit);
|
if (!track) return false;
|
||||||
|
if (settings?.explicitFilter && track.explicit) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="playlist-page">
|
||||||
<div className="flex flex-col md:flex-row items-start gap-8">
|
<div className="playlist-header">
|
||||||
<img src={playlist.images[0]?.url || '/placeholder.jpg'} alt={playlist.name} className="w-48 h-48 object-cover rounded-lg shadow-lg"/>
|
<img src={playlist.images[0]?.url} alt={playlist.name} className="playlist-image" />
|
||||||
<div className="flex-grow space-y-2">
|
<div>
|
||||||
<h1 className="text-4xl font-bold">{playlist.name}</h1>
|
<h1>{playlist.name}</h1>
|
||||||
<p className="text-gray-500">By {playlist.owner.display_name}</p>
|
<p>{playlist.description}</p>
|
||||||
{playlist.description && <p className="text-sm text-gray-400" dangerouslySetInnerHTML={{ __html: playlist.description }} />}
|
<button onClick={handleDownloadPlaylist} className="download-playlist-btn">
|
||||||
<p className="text-sm text-gray-500">{playlist.followers?.total.toLocaleString()} followers • {playlist.tracks.total} songs</p>
|
Download All
|
||||||
<div className="pt-2">
|
</button>
|
||||||
<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>
|
||||||
|
<div className="track-list">
|
||||||
<div>
|
{filteredTracks.map(({ track }) => {
|
||||||
<div className="flex flex-col">
|
if (!track) return null;
|
||||||
{playlist.tracks.items.map(({ track }, index) => {
|
return (
|
||||||
if (!track) return null; // Handle cases where a track might be unavailable
|
<div key={track.id} className="track-item">
|
||||||
|
<Link to="/track/$trackId" params={{ trackId: track.id }}>
|
||||||
if (isExplicitFilterEnabled && track.explicit) {
|
{track.name}
|
||||||
return (
|
</Link>
|
||||||
<div key={index} className="flex items-center p-3 text-sm bg-gray-100 dark:bg-gray-800 rounded-lg opacity-60">
|
<button onClick={() => handleDownloadTrack(track)}>Download</button>
|
||||||
<span className="w-8 text-gray-500">{index + 1}</span>
|
</div>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Outlet } from '@tanstack/react-router';
|
import { Outlet } from "@tanstack/react-router";
|
||||||
import { QueueProvider } from '../contexts/QueueProvider';
|
import { QueueProvider } from "../contexts/QueueProvider";
|
||||||
import { useQueue } from '../contexts/queue-context';
|
import { useQueue } from "../contexts/queue-context";
|
||||||
import { Queue } from '../components/Queue';
|
import { Queue } from "../components/Queue";
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from "@tanstack/react-router";
|
||||||
import { SettingsProvider } from '../contexts/SettingsProvider';
|
import { SettingsProvider } from "../contexts/SettingsProvider";
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from "sonner";
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
// Create a client
|
// Create a client
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
@@ -17,26 +17,26 @@ function AppLayout() {
|
|||||||
<>
|
<>
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<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">
|
<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">
|
<div className="container mx-auto h-14 flex items-center justify-between">
|
||||||
<Link to="/" className="flex items-center gap-2">
|
<Link to="/" className="flex items-center gap-2">
|
||||||
<img src="/music.svg" alt="Logo" className="w-6 h-6" />
|
<img src="/music.svg" alt="Logo" className="w-6 h-6" />
|
||||||
<h1 className="text-xl font-bold">Spotizerr</h1>
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<Link to="/history" className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||||
<Link to="/watchlist" 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" />
|
||||||
<img src="/binoculars.svg" alt="Watchlist" className="w-6 h-6" />
|
</Link>
|
||||||
</Link>
|
<Link to="/config" className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||||
<Link to="/history" 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" />
|
||||||
<img src="/history.svg" alt="History" className="w-6 h-6" />
|
</Link>
|
||||||
</Link>
|
<button onClick={toggleVisibility} className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||||
<Link to="/config" 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" />
|
||||||
<img src="/settings.svg" alt="Settings" className="w-6 h-6" />
|
</button>
|
||||||
</Link>
|
</div>
|
||||||
<button onClick={toggleVisibility} className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700">
|
</div>
|
||||||
<img src="/queue.svg" alt="Queue" className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -1,92 +1,54 @@
|
|||||||
import { Link, useParams } from '@tanstack/react-router';
|
import { useParams } from "@tanstack/react-router";
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useContext } from "react";
|
||||||
import apiClient from '../lib/api-client';
|
import apiClient from "../lib/api-client";
|
||||||
import { useQueue } from '../contexts/queue-context';
|
import type { TrackType } from "../types/spotify";
|
||||||
import type { TrackType, ImageType } from '../types/spotify';
|
import { toast } from "sonner";
|
||||||
|
import { QueueContext } from "../contexts/queue-context";
|
||||||
interface SimplifiedAlbum {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
images: ImageType[];
|
|
||||||
album_type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TrackDetails extends TrackType {
|
|
||||||
album: SimplifiedAlbum;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Track = () => {
|
export const Track = () => {
|
||||||
const { trackId } = useParams({ from: '/track/$trackId' });
|
const { trackId } = useParams({ from: "/track/$trackId" });
|
||||||
const [track, setTrack] = useState<TrackDetails | null>(null);
|
const [track, setTrack] = useState<TrackType | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { addItem, toggleVisibility } = useQueue();
|
const context = useContext(QueueContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useQueue must be used within a QueueProvider");
|
||||||
|
}
|
||||||
|
const { addItem } = context;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTrack = async () => {
|
const fetchTrack = async () => {
|
||||||
if (!trackId) return;
|
if (!trackId) return;
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<TrackDetails>(`/track/info?id=${trackId}`);
|
const response = await apiClient.get<TrackType>(`/track/info?id=${trackId}`);
|
||||||
setTrack(response.data);
|
setTrack(response.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to load track details.');
|
setError("Failed to load track");
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchTrack();
|
fetchTrack();
|
||||||
}, [trackId]);
|
}, [trackId]);
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownloadTrack = () => {
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
addItem({ id: track.id, type: 'track', name: track.name });
|
addItem({ spotifyId: track.id, type: "track", name: track.name });
|
||||||
toggleVisibility();
|
toast.info(`Adding ${track.name} to queue...`);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (error) return <div className="text-red-500">{error}</div>;
|
if (error) {
|
||||||
if (!track) return <div>Loading...</div>;
|
return <div className="text-red-500">{error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
const minutes = Math.floor(track.duration_ms / 60000);
|
if (!track) {
|
||||||
const seconds = ((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, '0');
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col md:flex-row items-center gap-8 p-4">
|
<div className="track-page">
|
||||||
<img
|
<h1>{track.name}</h1>
|
||||||
src={track.album.images[0]?.url || '/placeholder.jpg'}
|
<p>by {track.artists.map((artist) => artist.name).join(", ")}</p>
|
||||||
alt={track.album.name}
|
<button onClick={handleDownloadTrack}>Download</button>
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import apiClient from '../lib/api-client';
|
import apiClient from "../lib/api-client";
|
||||||
import { toast } from 'sonner';
|
import { toast } from "sonner";
|
||||||
import { useSettings } from '../contexts/settings-context';
|
import { useSettings } from "../contexts/settings-context";
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from "@tanstack/react-router";
|
||||||
|
|
||||||
// --- Type Definitions ---
|
// --- Type Definitions ---
|
||||||
interface Image {
|
interface Image {
|
||||||
@@ -10,7 +10,7 @@ interface Image {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface WatchedArtist {
|
interface WatchedArtist {
|
||||||
itemType: 'artist';
|
itemType: "artist";
|
||||||
spotify_id: string;
|
spotify_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
images?: Image[];
|
images?: Image[];
|
||||||
@@ -18,7 +18,7 @@ interface WatchedArtist {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface WatchedPlaylist {
|
interface WatchedPlaylist {
|
||||||
itemType: 'playlist';
|
itemType: "playlist";
|
||||||
spotify_id: string;
|
spotify_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
images?: Image[];
|
images?: Image[];
|
||||||
@@ -37,16 +37,16 @@ export const Watchlist = () => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const [artistsRes, playlistsRes] = await Promise.all([
|
const [artistsRes, playlistsRes] = await Promise.all([
|
||||||
apiClient.get<Omit<WatchedArtist, 'itemType'>[]>('/artist/watch/list'),
|
apiClient.get<Omit<WatchedArtist, "itemType">[]>("/artist/watch/list"),
|
||||||
apiClient.get<Omit<WatchedPlaylist, 'itemType'>[]>('/playlist/watch/list'),
|
apiClient.get<Omit<WatchedPlaylist, "itemType">[]>("/playlist/watch/list"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const artists: WatchedItem[] = artistsRes.data.map(a => ({ ...a, itemType: 'artist' }));
|
const artists: WatchedItem[] = artistsRes.data.map((a) => ({ ...a, itemType: "artist" }));
|
||||||
const playlists: WatchedItem[] = playlistsRes.data.map(p => ({ ...p, itemType: 'playlist' }));
|
const playlists: WatchedItem[] = playlistsRes.data.map((p) => ({ ...p, itemType: "playlist" }));
|
||||||
|
|
||||||
setItems([...artists, ...playlists]);
|
setItems([...artists, ...playlists]);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to load watchlist.');
|
toast.error("Failed to load watchlist.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -61,35 +61,33 @@ export const Watchlist = () => {
|
|||||||
}, [settings, settingsLoading, fetchWatchlist]);
|
}, [settings, settingsLoading, fetchWatchlist]);
|
||||||
|
|
||||||
const handleUnwatch = async (item: WatchedItem) => {
|
const handleUnwatch = async (item: WatchedItem) => {
|
||||||
toast.promise(
|
toast.promise(apiClient.delete(`/${item.itemType}/watch/${item.spotify_id}`), {
|
||||||
apiClient.delete(`/${item.itemType}/watch/${item.spotify_id}`), {
|
loading: `Unwatching ${item.name}...`,
|
||||||
loading: `Unwatching ${item.name}...`,
|
success: () => {
|
||||||
success: () => {
|
setItems((prev) => prev.filter((i) => i.spotify_id !== item.spotify_id));
|
||||||
setItems(prev => prev.filter(i => i.spotify_id !== item.spotify_id));
|
return `${item.name} has been unwatched.`;
|
||||||
return `${item.name} has been unwatched.`;
|
},
|
||||||
},
|
error: `Failed to unwatch ${item.name}.`,
|
||||||
error: `Failed to unwatch ${item.name}.`
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCheck = async (item: WatchedItem) => {
|
const handleCheck = async (item: WatchedItem) => {
|
||||||
toast.promise(
|
toast.promise(apiClient.post(`/${item.itemType}/watch/trigger_check/${item.spotify_id}`), {
|
||||||
apiClient.post(`/${item.itemType}/watch/trigger_check/${item.spotify_id}`), {
|
loading: `Checking ${item.name} for updates...`,
|
||||||
loading: `Checking ${item.name} for updates...`,
|
success: (res: { data: { message?: string } }) => res.data.message || `Check triggered for ${item.name}.`,
|
||||||
success: (res: { data: { message?: string }}) => res.data.message || `Check triggered for ${item.name}.`,
|
error: `Failed to trigger check for ${item.name}.`,
|
||||||
error: `Failed to trigger check for ${item.name}.`,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCheckAll = () => {
|
const handleCheckAll = () => {
|
||||||
toast.promise(Promise.all([
|
toast.promise(
|
||||||
apiClient.post('/artist/watch/trigger_check'),
|
Promise.all([apiClient.post("/artist/watch/trigger_check"), apiClient.post("/playlist/watch/trigger_check")]),
|
||||||
apiClient.post('/playlist/watch/trigger_check'),
|
{
|
||||||
]), {
|
loading: "Triggering checks for all watched items...",
|
||||||
loading: 'Triggering checks for all watched items...',
|
success: "Successfully triggered checks for all items.",
|
||||||
success: 'Successfully triggered checks for all items.',
|
error: "Failed to trigger one or more checks.",
|
||||||
error: 'Failed to trigger one or more checks.'
|
},
|
||||||
});
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading || settingsLoading) {
|
if (isLoading || settingsLoading) {
|
||||||
@@ -101,7 +99,9 @@ export const Watchlist = () => {
|
|||||||
<div className="text-center p-8">
|
<div className="text-center p-8">
|
||||||
<h2 className="text-2xl font-bold mb-2">Watchlist Disabled</h2>
|
<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>
|
<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>
|
<Link to="/config" className="text-blue-500 hover:underline mt-4 inline-block">
|
||||||
|
Go to Settings
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -120,28 +120,38 @@ export const Watchlist = () => {
|
|||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h1 className="text-3xl font-bold">Watched Artists & Playlists</h1>
|
<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">
|
<button onClick={handleCheckAll} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||||
Check All
|
Check All
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<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 => (
|
{items.map((item) => (
|
||||||
<div key={item.spotify_id} className="bg-card p-4 rounded-lg shadow space-y-2 flex flex-col">
|
<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">
|
<a href={`/${item.itemType}/${item.spotify_id}`} className="flex-grow">
|
||||||
<img
|
<img
|
||||||
src={item.images?.[0]?.url || '/images/placeholder.jpg'}
|
src={item.images?.[0]?.url || "/images/placeholder.jpg"}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
className="w-full h-auto object-cover rounded-md aspect-square"
|
className="w-full h-auto object-cover rounded-md aspect-square"
|
||||||
/>
|
/>
|
||||||
<h3 className="font-bold pt-2 truncate">{item.name}</h3>
|
<h3 className="font-bold pt-2 truncate">{item.name}</h3>
|
||||||
<p className="text-sm text-muted-foreground capitalize">{item.itemType}</p>
|
<p className="text-sm text-muted-foreground capitalize">{item.itemType}</p>
|
||||||
</a>
|
</a>
|
||||||
<div className="flex gap-2 pt-2">
|
<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
|
||||||
<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>
|
onClick={() => handleUnwatch(item)}
|
||||||
</div>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
// This new type reflects the flat structure of the /api/config response
|
// This new type reflects the flat structure of the /api/config response
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
service: 'spotify' | 'deezer';
|
service: "spotify" | "deezer";
|
||||||
spotify: string;
|
spotify: string;
|
||||||
spotifyQuality: 'NORMAL' | 'HIGH' | 'VERY_HIGH';
|
spotifyQuality: "NORMAL" | "HIGH" | "VERY_HIGH";
|
||||||
deezer: string;
|
deezer: string;
|
||||||
deezerQuality: 'MP3_128' | 'MP3_320' | 'FLAC';
|
deezerQuality: "MP3_128" | "MP3_320" | "FLAC";
|
||||||
maxConcurrentDownloads: number;
|
maxConcurrentDownloads: number;
|
||||||
realTime: boolean;
|
realTime: boolean;
|
||||||
fallback: boolean;
|
fallback: boolean;
|
||||||
convertTo: 'MP3' | 'AAC' | 'OGG' | 'OPUS' | 'FLAC' | 'WAV' | 'ALAC' | '';
|
convertTo: "MP3" | "AAC" | "OGG" | "OPUS" | "FLAC" | "WAV" | "ALAC" | "";
|
||||||
bitrate: string;
|
bitrate: string;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
retryDelaySeconds: number;
|
retryDelaySeconds: number;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface ImageType {
|
|||||||
export interface ArtistType {
|
export interface ArtistType {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
images: ImageType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrackType {
|
export interface TrackType {
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
{
|
{
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,26 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from "vite";
|
||||||
import react from '@vitejs/plugin-react'
|
import react from "@vitejs/plugin-react";
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from "url";
|
||||||
import { dirname, resolve } from 'path'
|
import { dirname, resolve } from "path";
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __dirname = dirname(__filename);
|
||||||
const __dirname = dirname(__filename)
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': resolve(__dirname, './src'),
|
"@": resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
"/api": {
|
||||||
target: 'http://localhost:7171',
|
target: "http://localhost:7171",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user