From c3b2233cf59fa324a5589c8bc28f2e7738d077ec Mon Sep 17 00:00:00 2001 From: Mustafa Soylu Date: Sun, 8 Jun 2025 12:39:39 +0200 Subject: [PATCH] complete ui overhaul --- .pre-commit-config.yaml | 16 +- Dockerfile | 29 +- app.py | 60 +- docker-compose.yaml | 4 +- spotizerr-ui/.gitignore | 24 + spotizerr-ui/README.md | 54 + spotizerr-ui/eslint.config.js | 28 + spotizerr-ui/index.html | 13 + spotizerr-ui/package.json | 41 + spotizerr-ui/pnpm-lock.yaml | 2949 +++++++++++++++++ spotizerr-ui/postcss.config.mjs | 5 + .../images => spotizerr-ui/public}/album.svg | 0 .../public}/arrow-left.svg | 0 .../public}/binoculars.svg | 0 .../images => spotizerr-ui/public}/check.svg | 0 .../images => spotizerr-ui/public}/cross.svg | 0 .../public}/download.svg | 0 .../public}/eye-crossed.svg | 0 .../images => spotizerr-ui/public}/eye.svg | 0 .../html => spotizerr-ui/public}/favicon.ico | Bin .../public}/history.svg | 0 .../images => spotizerr-ui/public}/home.svg | 0 .../images => spotizerr-ui/public}/info.svg | 0 .../public}/missing.svg | 0 .../images => spotizerr-ui/public}/music.svg | 0 .../public}/placeholder.jpg | Bin .../public}/plus-circle.svg | 0 .../public}/queue-empty.svg | 0 .../images => spotizerr-ui/public}/queue.svg | 0 .../public}/refresh-cw.svg | 0 .../public}/refresh.svg | 0 .../images => spotizerr-ui/public}/search.svg | 0 .../public}/settings.svg | 0 .../public}/skull-head.svg | 0 .../images => spotizerr-ui/public}/view.svg | 0 spotizerr-ui/src/components/Queue.tsx | 73 + .../src/components/config/AccountsTab.tsx | 150 + .../src/components/config/DownloadsTab.tsx | 143 + .../src/components/config/FormattingTab.tsx | 156 + .../src/components/config/GeneralTab.tsx | 149 + .../src/components/config/ServerTab.tsx | 174 + .../src/components/config/WatchTab.tsx | 121 + spotizerr-ui/src/contexts/QueueProvider.tsx | 117 + .../src/contexts/SettingsProvider.tsx | 118 + spotizerr-ui/src/contexts/queue-context.ts | 34 + spotizerr-ui/src/contexts/settings-context.ts | 54 + spotizerr-ui/src/index.css | 1 + spotizerr-ui/src/lib/api-client.ts | 41 + spotizerr-ui/src/main.tsx | 11 + spotizerr-ui/src/router.tsx | 81 + spotizerr-ui/src/routes/album.tsx | 168 + spotizerr-ui/src/routes/artist.tsx | 253 ++ spotizerr-ui/src/routes/config.tsx | 71 + spotizerr-ui/src/routes/history.tsx | 203 ++ spotizerr-ui/src/routes/home.tsx | 150 + spotizerr-ui/src/routes/playlist.tsx | 134 + spotizerr-ui/src/routes/root.tsx | 61 + spotizerr-ui/src/routes/track.tsx | 92 + spotizerr-ui/src/routes/watchlist.tsx | 147 + spotizerr-ui/src/types/settings.ts | 37 + spotizerr-ui/src/types/spotify.ts | 33 + spotizerr-ui/src/vite-env.d.ts | 1 + spotizerr-ui/tsconfig.app.json | 33 + spotizerr-ui/tsconfig.json | 7 + spotizerr-ui/tsconfig.node.json | 25 + spotizerr-ui/vite.config.ts | 27 + src/js/album.ts | 409 --- src/js/artist.ts | 854 ----- src/js/config.ts | 1240 ------- src/js/history.ts | 188 -- src/js/main.ts | 626 ---- src/js/playlist.ts | 864 ----- src/js/queue.ts | 2755 --------------- src/js/track.ts | 258 -- src/js/watch.ts | 688 ---- static/css/album/album.css | 406 --- static/css/artist/artist.css | 637 ---- static/css/config/config.css | 1014 ------ static/css/history/history.css | 121 - static/css/main/base.css | 530 --- static/css/main/icons.css | 205 -- static/css/main/main.css | 338 -- static/css/playlist/playlist.css | 562 ---- static/css/queue/queue.css | 746 ----- static/css/track/track.css | 360 -- static/css/watch/watch.css | 359 -- static/html/album.html | 72 - static/html/artist.html | 77 - static/html/config.html | 412 --- static/html/history.html | 85 - static/html/main.html | 85 - static/html/playlist.html | 84 - static/html/track.html | 72 - static/html/watch.html | 67 - 94 files changed, 6024 insertions(+), 14178 deletions(-) create mode 100644 spotizerr-ui/.gitignore create mode 100644 spotizerr-ui/README.md create mode 100644 spotizerr-ui/eslint.config.js create mode 100644 spotizerr-ui/index.html create mode 100644 spotizerr-ui/package.json create mode 100644 spotizerr-ui/pnpm-lock.yaml create mode 100644 spotizerr-ui/postcss.config.mjs rename {static/images => spotizerr-ui/public}/album.svg (100%) rename {static/images => spotizerr-ui/public}/arrow-left.svg (100%) rename {static/images => spotizerr-ui/public}/binoculars.svg (100%) rename {static/images => spotizerr-ui/public}/check.svg (100%) rename {static/images => spotizerr-ui/public}/cross.svg (100%) rename {static/images => spotizerr-ui/public}/download.svg (100%) rename {static/images => spotizerr-ui/public}/eye-crossed.svg (100%) rename {static/images => spotizerr-ui/public}/eye.svg (100%) rename {static/html => spotizerr-ui/public}/favicon.ico (100%) rename {static/images => spotizerr-ui/public}/history.svg (100%) rename {static/images => spotizerr-ui/public}/home.svg (100%) rename {static/images => spotizerr-ui/public}/info.svg (100%) rename {static/images => spotizerr-ui/public}/missing.svg (100%) rename {static/images => spotizerr-ui/public}/music.svg (100%) rename {static/images => spotizerr-ui/public}/placeholder.jpg (100%) rename {static/images => spotizerr-ui/public}/plus-circle.svg (100%) rename {static/images => spotizerr-ui/public}/queue-empty.svg (100%) rename {static/images => spotizerr-ui/public}/queue.svg (100%) rename {static/images => spotizerr-ui/public}/refresh-cw.svg (100%) rename {static/images => spotizerr-ui/public}/refresh.svg (100%) rename {static/images => spotizerr-ui/public}/search.svg (100%) rename {static/images => spotizerr-ui/public}/settings.svg (100%) rename {static/images => spotizerr-ui/public}/skull-head.svg (100%) rename {static/images => spotizerr-ui/public}/view.svg (100%) create mode 100644 spotizerr-ui/src/components/Queue.tsx create mode 100644 spotizerr-ui/src/components/config/AccountsTab.tsx create mode 100644 spotizerr-ui/src/components/config/DownloadsTab.tsx create mode 100644 spotizerr-ui/src/components/config/FormattingTab.tsx create mode 100644 spotizerr-ui/src/components/config/GeneralTab.tsx create mode 100644 spotizerr-ui/src/components/config/ServerTab.tsx create mode 100644 spotizerr-ui/src/components/config/WatchTab.tsx create mode 100644 spotizerr-ui/src/contexts/QueueProvider.tsx create mode 100644 spotizerr-ui/src/contexts/SettingsProvider.tsx create mode 100644 spotizerr-ui/src/contexts/queue-context.ts create mode 100644 spotizerr-ui/src/contexts/settings-context.ts create mode 100644 spotizerr-ui/src/index.css create mode 100644 spotizerr-ui/src/lib/api-client.ts create mode 100644 spotizerr-ui/src/main.tsx create mode 100644 spotizerr-ui/src/router.tsx create mode 100644 spotizerr-ui/src/routes/album.tsx create mode 100644 spotizerr-ui/src/routes/artist.tsx create mode 100644 spotizerr-ui/src/routes/config.tsx create mode 100644 spotizerr-ui/src/routes/history.tsx create mode 100644 spotizerr-ui/src/routes/home.tsx create mode 100644 spotizerr-ui/src/routes/playlist.tsx create mode 100644 spotizerr-ui/src/routes/root.tsx create mode 100644 spotizerr-ui/src/routes/track.tsx create mode 100644 spotizerr-ui/src/routes/watchlist.tsx create mode 100644 spotizerr-ui/src/types/settings.ts create mode 100644 spotizerr-ui/src/types/spotify.ts create mode 100644 spotizerr-ui/src/vite-env.d.ts create mode 100644 spotizerr-ui/tsconfig.app.json create mode 100644 spotizerr-ui/tsconfig.json create mode 100644 spotizerr-ui/tsconfig.node.json create mode 100644 spotizerr-ui/vite.config.ts delete mode 100644 src/js/album.ts delete mode 100644 src/js/artist.ts delete mode 100644 src/js/config.ts delete mode 100644 src/js/history.ts delete mode 100644 src/js/main.ts delete mode 100644 src/js/playlist.ts delete mode 100644 src/js/queue.ts delete mode 100644 src/js/track.ts delete mode 100644 src/js/watch.ts delete mode 100644 static/css/album/album.css delete mode 100644 static/css/artist/artist.css delete mode 100644 static/css/config/config.css delete mode 100644 static/css/history/history.css delete mode 100644 static/css/main/base.css delete mode 100644 static/css/main/icons.css delete mode 100644 static/css/main/main.css delete mode 100644 static/css/playlist/playlist.css delete mode 100644 static/css/queue/queue.css delete mode 100644 static/css/track/track.css delete mode 100644 static/css/watch/watch.css delete mode 100644 static/html/album.html delete mode 100644 static/html/artist.html delete mode 100644 static/html/config.html delete mode 100644 static/html/history.html delete mode 100755 static/html/main.html delete mode 100644 static/html/playlist.html delete mode 100644 static/html/track.html delete mode 100644 static/html/watch.html diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b6d23af..068820a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,23 +4,34 @@ repos: rev: v5.0.0 hooks: - id: check-symlinks + exclude: ^spotizerr-ui/ - id: trailing-whitespace + exclude: ^spotizerr-ui/ - id: mixed-line-ending args: [--fix=lf] + exclude: ^spotizerr-ui/ - id: check-yaml - exclude: 'mkdocs.yml' + exclude: 'mkdocs.yml|^spotizerr-ui/' - id: check-toml + exclude: ^spotizerr-ui/ - id: check-json + exclude: ^spotizerr-ui/ - id: check-ast + exclude: ^spotizerr-ui/ - id: debug-statements + exclude: ^spotizerr-ui/ - id: check-merge-conflict + exclude: ^spotizerr-ui/ - id: check-shebang-scripts-are-executable + exclude: ^spotizerr-ui/ - id: check-added-large-files args: [--maxkb=10000] + exclude: ^spotizerr-ui/ - repo: https://github.com/python-jsonschema/check-jsonschema rev: '0.33.0' hooks: - id: check-github-workflows + exclude: ^spotizerr-ui/ - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. rev: v0.11.13 @@ -29,13 +40,16 @@ repos: - id: ruff types_or: [python, pyi, jupyter] args: [--fix] + exclude: ^spotizerr-ui/ # Run the formatter. - id: ruff-format types_or: [python, pyi, jupyter] + exclude: ^spotizerr-ui/ - repo: https://github.com/pre-commit/mirrors-mypy rev: 'v1.16.0' hooks: - id: mypy args: [--no-strict-optional, --ignore-missing-imports] + exclude: ^spotizerr-ui/ # NOTE: you might need to add some deps here: additional_dependencies: [waitress==3.0.2, types-waitress] diff --git a/Dockerfile b/Dockerfile index 6d6a417..99b7d5b 100755 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ FROM python:3.12-slim # Set the working directory in the container WORKDIR /app -# Install system dependencies +# Install system dependencies, including Node.js and npm (for pnpm) RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ gosu \ @@ -15,23 +15,28 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -# Copy requirements file -COPY requirements.txt . +# Install pnpm globally +RUN npm install -g pnpm +# --- Backend Python Dependencies --- +# Copy only the requirements file to leverage Docker cache +COPY requirements.txt . # Install Python dependencies RUN pip install --no-cache-dir -r requirements.txt -# Copy application code +# --- Frontend Node.js Dependencies --- +# Copy package manager files to leverage Docker cache +COPY spotizerr-ui/package.json spotizerr-ui/pnpm-lock.yaml ./spotizerr-ui/ +# Install frontend dependencies +RUN cd spotizerr-ui && pnpm install --frozen-lockfile + +# --- Application Code & Frontend Build --- +# Copy the rest of the application code COPY . . +# Build the frontend application +RUN cd spotizerr-ui && pnpm build -# Install TypeScript globally -RUN npm install -g typescript - -# Compile TypeScript -# tsc will use tsconfig.json from the current directory (/app) -# It will read from /app/src/js and output to /app/static/js -RUN tsc - +# --- Final Container Setup --- # Create necessary directories with proper permissions RUN mkdir -p downloads data/config data/creds data/watch data/history logs/tasks && \ chmod -R 777 downloads data logs diff --git a/app.py b/app.py index 3a54956..13cdedf 100755 --- a/app.py +++ b/app.py @@ -1,4 +1,4 @@ -from flask import Flask, request, send_from_directory, render_template +from flask import Flask, request, send_from_directory from flask_cors import CORS from routes.search import search_bp from routes.credentials import credentials_bp @@ -145,7 +145,7 @@ def check_redis_connection(): def create_app(): - app = Flask(__name__, template_folder="static/html") + app = Flask(__name__, static_folder="spotizerr-ui/dist", static_url_path="/") # Set up CORS CORS(app) @@ -164,54 +164,14 @@ def create_app(): app.register_blueprint(prgs_bp, url_prefix="/api/prgs") app.register_blueprint(history_bp, url_prefix="/api/history") - # Serve frontend - @app.route("/") - def serve_index(): - return render_template("main.html") - - # Config page route - @app.route("/config") - def serve_config(): - return render_template("config.html") - - # New route: Serve watch.html under /watchlist - @app.route("/watchlist") - def serve_watchlist(): - return render_template("watch.html") - - # New route: Serve playlist.html under /playlist/ - @app.route("/playlist/") - def serve_playlist(id): - # The id parameter is captured, but you can use it as needed. - return render_template("playlist.html") - - @app.route("/album/") - def serve_album(id): - # The id parameter is captured, but you can use it as needed. - return render_template("album.html") - - @app.route("/track/") - def serve_track(id): - # The id parameter is captured, but you can use it as needed. - return render_template("track.html") - - @app.route("/artist/") - def serve_artist(id): - # The id parameter is captured, but you can use it as needed. - return render_template("artist.html") - - @app.route("/history") - def serve_history_page(): - return render_template("history.html") - - @app.route("/static/") - def serve_static(path): - return send_from_directory("static", path) - - # Serve favicon.ico from the same directory as index.html (templates) - @app.route("/favicon.ico") - def serve_favicon(): - return send_from_directory("static/html", "favicon.ico") + # Serve React App + @app.route("/", defaults={"path": ""}) + @app.route("/") + def serve_react_app(path): + if path != "" and os.path.exists(os.path.join(app.static_folder, path)): + return send_from_directory(app.static_folder, path) + else: + return send_from_directory(app.static_folder, "index.html") # Add request logging middleware @app.before_request diff --git a/docker-compose.yaml b/docker-compose.yaml index 659b42e..9e9b9ac 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,7 +8,9 @@ services: - ./logs:/app/logs # <-- Volume for persistent logs ports: - 7171:7171 - image: cooldockerizer93/spotizerr + build: + context: . + dockerfile: Dockerfile container_name: spotizerr-app restart: unless-stopped environment: diff --git a/spotizerr-ui/.gitignore b/spotizerr-ui/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/spotizerr-ui/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/spotizerr-ui/README.md b/spotizerr-ui/README.md new file mode 100644 index 0000000..da98444 --- /dev/null +++ b/spotizerr-ui/README.md @@ -0,0 +1,54 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default tseslint.config({ + extends: [ + // Remove ...tseslint.configs.recommended and replace with this + ...tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + ...tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + ...tseslint.configs.stylisticTypeChecked, + ], + languageOptions: { + // other options... + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, +}) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default tseslint.config({ + plugins: { + // Add the react-x and react-dom plugins + 'react-x': reactX, + 'react-dom': reactDom, + }, + rules: { + // other rules... + // Enable its recommended typescript rules + ...reactX.configs['recommended-typescript'].rules, + ...reactDom.configs.recommended.rules, + }, +}) +``` diff --git a/spotizerr-ui/eslint.config.js b/spotizerr-ui/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/spotizerr-ui/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/spotizerr-ui/index.html b/spotizerr-ui/index.html new file mode 100644 index 0000000..675b561 --- /dev/null +++ b/spotizerr-ui/index.html @@ -0,0 +1,13 @@ + + + + + + + Spotizerr + + +
+ + + diff --git a/spotizerr-ui/package.json b/spotizerr-ui/package.json new file mode 100644 index 0000000..6294105 --- /dev/null +++ b/spotizerr-ui/package.json @@ -0,0 +1,41 @@ +{ + "name": "spotizerr-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/postcss": "^4.1.8", + "@tailwindcss/vite": "^4.1.8", + "@tanstack/react-query": "^5.80.6", + "@tanstack/react-router": "^1.120.18", + "@tanstack/react-table": "^8.21.3", + "@tanstack/router-devtools": "^1.120.18", + "axios": "^1.9.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-hook-form": "^7.57.0", + "sonner": "^2.0.5", + "tailwindcss": "^4.1.8", + "use-debounce": "^10.0.5" + }, + "devDependencies": { + "@eslint/js": "^9.25.0", + "@types/node": "^22.15.30", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^4.4.1", + "eslint": "^9.25.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^16.0.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.30.1", + "vite": "^6.3.5" + } +} diff --git a/spotizerr-ui/pnpm-lock.yaml b/spotizerr-ui/pnpm-lock.yaml new file mode 100644 index 0000000..a6e2dc7 --- /dev/null +++ b/spotizerr-ui/pnpm-lock.yaml @@ -0,0 +1,2949 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tailwindcss/postcss': + specifier: ^4.1.8 + version: 4.1.8 + '@tailwindcss/vite': + specifier: ^4.1.8 + version: 4.1.8(vite@6.3.5(@types/node@22.15.30)(jiti@2.4.2)(lightningcss@1.30.1)) + '@tanstack/react-query': + specifier: ^5.80.6 + version: 5.80.6(react@19.1.0) + '@tanstack/react-router': + specifier: ^1.120.18 + version: 1.120.18(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/router-devtools': + specifier: ^1.120.18 + version: 1.120.18(@tanstack/react-router@1.120.18(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@tanstack/router-core@1.120.17)(csstype@3.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tiny-invariant@1.3.3) + axios: + specifier: ^1.9.0 + version: 1.9.0 + react: + specifier: ^19.1.0 + version: 19.1.0 + react-dom: + specifier: ^19.1.0 + version: 19.1.0(react@19.1.0) + react-hook-form: + specifier: ^7.57.0 + version: 7.57.0(react@19.1.0) + sonner: + specifier: ^2.0.5 + version: 2.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + tailwindcss: + specifier: ^4.1.8 + version: 4.1.8 + use-debounce: + specifier: ^10.0.5 + version: 10.0.5(react@19.1.0) + devDependencies: + '@eslint/js': + specifier: ^9.25.0 + version: 9.28.0 + '@types/node': + specifier: ^22.15.30 + version: 22.15.30 + '@types/react': + specifier: ^19.1.2 + version: 19.1.6 + '@types/react-dom': + specifier: ^19.1.2 + version: 19.1.6(@types/react@19.1.6) + '@vitejs/plugin-react': + specifier: ^4.4.1 + version: 4.5.1(vite@6.3.5(@types/node@22.15.30)(jiti@2.4.2)(lightningcss@1.30.1)) + eslint: + specifier: ^9.25.0 + version: 9.28.0(jiti@2.4.2) + eslint-plugin-react-hooks: + specifier: ^5.2.0 + version: 5.2.0(eslint@9.28.0(jiti@2.4.2)) + eslint-plugin-react-refresh: + specifier: ^0.4.19 + version: 0.4.20(eslint@9.28.0(jiti@2.4.2)) + globals: + specifier: ^16.0.0 + version: 16.2.0 + typescript: + specifier: ~5.8.3 + version: 5.8.3 + typescript-eslint: + specifier: ^8.30.1 + version: 8.33.1(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) + vite: + specifier: ^6.3.5 + version: 6.3.5(@types/node@22.15.30)(jiti@2.4.2)(lightningcss@1.30.1) + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.27.5': + resolution: {integrity: sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.27.4': + resolution: {integrity: sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.27.5': + resolution: {integrity: sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.27.3': + resolution: {integrity: sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.27.1': + resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.27.6': + resolution: {integrity: sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.27.5': + resolution: {integrity: sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.27.4': + resolution: {integrity: sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.27.6': + resolution: {integrity: sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.25.5': + resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.5': + resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.5': + resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.5': + resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.5': + resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.5': + resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.5': + resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.5': + resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.5': + resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.5': + resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.5': + resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.5': + resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.5': + resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.5': + resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.5': + resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.5': + resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.5': + resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.5': + resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.5': + resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.5': + resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.5': + resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.25.5': + resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.5': + resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.5': + resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.5': + resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.7.0': + resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.1': + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.20.0': + resolution: {integrity: sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.2.2': + resolution: {integrity: sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.14.0': + resolution: {integrity: sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.1': + resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.28.0': + resolution: {integrity: sha512-fnqSjGWd/CoIp4EXIxWVK/sHA6DOHN4+8Ix2cX5ycOY7LG0UY8nHCU5pIp2eaE1Mc7Qd8kHspYNzYXT2ojPLzg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.6': + resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.3.1': + resolution: {integrity: sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.6': + resolution: {integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.3.1': + resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==} + engines: {node: '>=18.18'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@jridgewell/gen-mapping@0.3.8': + resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@rolldown/pluginutils@1.0.0-beta.9': + resolution: {integrity: sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==} + + '@rollup/rollup-android-arm-eabi@4.42.0': + resolution: {integrity: sha512-gldmAyS9hpj+H6LpRNlcjQWbuKUtb94lodB9uCz71Jm+7BxK1VIOo7y62tZZwxhA7j1ylv/yQz080L5WkS+LoQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.42.0': + resolution: {integrity: sha512-bpRipfTgmGFdCZDFLRvIkSNO1/3RGS74aWkJJTFJBH7h3MRV4UijkaEUeOMbi9wxtxYmtAbVcnMtHTPBhLEkaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.42.0': + resolution: {integrity: sha512-JxHtA081izPBVCHLKnl6GEA0w3920mlJPLh89NojpU2GsBSB6ypu4erFg/Wx1qbpUbepn0jY4dVWMGZM8gplgA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.42.0': + resolution: {integrity: sha512-rv5UZaWVIJTDMyQ3dCEK+m0SAn6G7H3PRc2AZmExvbDvtaDc+qXkei0knQWcI3+c9tEs7iL/4I4pTQoPbNL2SA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.42.0': + resolution: {integrity: sha512-fJcN4uSGPWdpVmvLuMtALUFwCHgb2XiQjuECkHT3lWLZhSQ3MBQ9pq+WoWeJq2PrNxr9rPM1Qx+IjyGj8/c6zQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.42.0': + resolution: {integrity: sha512-CziHfyzpp8hJpCVE/ZdTizw58gr+m7Y2Xq5VOuCSrZR++th2xWAz4Nqk52MoIIrV3JHtVBhbBsJcAxs6NammOQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.42.0': + resolution: {integrity: sha512-UsQD5fyLWm2Fe5CDM7VPYAo+UC7+2Px4Y+N3AcPh/LdZu23YcuGPegQly++XEVaC8XUTFVPscl5y5Cl1twEI4A==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.42.0': + resolution: {integrity: sha512-/i8NIrlgc/+4n1lnoWl1zgH7Uo0XK5xK3EDqVTf38KvyYgCU/Rm04+o1VvvzJZnVS5/cWSd07owkzcVasgfIkQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.42.0': + resolution: {integrity: sha512-eoujJFOvoIBjZEi9hJnXAbWg+Vo1Ov8n/0IKZZcPZ7JhBzxh2A+2NFyeMZIRkY9iwBvSjloKgcvnjTbGKHE44Q==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.42.0': + resolution: {integrity: sha512-/3NrcOWFSR7RQUQIuZQChLND36aTU9IYE4j+TB40VU78S+RA0IiqHR30oSh6P1S9f9/wVOenHQnacs/Byb824g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loongarch64-gnu@4.42.0': + resolution: {integrity: sha512-O8AplvIeavK5ABmZlKBq9/STdZlnQo7Sle0LLhVA7QT+CiGpNVe197/t8Aph9bhJqbDVGCHpY2i7QyfEDDStDg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-powerpc64le-gnu@4.42.0': + resolution: {integrity: sha512-6Qb66tbKVN7VyQrekhEzbHRxXXFFD8QKiFAwX5v9Xt6FiJ3BnCVBuyBxa2fkFGqxOCSGGYNejxd8ht+q5SnmtA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.42.0': + resolution: {integrity: sha512-KQETDSEBamQFvg/d8jajtRwLNBlGc3aKpaGiP/LvEbnmVUKlFta1vqJqTrvPtsYsfbE/DLg5CC9zyXRX3fnBiA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.42.0': + resolution: {integrity: sha512-qMvnyjcU37sCo/tuC+JqeDKSuukGAd+pVlRl/oyDbkvPJ3awk6G6ua7tyum02O3lI+fio+eM5wsVd66X0jQtxw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.42.0': + resolution: {integrity: sha512-I2Y1ZUgTgU2RLddUHXTIgyrdOwljjkmcZ/VilvaEumtS3Fkuhbw4p4hgHc39Ypwvo2o7sBFNl2MquNvGCa55Iw==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.42.0': + resolution: {integrity: sha512-Gfm6cV6mj3hCUY8TqWa63DB8Mx3NADoFwiJrMpoZ1uESbK8FQV3LXkhfry+8bOniq9pqY1OdsjFWNsSbfjPugw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.42.0': + resolution: {integrity: sha512-g86PF8YZ9GRqkdi0VoGlcDUb4rYtQKyTD1IVtxxN4Hpe7YqLBShA7oHMKU6oKTCi3uxwW4VkIGnOaH/El8de3w==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-win32-arm64-msvc@4.42.0': + resolution: {integrity: sha512-+axkdyDGSp6hjyzQ5m1pgcvQScfHnMCcsXkx8pTgy/6qBmWVhtRVlgxjWwDp67wEXXUr0x+vD6tp5W4x6V7u1A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.42.0': + resolution: {integrity: sha512-F+5J9pelstXKwRSDq92J0TEBXn2nfUrQGg+HK1+Tk7VOL09e0gBqUHugZv7SW4MGrYj41oNCUe3IKCDGVlis2g==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.42.0': + resolution: {integrity: sha512-LpHiJRwkaVz/LqjHjK8LCi8osq7elmpwujwbXKNW88bM8eeGxavJIKKjkjpMHAh/2xfnrt1ZSnhTv41WYUHYmA==} + cpu: [x64] + os: [win32] + + '@tailwindcss/node@4.1.8': + resolution: {integrity: sha512-OWwBsbC9BFAJelmnNcrKuf+bka2ZxCE2A4Ft53Tkg4uoiE67r/PMEYwCsourC26E+kmxfwE0hVzMdxqeW+xu7Q==} + + '@tailwindcss/oxide-android-arm64@4.1.8': + resolution: {integrity: sha512-Fbz7qni62uKYceWYvUjRqhGfZKwhZDQhlrJKGtnZfuNtHFqa8wmr+Wn74CTWERiW2hn3mN5gTpOoxWKk0jRxjg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.1.8': + resolution: {integrity: sha512-RdRvedGsT0vwVVDztvyXhKpsU2ark/BjgG0huo4+2BluxdXo8NDgzl77qh0T1nUxmM11eXwR8jA39ibvSTbi7A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.1.8': + resolution: {integrity: sha512-t6PgxjEMLp5Ovf7uMb2OFmb3kqzVTPPakWpBIFzppk4JE4ix0yEtbtSjPbU8+PZETpaYMtXvss2Sdkx8Vs4XRw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.1.8': + resolution: {integrity: sha512-g8C8eGEyhHTqwPStSwZNSrOlyx0bhK/V/+zX0Y+n7DoRUzyS8eMbVshVOLJTDDC+Qn9IJnilYbIKzpB9n4aBsg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.8': + resolution: {integrity: sha512-Jmzr3FA4S2tHhaC6yCjac3rGf7hG9R6Gf2z9i9JFcuyy0u79HfQsh/thifbYTF2ic82KJovKKkIB6Z9TdNhCXQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.8': + resolution: {integrity: sha512-qq7jXtO1+UEtCmCeBBIRDrPFIVI4ilEQ97qgBGdwXAARrUqSn/L9fUrkb1XP/mvVtoVeR2bt/0L77xx53bPZ/Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.1.8': + resolution: {integrity: sha512-O6b8QesPbJCRshsNApsOIpzKt3ztG35gfX9tEf4arD7mwNinsoCKxkj8TgEE0YRjmjtO3r9FlJnT/ENd9EVefQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.1.8': + resolution: {integrity: sha512-32iEXX/pXwikshNOGnERAFwFSfiltmijMIAbUhnNyjFr3tmWmMJWQKU2vNcFX0DACSXJ3ZWcSkzNbaKTdngH6g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.1.8': + resolution: {integrity: sha512-s+VSSD+TfZeMEsCaFaHTaY5YNj3Dri8rST09gMvYQKwPphacRG7wbuQ5ZJMIJXN/puxPcg/nU+ucvWguPpvBDg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.1.8': + resolution: {integrity: sha512-CXBPVFkpDjM67sS1psWohZ6g/2/cd+cq56vPxK4JeawelxwK4YECgl9Y9TjkE2qfF+9/s1tHHJqrC4SS6cVvSg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.8': + resolution: {integrity: sha512-7GmYk1n28teDHUjPlIx4Z6Z4hHEgvP5ZW2QS9ygnDAdI/myh3HTHjDqtSqgu1BpRoI4OiLx+fThAyA1JePoENA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.1.8': + resolution: {integrity: sha512-fou+U20j+Jl0EHwK92spoWISON2OBnCazIc038Xj2TdweYV33ZRkS9nwqiUi2d/Wba5xg5UoHfvynnb/UB49cQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.1.8': + resolution: {integrity: sha512-d7qvv9PsM5N3VNKhwVUhpK6r4h9wtLkJ6lz9ZY9aeZgrUWk1Z8VPyqyDT9MZlem7GTGseRQHkeB1j3tC7W1P+A==} + engines: {node: '>= 10'} + + '@tailwindcss/postcss@4.1.8': + resolution: {integrity: sha512-vB/vlf7rIky+w94aWMw34bWW1ka6g6C3xIOdICKX2GC0VcLtL6fhlLiafF0DVIwa9V6EHz8kbWMkS2s2QvvNlw==} + + '@tailwindcss/vite@4.1.8': + resolution: {integrity: sha512-CQ+I8yxNV5/6uGaJjiuymgw0kEQiNKRinYbZXPdx1fk5WgiyReG0VaUx/Xq6aVNSUNJFzxm6o8FNKS5aMaim5A==} + peerDependencies: + vite: ^5.2.0 || ^6 + + '@tanstack/history@1.120.17': + resolution: {integrity: sha512-k07LFI4Qo074IIaWzT/XjD0KlkGx2w1V3fnNtclKx0oAl8z4O9kCh6za+FPEIRe98xLgNFEiddDbJeAYGSlPtw==} + engines: {node: '>=12'} + + '@tanstack/query-core@5.80.6': + resolution: {integrity: sha512-nl7YxT/TAU+VTf+e2zTkObGTyY8YZBMnbgeA1ee66lIVqzKlYursAII6z5t0e6rXgwUMJSV4dshBTNacNpZHbQ==} + + '@tanstack/react-query@5.80.6': + resolution: {integrity: sha512-izX+5CnkpON3NQGcEm3/d7LfFQNo9ZpFtX2QsINgCYK9LT2VCIdi8D3bMaMSNhrAJCznRoAkFic76uvLroALBw==} + peerDependencies: + react: ^18 || ^19 + + '@tanstack/react-router-devtools@1.120.18': + resolution: {integrity: sha512-iYz1jp2AJG0a+FiyxKqpg44RM9KiVzChD57RsZTQylbwjPBF+dpP1GHhSwKPS99JgA10VhsB2moo3wc7wAsmvg==} + engines: {node: '>=12'} + peerDependencies: + '@tanstack/react-router': ^1.120.18 + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-router@1.120.18': + resolution: {integrity: sha512-VXEP4L7We0XGKv1zRmttTbjPZfDXma6y9IV5Jo2yTrz4V4mhL4cPb7Do+shgW08dW16nJ0Yiy7VlxFd5P8LLeA==} + engines: {node: '>=12'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.7.1': + resolution: {integrity: sha512-qUTEKdId6QPWGiWyKAPf/gkN29scEsz6EUSJ0C3HgLMgaqTAyBsQ2sMCfGVcqb+kkhEXAdjleCgH6LAPD6f2sA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + '@tanstack/router-core@1.120.17': + resolution: {integrity: sha512-AqNr/rJKua/q/BFcUJTFH1YNUWcLF7dzsRVlN+EdRyvGT4Vpz0wip2wroO5N/9A3463NIuLSacjiZI9UKO/4XQ==} + engines: {node: '>=12'} + + '@tanstack/router-devtools-core@1.120.17': + resolution: {integrity: sha512-Ci2OV/hecsKy/ZXqK3gLS/h1Qg7OEl2Gy9c76iSYWIzroQ9nhKgsQ+qj+LxucImztWujNINXf23wkq5fVlR0EQ==} + engines: {node: '>=12'} + peerDependencies: + '@tanstack/router-core': ^1.120.17 + csstype: ^3.0.10 + solid-js: '>=1.9.5' + tiny-invariant: ^1.3.3 + peerDependenciesMeta: + csstype: + optional: true + + '@tanstack/router-devtools@1.120.18': + resolution: {integrity: sha512-9P74erkZQprhMIJftzein35WS0Fi2J+jOvNaaylX59k+2wjahYdSImIkICAEGmj2K5C51QsPFUrikYVTT591LA==} + engines: {node: '>=12'} + peerDependencies: + '@tanstack/react-router': ^1.120.18 + csstype: ^3.0.10 + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + peerDependenciesMeta: + csstype: + optional: true + + '@tanstack/store@0.7.1': + resolution: {integrity: sha512-PjUQKXEXhLYj2X5/6c1Xn/0/qKY0IVFxTJweopRfF26xfjVyb14yALydJrHupDh3/d+1WKmfEgZPBVCmDkzzwg==} + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.20.7': + resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + + '@types/estree@1.0.7': + resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@22.15.30': + resolution: {integrity: sha512-6Q7lr06bEHdlfplU6YRbgG1SFBdlsfNC4/lX+SkhiTs0cpJkOElmWls8PxDFv4yY/xKb8Y6SO0OmSX4wgqTZbA==} + + '@types/react-dom@19.1.6': + resolution: {integrity: sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==} + peerDependencies: + '@types/react': ^19.0.0 + + '@types/react@19.1.6': + resolution: {integrity: sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q==} + + '@typescript-eslint/eslint-plugin@8.33.1': + resolution: {integrity: sha512-TDCXj+YxLgtvxvFlAvpoRv9MAncDLBV2oT9Bd7YBGC/b/sEURoOYuIwLI99rjWOfY3QtDzO+mk0n4AmdFExW8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.33.1 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/parser@8.33.1': + resolution: {integrity: sha512-qwxv6dq682yVvgKKp2qWwLgRbscDAYktPptK4JPojCwwi3R9cwrvIxS4lvBpzmcqzR4bdn54Z0IG1uHFskW4dA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/project-service@8.33.1': + resolution: {integrity: sha512-DZR0efeNklDIHHGRpMpR5gJITQpu6tLr9lDJnKdONTC7vvzOlLAG/wcfxcdxEWrbiZApcoBCzXqU/Z458Za5Iw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/scope-manager@8.33.1': + resolution: {integrity: sha512-dM4UBtgmzHR9bS0Rv09JST0RcHYearoEoo3pG5B6GoTR9XcyeqX87FEhPo+5kTvVfKCvfHaHrcgeJQc6mrDKrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.33.1': + resolution: {integrity: sha512-STAQsGYbHCF0/e+ShUQ4EatXQ7ceh3fBCXkNU7/MZVKulrlq1usH7t2FhxvCpuCi5O5oi1vmVaAjrGeL71OK1g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/type-utils@8.33.1': + resolution: {integrity: sha512-1cG37d9xOkhlykom55WVwG2QRNC7YXlxMaMzqw2uPeJixBFfKWZgaP/hjAObqMN/u3fr5BrTwTnc31/L9jQ2ww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/types@8.33.1': + resolution: {integrity: sha512-xid1WfizGhy/TKMTwhtVOgalHwPtV8T32MS9MaH50Cwvz6x6YqRIPdD2WvW0XaqOzTV9p5xdLY0h/ZusU5Lokg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.33.1': + resolution: {integrity: sha512-+s9LYcT8LWjdYWu7IWs7FvUxpQ/DGkdjZeE/GGulHvv8rvYwQvVaUZ6DE+j5x/prADUgSbbCWZ2nPI3usuVeOA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/utils@8.33.1': + resolution: {integrity: sha512-52HaBiEQUaRYqAXpfzWSR2U3gxk92Kw006+xZpElaPMg3C4PgM+A5LqwoQI1f9E5aZ/qlxAZxzm42WX+vn92SQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/visitor-keys@8.33.1': + resolution: {integrity: sha512-3i8NrFcZeeDHJ+7ZUuDkGT+UHq+XoFGsymNK2jZCOHcfEzRQ0BdpRtdpSx/Iyf3MHLWIcLS0COuOPibKQboIiQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@4.5.1': + resolution: {integrity: sha512-uPZBqSI0YD4lpkIru6M35sIfylLGTyhGHvDZbNLuMA73lMlwJKz5xweH7FajfcCAc2HnINciejA9qTz0dr0M7A==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.14.1: + resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.9.0: + resolution: {integrity: sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.25.0: + resolution: {integrity: sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001721: + resolution: {integrity: sha512-cOuvmUVtKrtEaoKiO0rSc29jcjwMwX5tOHDy4MgVFEWiUXj4uBMJkwI8MDySkgXidpMiHUcviogAvFi4pA2hDQ==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + debug@4.4.1: + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@2.0.4: + resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.165: + resolution: {integrity: sha512-naiMx1Z6Nb2TxPU6fiFrUrDTjyPMLdTtaOd2oLmG8zVSg2hCWGkhPyxwk+qRmZ1ytwVqUv0u7ZcDA5+ALhaUtw==} + + enhanced-resolve@5.18.1: + resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} + engines: {node: '>=10.13.0'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.25.5: + resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@5.2.0: + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react-refresh@0.4.20: + resolution: {integrity: sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==} + peerDependencies: + eslint: '>=8.40' + + eslint-scope@8.3.0: + resolution: {integrity: sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.0: + resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.28.0: + resolution: {integrity: sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.3.0: + resolution: {integrity: sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fdir@6.4.5: + resolution: {integrity: sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.3: + resolution: {integrity: sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.2.0: + resolution: {integrity: sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg==} + engines: {node: '>=18'} + + goober@2.1.16: + resolution: {integrity: sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==} + peerDependencies: + csstype: ^3.0.10 + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.4.2: + resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-darwin-arm64@1.30.1: + resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.1: + resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.1: + resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.1: + resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.1: + resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.1: + resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.1: + resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.1: + resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.1: + resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.1: + resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.1: + resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.0.2: + resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==} + engines: {node: '>= 18'} + + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.19: + resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.2: + resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + engines: {node: '>=12'} + + postcss@8.5.4: + resolution: {integrity: sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.1.0: + resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} + peerDependencies: + react: ^19.1.0 + + react-hook-form@7.57.0: + resolution: {integrity: sha512-RbEks3+cbvTP84l/VXGUZ+JMrKOS8ykQCRYdm5aYsxnDquL0vspsyNhGRO7pcH6hsZqWlPOjLye7rJqdtdAmlg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@19.1.0: + resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.42.0: + resolution: {integrity: sha512-LW+Vse3BJPyGJGAJt1j8pWDKPd73QM8cRXYK1IxOBgL2AGLu7Xd2YOW0M2sLUBCkF5MshXXtMApyEAEzMVMsnw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + scheduler@0.26.0: + resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + + seroval-plugins@1.3.2: + resolution: {integrity: sha512-0QvCV2lM3aj/U3YozDiVwx9zpH0q8A60CTWIv4Jszj/givcudPb48B+rkU5D51NJ0pTpweGMttHjboPa9/zoIQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.3.2: + resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} + engines: {node: '>=10'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + solid-js@1.9.7: + resolution: {integrity: sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==} + + sonner@2.0.5: + resolution: {integrity: sha512-YwbHQO6cSso3HBXlbCkgrgzDNIhws14r4MO87Ofy+cV2X7ES4pOoAK3+veSmVTvqNx1BWUxlhPmZzP00Crk2aQ==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tailwindcss@4.1.8: + resolution: {integrity: sha512-kjeW8gjdxasbmFKpVGrGd5T4i40mV5J2Rasw48QARfYeQ8YS9x02ON9SFWax3Qf616rt4Cp3nVNIj6Hd1mP3og==} + + tapable@2.2.2: + resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==} + engines: {node: '>=6'} + + tar@7.4.3: + resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} + engines: {node: '>=18'} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tiny-warning@1.0.3: + resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + + tinyglobby@0.2.14: + resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@2.1.0: + resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.33.1: + resolution: {integrity: sha512-AgRnV4sKkWOiZ0Kjbnf5ytTJXMUZQ0qhSVdQtDNYLPLnjsATEYhaO94GlRQwi4t4gO8FfjM6NnikHeKjUm8D7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.9.0' + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + update-browserslist-db@1.1.3: + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-debounce@10.0.5: + resolution: {integrity: sha512-Q76E3lnIV+4YT9AHcrHEHYmAd9LKwUAbPXDm7FlqVGDHiSOhX3RDjT8dm0AxbJup6WgOb1YEcKyCr11kBJR5KQ==} + engines: {node: '>= 16.0.0'} + peerDependencies: + react: '*' + + use-sync-external-store@1.5.0: + resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vite@6.3.5: + resolution: {integrity: sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.27.1 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.27.5': {} + + '@babel/core@7.27.4': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.27.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.27.3(@babel/core@7.27.4) + '@babel/helpers': 7.27.6 + '@babel/parser': 7.27.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.27.4 + '@babel/types': 7.27.6 + convert-source-map: 2.0.0 + debug: 4.4.1 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.27.5': + dependencies: + '@babel/parser': 7.27.5 + '@babel/types': 7.27.6 + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.27.5 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.25.0 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.27.4 + '@babel/types': 7.27.6 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.27.3(@babel/core@7.27.4)': + dependencies: + '@babel/core': 7.27.4 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + '@babel/traverse': 7.27.4 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.27.1': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.27.6': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.27.6 + + '@babel/parser@7.27.5': + dependencies: + '@babel/types': 7.27.6 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.27.4)': + dependencies: + '@babel/core': 7.27.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.27.4)': + dependencies: + '@babel/core': 7.27.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.27.5 + '@babel/types': 7.27.6 + + '@babel/traverse@7.27.4': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.27.5 + '@babel/parser': 7.27.5 + '@babel/template': 7.27.2 + '@babel/types': 7.27.6 + debug: 4.4.1 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.27.6': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + + '@esbuild/aix-ppc64@0.25.5': + optional: true + + '@esbuild/android-arm64@0.25.5': + optional: true + + '@esbuild/android-arm@0.25.5': + optional: true + + '@esbuild/android-x64@0.25.5': + optional: true + + '@esbuild/darwin-arm64@0.25.5': + optional: true + + '@esbuild/darwin-x64@0.25.5': + optional: true + + '@esbuild/freebsd-arm64@0.25.5': + optional: true + + '@esbuild/freebsd-x64@0.25.5': + optional: true + + '@esbuild/linux-arm64@0.25.5': + optional: true + + '@esbuild/linux-arm@0.25.5': + optional: true + + '@esbuild/linux-ia32@0.25.5': + optional: true + + '@esbuild/linux-loong64@0.25.5': + optional: true + + '@esbuild/linux-mips64el@0.25.5': + optional: true + + '@esbuild/linux-ppc64@0.25.5': + optional: true + + '@esbuild/linux-riscv64@0.25.5': + optional: true + + '@esbuild/linux-s390x@0.25.5': + optional: true + + '@esbuild/linux-x64@0.25.5': + optional: true + + '@esbuild/netbsd-arm64@0.25.5': + optional: true + + '@esbuild/netbsd-x64@0.25.5': + optional: true + + '@esbuild/openbsd-arm64@0.25.5': + optional: true + + '@esbuild/openbsd-x64@0.25.5': + optional: true + + '@esbuild/sunos-x64@0.25.5': + optional: true + + '@esbuild/win32-arm64@0.25.5': + optional: true + + '@esbuild/win32-ia32@0.25.5': + optional: true + + '@esbuild/win32-x64@0.25.5': + optional: true + + '@eslint-community/eslint-utils@4.7.0(eslint@9.28.0(jiti@2.4.2))': + dependencies: + eslint: 9.28.0(jiti@2.4.2) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.1': {} + + '@eslint/config-array@0.20.0': + dependencies: + '@eslint/object-schema': 2.1.6 + debug: 4.4.1 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.2.2': {} + + '@eslint/core@0.14.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.1': + dependencies: + ajv: 6.12.6 + debug: 4.4.1 + espree: 10.3.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.28.0': {} + + '@eslint/object-schema@2.1.6': {} + + '@eslint/plugin-kit@0.3.1': + dependencies: + '@eslint/core': 0.14.0 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.6': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.3.1 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.3.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.2 + + '@jridgewell/gen-mapping@0.3.8': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@rolldown/pluginutils@1.0.0-beta.9': {} + + '@rollup/rollup-android-arm-eabi@4.42.0': + optional: true + + '@rollup/rollup-android-arm64@4.42.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.42.0': + optional: true + + '@rollup/rollup-darwin-x64@4.42.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.42.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.42.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.42.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.42.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.42.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.42.0': + optional: true + + '@rollup/rollup-linux-loongarch64-gnu@4.42.0': + optional: true + + '@rollup/rollup-linux-powerpc64le-gnu@4.42.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.42.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.42.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.42.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.42.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.42.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.42.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.42.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.42.0': + optional: true + + '@tailwindcss/node@4.1.8': + dependencies: + '@ampproject/remapping': 2.3.0 + enhanced-resolve: 5.18.1 + jiti: 2.4.2 + lightningcss: 1.30.1 + magic-string: 0.30.17 + source-map-js: 1.2.1 + tailwindcss: 4.1.8 + + '@tailwindcss/oxide-android-arm64@4.1.8': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.8': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.8': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.8': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.8': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.8': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.8': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.8': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.8': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.8': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.8': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.8': + optional: true + + '@tailwindcss/oxide@4.1.8': + dependencies: + detect-libc: 2.0.4 + tar: 7.4.3 + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.8 + '@tailwindcss/oxide-darwin-arm64': 4.1.8 + '@tailwindcss/oxide-darwin-x64': 4.1.8 + '@tailwindcss/oxide-freebsd-x64': 4.1.8 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.8 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.8 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.8 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.8 + '@tailwindcss/oxide-linux-x64-musl': 4.1.8 + '@tailwindcss/oxide-wasm32-wasi': 4.1.8 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.8 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.8 + + '@tailwindcss/postcss@4.1.8': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.1.8 + '@tailwindcss/oxide': 4.1.8 + postcss: 8.5.4 + tailwindcss: 4.1.8 + + '@tailwindcss/vite@4.1.8(vite@6.3.5(@types/node@22.15.30)(jiti@2.4.2)(lightningcss@1.30.1))': + dependencies: + '@tailwindcss/node': 4.1.8 + '@tailwindcss/oxide': 4.1.8 + tailwindcss: 4.1.8 + vite: 6.3.5(@types/node@22.15.30)(jiti@2.4.2)(lightningcss@1.30.1) + + '@tanstack/history@1.120.17': {} + + '@tanstack/query-core@5.80.6': {} + + '@tanstack/react-query@5.80.6(react@19.1.0)': + dependencies: + '@tanstack/query-core': 5.80.6 + react: 19.1.0 + + '@tanstack/react-router-devtools@1.120.18(@tanstack/react-router@1.120.18(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@tanstack/router-core@1.120.17)(csstype@3.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tiny-invariant@1.3.3)': + dependencies: + '@tanstack/react-router': 1.120.18(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/router-devtools-core': 1.120.17(@tanstack/router-core@1.120.17)(csstype@3.1.3)(solid-js@1.9.7)(tiny-invariant@1.3.3) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + solid-js: 1.9.7 + transitivePeerDependencies: + - '@tanstack/router-core' + - csstype + - tiny-invariant + + '@tanstack/react-router@1.120.18(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/history': 1.120.17 + '@tanstack/react-store': 0.7.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/router-core': 1.120.17 + jsesc: 3.1.0 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + '@tanstack/react-store@0.7.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/store': 0.7.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + use-sync-external-store: 1.5.0(react@19.1.0) + + '@tanstack/react-table@8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@tanstack/router-core@1.120.17': + dependencies: + '@tanstack/history': 1.120.17 + '@tanstack/store': 0.7.1 + tiny-invariant: 1.3.3 + + '@tanstack/router-devtools-core@1.120.17(@tanstack/router-core@1.120.17)(csstype@3.1.3)(solid-js@1.9.7)(tiny-invariant@1.3.3)': + dependencies: + '@tanstack/router-core': 1.120.17 + clsx: 2.1.1 + goober: 2.1.16(csstype@3.1.3) + solid-js: 1.9.7 + tiny-invariant: 1.3.3 + optionalDependencies: + csstype: 3.1.3 + + '@tanstack/router-devtools@1.120.18(@tanstack/react-router@1.120.18(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@tanstack/router-core@1.120.17)(csstype@3.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tiny-invariant@1.3.3)': + dependencies: + '@tanstack/react-router': 1.120.18(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/react-router-devtools': 1.120.18(@tanstack/react-router@1.120.18(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@tanstack/router-core@1.120.17)(csstype@3.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tiny-invariant@1.3.3) + clsx: 2.1.1 + goober: 2.1.16(csstype@3.1.3) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + csstype: 3.1.3 + transitivePeerDependencies: + - '@tanstack/router-core' + - tiny-invariant + + '@tanstack/store@0.7.1': {} + + '@tanstack/table-core@8.21.3': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.27.5 + '@babel/types': 7.27.6 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.7 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.27.6 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.27.5 + '@babel/types': 7.27.6 + + '@types/babel__traverse@7.20.7': + dependencies: + '@babel/types': 7.27.6 + + '@types/estree@1.0.7': {} + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@22.15.30': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.1.6(@types/react@19.1.6)': + dependencies: + '@types/react': 19.1.6 + + '@types/react@19.1.6': + dependencies: + csstype: 3.1.3 + + '@typescript-eslint/eslint-plugin@8.33.1(@typescript-eslint/parser@8.33.1(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 8.33.1(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.33.1 + '@typescript-eslint/type-utils': 8.33.1(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/utils': 8.33.1(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.33.1 + eslint: 9.28.0(jiti@2.4.2) + graphemer: 1.4.0 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.1.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.33.1(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.33.1 + '@typescript-eslint/types': 8.33.1 + '@typescript-eslint/typescript-estree': 8.33.1(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.33.1 + debug: 4.4.1 + eslint: 9.28.0(jiti@2.4.2) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.33.1(typescript@5.8.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.33.1(typescript@5.8.3) + '@typescript-eslint/types': 8.33.1 + debug: 4.4.1 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.33.1': + dependencies: + '@typescript-eslint/types': 8.33.1 + '@typescript-eslint/visitor-keys': 8.33.1 + + '@typescript-eslint/tsconfig-utils@8.33.1(typescript@5.8.3)': + dependencies: + typescript: 5.8.3 + + '@typescript-eslint/type-utils@8.33.1(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3)': + dependencies: + '@typescript-eslint/typescript-estree': 8.33.1(typescript@5.8.3) + '@typescript-eslint/utils': 8.33.1(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) + debug: 4.4.1 + eslint: 9.28.0(jiti@2.4.2) + ts-api-utils: 2.1.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.33.1': {} + + '@typescript-eslint/typescript-estree@8.33.1(typescript@5.8.3)': + dependencies: + '@typescript-eslint/project-service': 8.33.1(typescript@5.8.3) + '@typescript-eslint/tsconfig-utils': 8.33.1(typescript@5.8.3) + '@typescript-eslint/types': 8.33.1 + '@typescript-eslint/visitor-keys': 8.33.1 + debug: 4.4.1 + fast-glob: 3.3.3 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.2 + ts-api-utils: 2.1.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.33.1(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3)': + dependencies: + '@eslint-community/eslint-utils': 4.7.0(eslint@9.28.0(jiti@2.4.2)) + '@typescript-eslint/scope-manager': 8.33.1 + '@typescript-eslint/types': 8.33.1 + '@typescript-eslint/typescript-estree': 8.33.1(typescript@5.8.3) + eslint: 9.28.0(jiti@2.4.2) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.33.1': + dependencies: + '@typescript-eslint/types': 8.33.1 + eslint-visitor-keys: 4.2.0 + + '@vitejs/plugin-react@4.5.1(vite@6.3.5(@types/node@22.15.30)(jiti@2.4.2)(lightningcss@1.30.1))': + dependencies: + '@babel/core': 7.27.4 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.27.4) + '@rolldown/pluginutils': 1.0.0-beta.9 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.3.5(@types/node@22.15.30)(jiti@2.4.2)(lightningcss@1.30.1) + transitivePeerDependencies: + - supports-color + + acorn-jsx@5.3.2(acorn@8.14.1): + dependencies: + acorn: 8.14.1 + + acorn@8.14.1: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + asynckit@0.4.0: {} + + axios@1.9.0: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.3 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + balanced-match@1.0.2: {} + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.1: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.25.0: + dependencies: + caniuse-lite: 1.0.30001721 + electron-to-chromium: 1.5.165 + node-releases: 2.0.19 + update-browserslist-db: 1.1.3(browserslist@4.25.0) + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001721: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chownr@3.0.0: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.1.3: {} + + debug@4.4.1: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + delayed-stream@1.0.0: {} + + detect-libc@2.0.4: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.165: {} + + enhanced-resolve@5.18.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.2 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.25.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.5 + '@esbuild/android-arm': 0.25.5 + '@esbuild/android-arm64': 0.25.5 + '@esbuild/android-x64': 0.25.5 + '@esbuild/darwin-arm64': 0.25.5 + '@esbuild/darwin-x64': 0.25.5 + '@esbuild/freebsd-arm64': 0.25.5 + '@esbuild/freebsd-x64': 0.25.5 + '@esbuild/linux-arm': 0.25.5 + '@esbuild/linux-arm64': 0.25.5 + '@esbuild/linux-ia32': 0.25.5 + '@esbuild/linux-loong64': 0.25.5 + '@esbuild/linux-mips64el': 0.25.5 + '@esbuild/linux-ppc64': 0.25.5 + '@esbuild/linux-riscv64': 0.25.5 + '@esbuild/linux-s390x': 0.25.5 + '@esbuild/linux-x64': 0.25.5 + '@esbuild/netbsd-arm64': 0.25.5 + '@esbuild/netbsd-x64': 0.25.5 + '@esbuild/openbsd-arm64': 0.25.5 + '@esbuild/openbsd-x64': 0.25.5 + '@esbuild/sunos-x64': 0.25.5 + '@esbuild/win32-arm64': 0.25.5 + '@esbuild/win32-ia32': 0.25.5 + '@esbuild/win32-x64': 0.25.5 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@5.2.0(eslint@9.28.0(jiti@2.4.2)): + dependencies: + eslint: 9.28.0(jiti@2.4.2) + + eslint-plugin-react-refresh@0.4.20(eslint@9.28.0(jiti@2.4.2)): + dependencies: + eslint: 9.28.0(jiti@2.4.2) + + eslint-scope@8.3.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.0: {} + + eslint@9.28.0(jiti@2.4.2): + dependencies: + '@eslint-community/eslint-utils': 4.7.0(eslint@9.28.0(jiti@2.4.2)) + '@eslint-community/regexpp': 4.12.1 + '@eslint/config-array': 0.20.0 + '@eslint/config-helpers': 0.2.2 + '@eslint/core': 0.14.0 + '@eslint/eslintrc': 3.3.1 + '@eslint/js': 9.28.0 + '@eslint/plugin-kit': 0.3.1 + '@humanfs/node': 0.16.6 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.1 + escape-string-regexp: 4.0.0 + eslint-scope: 8.3.0 + eslint-visitor-keys: 4.2.0 + espree: 10.3.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.4.2 + transitivePeerDependencies: + - supports-color + + espree@10.3.0: + dependencies: + acorn: 8.14.1 + acorn-jsx: 5.3.2(acorn@8.14.1) + eslint-visitor-keys: 4.2.0 + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + fdir@6.4.5(picomatch@4.0.2): + optionalDependencies: + picomatch: 4.0.2 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + + flatted@3.3.3: {} + + follow-redirects@1.15.9: {} + + form-data@4.0.3: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@11.12.0: {} + + globals@14.0.0: {} + + globals@16.2.0: {} + + goober@2.1.16(csstype@3.1.3): + dependencies: + csstype: 3.1.3 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + isexe@2.0.0: {} + + jiti@2.4.2: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-darwin-arm64@1.30.1: + optional: true + + lightningcss-darwin-x64@1.30.1: + optional: true + + lightningcss-freebsd-x64@1.30.1: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.1: + optional: true + + lightningcss-linux-arm64-gnu@1.30.1: + optional: true + + lightningcss-linux-arm64-musl@1.30.1: + optional: true + + lightningcss-linux-x64-gnu@1.30.1: + optional: true + + lightningcss-linux-x64-musl@1.30.1: + optional: true + + lightningcss-win32-arm64-msvc@1.30.1: + optional: true + + lightningcss-win32-x64-msvc@1.30.1: + optional: true + + lightningcss@1.30.1: + dependencies: + detect-libc: 2.0.4 + optionalDependencies: + lightningcss-darwin-arm64: 1.30.1 + lightningcss-darwin-x64: 1.30.1 + lightningcss-freebsd-x64: 1.30.1 + lightningcss-linux-arm-gnueabihf: 1.30.1 + lightningcss-linux-arm64-gnu: 1.30.1 + lightningcss-linux-arm64-musl: 1.30.1 + lightningcss-linux-x64-gnu: 1.30.1 + lightningcss-linux-x64-musl: 1.30.1 + lightningcss-win32-arm64-msvc: 1.30.1 + lightningcss-win32-x64-msvc: 1.30.1 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.11 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.1 + + minipass@7.1.2: {} + + minizlib@3.0.2: + dependencies: + minipass: 7.1.2 + + mkdirp@3.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.19: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.2: {} + + postcss@8.5.4: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + proxy-from-env@1.1.0: {} + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-dom@19.1.0(react@19.1.0): + dependencies: + react: 19.1.0 + scheduler: 0.26.0 + + react-hook-form@7.57.0(react@19.1.0): + dependencies: + react: 19.1.0 + + react-refresh@0.17.0: {} + + react@19.1.0: {} + + resolve-from@4.0.0: {} + + reusify@1.1.0: {} + + rollup@4.42.0: + dependencies: + '@types/estree': 1.0.7 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.42.0 + '@rollup/rollup-android-arm64': 4.42.0 + '@rollup/rollup-darwin-arm64': 4.42.0 + '@rollup/rollup-darwin-x64': 4.42.0 + '@rollup/rollup-freebsd-arm64': 4.42.0 + '@rollup/rollup-freebsd-x64': 4.42.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.42.0 + '@rollup/rollup-linux-arm-musleabihf': 4.42.0 + '@rollup/rollup-linux-arm64-gnu': 4.42.0 + '@rollup/rollup-linux-arm64-musl': 4.42.0 + '@rollup/rollup-linux-loongarch64-gnu': 4.42.0 + '@rollup/rollup-linux-powerpc64le-gnu': 4.42.0 + '@rollup/rollup-linux-riscv64-gnu': 4.42.0 + '@rollup/rollup-linux-riscv64-musl': 4.42.0 + '@rollup/rollup-linux-s390x-gnu': 4.42.0 + '@rollup/rollup-linux-x64-gnu': 4.42.0 + '@rollup/rollup-linux-x64-musl': 4.42.0 + '@rollup/rollup-win32-arm64-msvc': 4.42.0 + '@rollup/rollup-win32-ia32-msvc': 4.42.0 + '@rollup/rollup-win32-x64-msvc': 4.42.0 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + scheduler@0.26.0: {} + + semver@6.3.1: {} + + semver@7.7.2: {} + + seroval-plugins@1.3.2(seroval@1.3.2): + dependencies: + seroval: 1.3.2 + + seroval@1.3.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + solid-js@1.9.7: + dependencies: + csstype: 3.1.3 + seroval: 1.3.2 + seroval-plugins: 1.3.2(seroval@1.3.2) + + sonner@2.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + source-map-js@1.2.1: {} + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tailwindcss@4.1.8: {} + + tapable@2.2.2: {} + + tar@7.4.3: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.2 + minizlib: 3.0.2 + mkdirp: 3.0.1 + yallist: 5.0.0 + + tiny-invariant@1.3.3: {} + + tiny-warning@1.0.3: {} + + tinyglobby@0.2.14: + dependencies: + fdir: 6.4.5(picomatch@4.0.2) + picomatch: 4.0.2 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@2.1.0(typescript@5.8.3): + dependencies: + typescript: 5.8.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.33.1(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.33.1(@typescript-eslint/parser@8.33.1(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/parser': 8.33.1(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/utils': 8.33.1(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) + eslint: 9.28.0(jiti@2.4.2) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + typescript@5.8.3: {} + + undici-types@6.21.0: {} + + update-browserslist-db@1.1.3(browserslist@4.25.0): + dependencies: + browserslist: 4.25.0 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-debounce@10.0.5(react@19.1.0): + dependencies: + react: 19.1.0 + + use-sync-external-store@1.5.0(react@19.1.0): + dependencies: + react: 19.1.0 + + vite@6.3.5(@types/node@22.15.30)(jiti@2.4.2)(lightningcss@1.30.1): + dependencies: + esbuild: 0.25.5 + fdir: 6.4.5(picomatch@4.0.2) + picomatch: 4.0.2 + postcss: 8.5.4 + rollup: 4.42.0 + tinyglobby: 0.2.14 + optionalDependencies: + '@types/node': 22.15.30 + fsevents: 2.3.3 + jiti: 2.4.2 + lightningcss: 1.30.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yallist@5.0.0: {} + + yocto-queue@0.1.0: {} diff --git a/spotizerr-ui/postcss.config.mjs b/spotizerr-ui/postcss.config.mjs new file mode 100644 index 0000000..5e6830c --- /dev/null +++ b/spotizerr-ui/postcss.config.mjs @@ -0,0 +1,5 @@ +import tailwindcss from '@tailwindcss/postcss'; + +export default { + plugins: [tailwindcss], +}; diff --git a/static/images/album.svg b/spotizerr-ui/public/album.svg similarity index 100% rename from static/images/album.svg rename to spotizerr-ui/public/album.svg diff --git a/static/images/arrow-left.svg b/spotizerr-ui/public/arrow-left.svg similarity index 100% rename from static/images/arrow-left.svg rename to spotizerr-ui/public/arrow-left.svg diff --git a/static/images/binoculars.svg b/spotizerr-ui/public/binoculars.svg similarity index 100% rename from static/images/binoculars.svg rename to spotizerr-ui/public/binoculars.svg diff --git a/static/images/check.svg b/spotizerr-ui/public/check.svg similarity index 100% rename from static/images/check.svg rename to spotizerr-ui/public/check.svg diff --git a/static/images/cross.svg b/spotizerr-ui/public/cross.svg similarity index 100% rename from static/images/cross.svg rename to spotizerr-ui/public/cross.svg diff --git a/static/images/download.svg b/spotizerr-ui/public/download.svg similarity index 100% rename from static/images/download.svg rename to spotizerr-ui/public/download.svg diff --git a/static/images/eye-crossed.svg b/spotizerr-ui/public/eye-crossed.svg similarity index 100% rename from static/images/eye-crossed.svg rename to spotizerr-ui/public/eye-crossed.svg diff --git a/static/images/eye.svg b/spotizerr-ui/public/eye.svg similarity index 100% rename from static/images/eye.svg rename to spotizerr-ui/public/eye.svg diff --git a/static/html/favicon.ico b/spotizerr-ui/public/favicon.ico similarity index 100% rename from static/html/favicon.ico rename to spotizerr-ui/public/favicon.ico diff --git a/static/images/history.svg b/spotizerr-ui/public/history.svg similarity index 100% rename from static/images/history.svg rename to spotizerr-ui/public/history.svg diff --git a/static/images/home.svg b/spotizerr-ui/public/home.svg similarity index 100% rename from static/images/home.svg rename to spotizerr-ui/public/home.svg diff --git a/static/images/info.svg b/spotizerr-ui/public/info.svg similarity index 100% rename from static/images/info.svg rename to spotizerr-ui/public/info.svg diff --git a/static/images/missing.svg b/spotizerr-ui/public/missing.svg similarity index 100% rename from static/images/missing.svg rename to spotizerr-ui/public/missing.svg diff --git a/static/images/music.svg b/spotizerr-ui/public/music.svg similarity index 100% rename from static/images/music.svg rename to spotizerr-ui/public/music.svg diff --git a/static/images/placeholder.jpg b/spotizerr-ui/public/placeholder.jpg similarity index 100% rename from static/images/placeholder.jpg rename to spotizerr-ui/public/placeholder.jpg diff --git a/static/images/plus-circle.svg b/spotizerr-ui/public/plus-circle.svg similarity index 100% rename from static/images/plus-circle.svg rename to spotizerr-ui/public/plus-circle.svg diff --git a/static/images/queue-empty.svg b/spotizerr-ui/public/queue-empty.svg similarity index 100% rename from static/images/queue-empty.svg rename to spotizerr-ui/public/queue-empty.svg diff --git a/static/images/queue.svg b/spotizerr-ui/public/queue.svg similarity index 100% rename from static/images/queue.svg rename to spotizerr-ui/public/queue.svg diff --git a/static/images/refresh-cw.svg b/spotizerr-ui/public/refresh-cw.svg similarity index 100% rename from static/images/refresh-cw.svg rename to spotizerr-ui/public/refresh-cw.svg diff --git a/static/images/refresh.svg b/spotizerr-ui/public/refresh.svg similarity index 100% rename from static/images/refresh.svg rename to spotizerr-ui/public/refresh.svg diff --git a/static/images/search.svg b/spotizerr-ui/public/search.svg similarity index 100% rename from static/images/search.svg rename to spotizerr-ui/public/search.svg diff --git a/static/images/settings.svg b/spotizerr-ui/public/settings.svg similarity index 100% rename from static/images/settings.svg rename to spotizerr-ui/public/settings.svg diff --git a/static/images/skull-head.svg b/spotizerr-ui/public/skull-head.svg similarity index 100% rename from static/images/skull-head.svg rename to spotizerr-ui/public/skull-head.svg diff --git a/static/images/view.svg b/spotizerr-ui/public/view.svg similarity index 100% rename from static/images/view.svg rename to spotizerr-ui/public/view.svg diff --git a/spotizerr-ui/src/components/Queue.tsx b/spotizerr-ui/src/components/Queue.tsx new file mode 100644 index 0000000..dd9bba9 --- /dev/null +++ b/spotizerr-ui/src/components/Queue.tsx @@ -0,0 +1,73 @@ +import { useQueue, type QueueItem } from '../contexts/queue-context'; + +export function Queue() { + const { items, isVisible, removeItem, clearQueue, toggleVisibility } = useQueue(); + + if (!isVisible) return null; + + const renderStatus = (item: QueueItem) => { + switch (item.status) { + case 'downloading': + return ( +
+
+
+ ); + case 'completed': + return Completed; + case 'error': + return {item.error || 'Failed'}; + default: + return {item.status}; + } + }; + + const renderItemDetails = (item: QueueItem) => { + if (item.status !== 'downloading' || !item.progress) return null; + return ( +
+ {item.progress.toFixed(0)}% + {item.speed} + {item.size} + {item.eta} +
+ ) + } + + return ( +
+
+

Download Queue

+
+ + +
+
+
+ {items.length === 0 ? ( +

Queue is empty.

+ ) : ( + items.map((item) => ( +
+
+ {item.name} + +
+
+ {renderStatus(item)} + {renderItemDetails(item)} +
+
+ )) + )} +
+
+ ); +} diff --git a/spotizerr-ui/src/components/config/AccountsTab.tsx b/spotizerr-ui/src/components/config/AccountsTab.tsx new file mode 100644 index 0000000..f1f2119 --- /dev/null +++ b/spotizerr-ui/src/components/config/AccountsTab.tsx @@ -0,0 +1,150 @@ +import { useState } from 'react'; +import { useForm, type SubmitHandler } from 'react-hook-form'; +import apiClient from '../../lib/api-client'; +import { toast } from 'sonner'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +// --- Type Definitions --- +type Service = 'spotify' | 'deezer'; + +interface Credential { + name: string; +} + +// A single form shape with optional fields +interface AccountFormData { + accountName: string; + accountRegion?: string; + authBlob?: string; // Spotify specific + arl?: string; // Deezer specific +} + +// --- API Functions --- +const fetchCredentials = async (service: Service): Promise => { + const { data } = await apiClient.get(`/credentials/${service}`); + return data.map(name => ({ name })); +}; + +const addCredential = async ({ service, data }: { service: Service, data: AccountFormData }) => { + const payload = service === 'spotify' + ? { blob_content: data.authBlob, region: data.accountRegion } + : { arl: data.arl, region: data.accountRegion }; + + const { data: response } = await apiClient.post(`/credentials/${service}/${data.accountName}`, payload); + return response; +}; + +const deleteCredential = async ({ service, name }: { service: Service, name:string }) => { + const { data: response } = await apiClient.delete(`/credentials/${service}/${name}`); + return response; +}; + +// --- Component --- +export function AccountsTab() { + const queryClient = useQueryClient(); + const [activeService, setActiveService] = useState('spotify'); + const [isAdding, setIsAdding] = useState(false); + + const { data: credentials, isLoading } = useQuery({ + queryKey: ['credentials', activeService], + queryFn: () => fetchCredentials(activeService), + }); + + const { register, handleSubmit, reset, formState: { errors } } = useForm(); + + const addMutation = useMutation({ + mutationFn: addCredential, + onSuccess: () => { + toast.success('Account added successfully!'); + queryClient.invalidateQueries({ queryKey: ['credentials', activeService] }); + setIsAdding(false); + reset(); + }, + onError: (error) => { + toast.error(`Failed to add account: ${error.message}`); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: deleteCredential, + onSuccess: (_, variables) => { + toast.success(`Account "${variables.name}" deleted.`); + queryClient.invalidateQueries({ queryKey: ['credentials', activeService] }); + }, + onError: (error) => { + toast.error(`Failed to delete account: ${error.message}`); + }, + }); + + const onSubmit: SubmitHandler = (data) => { + addMutation.mutate({ service: activeService, data }); + }; + + const renderAddForm = () => ( +
+

Add New {activeService === 'spotify' ? 'Spotify' : 'Deezer'} Account

+
+ + + {errors.accountName &&

{errors.accountName.message}

} +
+ {activeService === 'spotify' && ( +
+ + + {errors.authBlob &&

{errors.authBlob.message}

} +
+ )} + {activeService === 'deezer' && ( +
+ + + {errors.arl &&

{errors.arl.message}

} +
+ )} +
+ + +
+
+ + +
+
+ ); + + return ( +
+
+ + +
+ + {isLoading ? ( +

Loading accounts...

+ ) : ( +
+ {credentials?.map(cred => ( +
+ {cred.name} + +
+ ))} +
+ )} + + {!isAdding && ( + + )} + {isAdding && renderAddForm()} +
+ ); +} diff --git a/spotizerr-ui/src/components/config/DownloadsTab.tsx b/spotizerr-ui/src/components/config/DownloadsTab.tsx new file mode 100644 index 0000000..1ccd213 --- /dev/null +++ b/spotizerr-ui/src/components/config/DownloadsTab.tsx @@ -0,0 +1,143 @@ +import { useForm, type SubmitHandler } from 'react-hook-form'; +import apiClient from '../../lib/api-client'; +import { toast } from 'sonner'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +// --- Type Definitions --- +interface DownloadSettings { + maxConcurrentDownloads: number; + realTime: boolean; + fallback: boolean; + convertTo: 'MP3' | 'AAC' | 'OGG' | 'OPUS' | 'FLAC' | 'WAV' | 'ALAC' | ''; + bitrate: string; + maxRetries: number; + retryDelaySeconds: number; + retryDelayIncrease: number; + threads: number; + path: string; + skipExisting: boolean; + m3u: boolean; + hlsThreads: number; +} + +interface DownloadsTabProps { + config: DownloadSettings; + isLoading: boolean; +} + +const CONVERSION_FORMATS: Record = { + MP3: ['32k', '64k', '96k', '128k', '192k', '256k', '320k'], + AAC: ['32k', '64k', '96k', '128k', '192k', '256k'], + OGG: ['64k', '96k', '128k', '192k', '256k', '320k'], + OPUS: ['32k', '64k', '96k', '128k', '192k', '256k'], + FLAC: [], + WAV: [], + ALAC: [] +}; + +// --- API Functions --- +const saveDownloadConfig = async (data: Partial) => { + const { data: response } = await apiClient.post('/config', data); + return response; +}; + +// --- Component --- +export function DownloadsTab({ config, isLoading }: DownloadsTabProps) { + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: saveDownloadConfig, + onSuccess: () => { + toast.success('Download settings saved successfully!'); + queryClient.invalidateQueries({ queryKey: ['config'] }); + }, + onError: (error) => { + toast.error(`Failed to save settings: ${error.message}`); + }, + }); + + const { register, handleSubmit, watch } = useForm({ + values: config, + }); + + const selectedFormat = watch('convertTo'); + + const onSubmit: SubmitHandler = (data) => { + mutation.mutate({ + ...data, + maxConcurrentDownloads: Number(data.maxConcurrentDownloads), + maxRetries: Number(data.maxRetries), + retryDelaySeconds: Number(data.retryDelaySeconds), + retryDelayIncrease: Number(data.retryDelayIncrease), + }); + }; + + if (isLoading) { + return
Loading download settings...
; + } + + return ( +
+ {/* Download Settings */} +
+

Download Behavior

+
+ + +
+
+ + +
+
+ + +
+
+ + {/* Conversion Settings */} +
+

Conversion

+
+ + +
+
+ + +
+
+ + {/* Retry Options */} +
+

Retries

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ ); +} diff --git a/spotizerr-ui/src/components/config/FormattingTab.tsx b/spotizerr-ui/src/components/config/FormattingTab.tsx new file mode 100644 index 0000000..22a58d8 --- /dev/null +++ b/spotizerr-ui/src/components/config/FormattingTab.tsx @@ -0,0 +1,156 @@ +import { useRef } from 'react'; +import { useForm, type SubmitHandler } from 'react-hook-form'; +import apiClient from '../../lib/api-client'; +import { toast } from 'sonner'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +// --- Type Definitions --- +interface FormattingSettings { + customDirFormat: string; + customTrackFormat: string; + tracknumPadding: boolean; + saveCover: boolean; + track: string; + album: string; + playlist: string; + compilation: string; +} + +interface FormattingTabProps { + config: FormattingSettings; + isLoading: boolean; +} + +// --- API Functions --- +const saveFormattingConfig = async (data: Partial) => { + const { data: response } = await apiClient.post('/config', data); + return response; +}; + +// --- Placeholders --- +const placeholders = { + "Common": { + "%music%": "Track title", + "%artist%": "Track artist", + "%album%": "Album name", + "%ar_album%": "Album artist", + "%tracknum%": "Track number", + "%year%": "Year of release", + }, + "Additional": { + "%discnum%": "Disc number", + "%date%": "Release date", + "%genre%": "Music genre", + "%isrc%": "ISRC", + "%explicit%": "Explicit flag", + "%duration%": "Track duration (s)", + }, +}; + +const PlaceholderSelector = ({ onSelect }: { onSelect: (value: string) => void }) => ( + +); + + +// --- Component --- +export function FormattingTab({ config, isLoading }: FormattingTabProps) { + const queryClient = useQueryClient(); + const dirInputRef = useRef(null); + const trackInputRef = useRef(null); + + const mutation = useMutation({ + mutationFn: saveFormattingConfig, + onSuccess: () => { + toast.success('Formatting settings saved!'); + queryClient.invalidateQueries({ queryKey: ['config'] }); + }, + onError: (error) => { + toast.error(`Failed to save settings: ${error.message}`); + }, + }); + + const { register, handleSubmit, setValue } = useForm({ + values: config, + }); + + // Correctly register the refs for react-hook-form while also holding a local ref. + const { ref: dirFormatRef, ...dirFormatRest } = register('customDirFormat'); + const { ref: trackFormatRef, ...trackFormatRest } = register('customTrackFormat'); + + const handlePlaceholderSelect = (field: 'customDirFormat' | 'customTrackFormat', inputRef: React.RefObject) => (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 = (data) => { + mutation.mutate(data); + }; + + if (isLoading) { + return
Loading formatting settings...
; + } + + return ( +
+
+

File Naming

+
+ + { + dirFormatRef(e); + dirInputRef.current = e; + }} + className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + +
+
+ + { + trackFormatRef(e); + trackInputRef.current = e; + }} + className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + +
+
+ + +
+
+ + +
+
+ + +
+ ); +} diff --git a/spotizerr-ui/src/components/config/GeneralTab.tsx b/spotizerr-ui/src/components/config/GeneralTab.tsx new file mode 100644 index 0000000..57b2164 --- /dev/null +++ b/spotizerr-ui/src/components/config/GeneralTab.tsx @@ -0,0 +1,149 @@ +import { useForm } from 'react-hook-form'; +import apiClient from '../../lib/api-client'; +import { toast } from 'sonner'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useSettings } from '../../contexts/settings-context'; + +// --- Type Definitions --- +interface Credential { + name: string; +} + +interface GeneralSettings { + service: 'spotify' | 'deezer'; + spotify: string; + spotifyQuality: 'NORMAL' | 'HIGH' | 'VERY_HIGH'; + deezer: string; + deezerQuality: 'MP3_128' | 'MP3_320' | 'FLAC'; +} + +interface GeneralTabProps { + config: GeneralSettings; + isLoading: boolean; +} + +// --- API Functions --- +const fetchCredentials = async (service: 'spotify' | 'deezer'): Promise => { + const { data } = await apiClient.get(`/credentials/${service}`); + return data.map(name => ({ name })); +}; + +const saveGeneralConfig = (data: Partial) => apiClient.post('/config', data); + +// --- Component --- +export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabProps) { + const queryClient = useQueryClient(); + const { settings: globalSettings, isLoading: settingsLoading } = useSettings(); + + const { data: spotifyAccounts, isLoading: spotifyLoading } = useQuery({ queryKey: ['credentials', 'spotify'], queryFn: () => fetchCredentials('spotify') }); + const { data: deezerAccounts, isLoading: deezerLoading } = useQuery({ queryKey: ['credentials', 'deezer'], queryFn: () => fetchCredentials('deezer') }); + + const { register, handleSubmit } = useForm({ + values: config, + }); + + const mutation = useMutation({ + mutationFn: saveGeneralConfig, + onSuccess: () => { + toast.success('General settings saved!'); + queryClient.invalidateQueries({ queryKey: ['config'] }); + }, + onError: (e: Error) => toast.error(`Failed to save: ${e.message}`), + }); + + const onSubmit = (data: GeneralSettings) => mutation.mutate(data); + + const isLoading = isConfigLoading || spotifyLoading || deezerLoading || settingsLoading; + if (isLoading) return

Loading general settings...

; + + return ( +
+
+

Service Defaults

+
+ + +
+
+ +
+

Spotify Settings

+
+ + +
+
+ + +
+
+ +
+

Deezer Settings

+
+ + +
+
+ + +
+
+ +
+

Content Filters

+
+ +
+ + {globalSettings?.explicitFilter ? 'Enabled' : 'Disabled'} + + ENV +
+
+

+ The explicit content filter is controlled by an environment variable and cannot be changed here. +

+
+ + +
+ ); +} diff --git a/spotizerr-ui/src/components/config/ServerTab.tsx b/spotizerr-ui/src/components/config/ServerTab.tsx new file mode 100644 index 0000000..22143d0 --- /dev/null +++ b/spotizerr-ui/src/components/config/ServerTab.tsx @@ -0,0 +1,174 @@ +import { useEffect } from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import apiClient from '../../lib/api-client'; +import { toast } from 'sonner'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +// --- Type Definitions --- +interface SpotifyApiSettings { + client_id: string; + client_secret: string; +} + +interface WebhookSettings { + url: string; + events: string[]; + available_events: string[]; // Provided by API, not saved +} + +// --- API Functions --- +const fetchSpotifyApiConfig = async (): Promise => { + const { data } = await apiClient.get('/credentials/spotify_api_config'); + return data; +}; +const saveSpotifyApiConfig = (data: SpotifyApiSettings) => apiClient.put('/credentials/spotify_api_config', data); + +const fetchWebhookConfig = async (): Promise => { + // Mock a response since backend endpoint doesn't exist + // This will prevent the UI from crashing. + return Promise.resolve({ + url: '', + events: [], + available_events: ["download_start", "download_complete", "download_failed", "watch_added"], + }); +}; +const saveWebhookConfig = (data: Partial) => { + toast.info("Webhook configuration is not available."); + return Promise.resolve(data); +}; +const testWebhook = (url: string) => { + toast.info("Webhook testing is not available."); + return Promise.resolve(url); +} + +// --- Components --- +function SpotifyApiForm() { + const queryClient = useQueryClient(); + const { data, isLoading } = useQuery({ queryKey: ['spotifyApiConfig'], queryFn: fetchSpotifyApiConfig }); + const { register, handleSubmit, reset } = useForm(); + + const mutation = useMutation({ + mutationFn: saveSpotifyApiConfig, + onSuccess: () => { + toast.success('Spotify API settings saved!'); + queryClient.invalidateQueries({ queryKey: ['spotifyApiConfig'] }); + }, + onError: (e) => toast.error(`Failed to save: ${e.message}`), + }); + + useEffect(() => { if (data) reset(data); }, [data, reset]); + + const onSubmit = (formData: SpotifyApiSettings) => mutation.mutate(formData); + + if (isLoading) return

Loading Spotify API settings...

; + + return ( +
+
+ + +
+
+ + +
+ +
+ ); +} + +function WebhookForm() { + const queryClient = useQueryClient(); + const { data, isLoading } = useQuery({ queryKey: ['webhookConfig'], queryFn: fetchWebhookConfig }); + const { register, handleSubmit, control, reset, watch } = useForm(); + const currentUrl = watch('url'); + + const mutation = useMutation({ + mutationFn: saveWebhookConfig, + onSuccess: () => { + // No toast needed since the function shows one + queryClient.invalidateQueries({ queryKey: ['webhookConfig'] }); + }, + onError: (e) => toast.error(`Failed to save: ${e.message}`), + }); + + const testMutation = useMutation({ + mutationFn: testWebhook, + onSuccess: () => { + // No toast needed + }, + onError: (e) => toast.error(`Webhook test failed: ${e.message}`), + }); + + useEffect(() => { if (data) reset(data); }, [data, reset]); + + const onSubmit = (formData: WebhookSettings) => mutation.mutate(formData); + + if (isLoading) return

Loading Webhook settings...

; + + return ( +
+
+ + +
+
+ +
+ {data?.available_events.map((event) => ( + ( + + )} + /> + ))} +
+
+
+ + +
+
+ ); +} + +export function ServerTab() { + return ( +
+
+

Spotify API

+

Provide your own API credentials to avoid rate-limiting issues.

+ +
+
+
+

Webhooks

+

Get notifications for events like download completion. (Currently disabled)

+ +
+
+ ); +} diff --git a/spotizerr-ui/src/components/config/WatchTab.tsx b/spotizerr-ui/src/components/config/WatchTab.tsx new file mode 100644 index 0000000..cdd7149 --- /dev/null +++ b/spotizerr-ui/src/components/config/WatchTab.tsx @@ -0,0 +1,121 @@ +import { useEffect } from 'react'; +import { useForm, type SubmitHandler, Controller } from 'react-hook-form'; +import apiClient from '../../lib/api-client'; +import { toast } from 'sonner'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +// --- Type Definitions --- +const ALBUM_GROUPS = ["album", "single", "compilation", "appears_on"] as const; + +type AlbumGroup = typeof ALBUM_GROUPS[number]; + +interface WatchSettings { + enabled: boolean; + watchPollIntervalSeconds: number; + watchedArtistAlbumGroup: AlbumGroup[]; +} + +// --- API Functions --- +const fetchWatchConfig = async (): Promise => { + const { data } = await apiClient.get('/config/watch'); + return data; +}; + +const saveWatchConfig = async (data: Partial) => { + const { data: response } = await apiClient.post('/config/watch', data); + return response; +}; + +// --- Component --- +export function WatchTab() { + const queryClient = useQueryClient(); + + const { data: config, isLoading } = useQuery({ + queryKey: ['watchConfig'], + queryFn: fetchWatchConfig, + }); + + const mutation = useMutation({ + mutationFn: saveWatchConfig, + onSuccess: () => { + toast.success('Watch settings saved successfully!'); + queryClient.invalidateQueries({ queryKey: ['watchConfig'] }); + }, + onError: (error) => { + toast.error(`Failed to save settings: ${error.message}`); + }, + }); + + const { register, handleSubmit, control, reset } = useForm(); + + useEffect(() => { + if (config) { + reset(config); + } + }, [config, reset]); + + const onSubmit: SubmitHandler = (data) => { + mutation.mutate({ + ...data, + watchPollIntervalSeconds: Number(data.watchPollIntervalSeconds), + }); + }; + + if (isLoading) { + return
Loading watch settings...
; + } + + return ( +
+
+

Watchlist Behavior

+
+ + +
+
+ + +

+ How often to check watched items for updates. +

+
+
+ +
+

Artist Album Groups

+

Select which album groups to monitor for watched artists.

+
+ {ALBUM_GROUPS.map((group) => ( + ( + + )} + /> + ))} +
+
+ + +
+ ); +} diff --git a/spotizerr-ui/src/contexts/QueueProvider.tsx b/spotizerr-ui/src/contexts/QueueProvider.tsx new file mode 100644 index 0000000..f5a6bf8 --- /dev/null +++ b/spotizerr-ui/src/contexts/QueueProvider.tsx @@ -0,0 +1,117 @@ +import { useState, useCallback, type ReactNode, useEffect, useRef } from 'react'; +import apiClient from '../lib/api-client'; +import { QueueContext, type QueueItem } from './queue-context'; + +// --- Helper Types --- +interface TaskStatus { + status: 'downloading' | 'completed' | 'error' | 'queued'; + progress?: number; + speed?: string; + size?: string; + eta?: string; + message?: string; +} + +export function QueueProvider({ children }: { children: ReactNode }) { + const [items, setItems] = useState([]); + const [isVisible, setIsVisible] = useState(false); + const pollingIntervals = useRef>({}); + + // --- Core Action: Add Item --- + const addItem = useCallback(async (item: Omit) => { + const newItem: QueueItem = { ...item, status: 'queued' }; + setItems(prev => [...prev, newItem]); + toggleVisibility(); + + try { + // This endpoint should initiate the download and return a task ID + const response = await apiClient.post<{ taskId: string }>(`/download/${item.type}`, { id: item.id }); + const { taskId } = response.data; + + // Update item with taskId and start polling + setItems(prev => prev.map(i => i.id === item.id ? { ...i, taskId, status: 'pending' } : i)); + startPolling(taskId); + } catch (error) { + console.error(`Failed to start download for ${item.name}:`, error); + setItems(prev => prev.map(i => i.id === item.id ? { ...i, status: 'error', error: 'Failed to start download' } : i)); + } + }, []); + + // --- Polling Logic --- + const startPolling = (taskId: string) => { + if (pollingIntervals.current[taskId]) return; // Already polling + + const intervalId = window.setInterval(async () => { + try { + const response = await apiClient.get(`/download/status/${taskId}`); + const statusUpdate = response.data; + + setItems(prev => prev.map(item => { + if (item.taskId === taskId) { + const updatedItem = { + ...item, + status: statusUpdate.status, + progress: statusUpdate.progress, + speed: statusUpdate.speed, + size: statusUpdate.size, + eta: statusUpdate.eta, + error: statusUpdate.status === 'error' ? statusUpdate.message : undefined, + }; + + if (statusUpdate.status === 'completed' || statusUpdate.status === 'error') { + stopPolling(taskId); + } + return updatedItem; + } + return item; + })); + } catch (error) { + console.error(`Polling failed for task ${taskId}:`, error); + stopPolling(taskId); + setItems(prev => prev.map(i => i.taskId === taskId ? { ...i, status: 'error', error: 'Connection lost' } : i)); + } + }, 2000); // Poll every 2 seconds + + pollingIntervals.current[taskId] = intervalId; + }; + + const stopPolling = (taskId: string) => { + clearInterval(pollingIntervals.current[taskId]); + delete pollingIntervals.current[taskId]; + }; + + // Cleanup on unmount + useEffect(() => { + return () => { + Object.values(pollingIntervals.current).forEach(clearInterval); + }; + }, []); + + // --- Other Actions --- + const removeItem = useCallback((id: string) => { + const itemToRemove = items.find(i => i.id === id); + if (itemToRemove && itemToRemove.taskId) { + stopPolling(itemToRemove.taskId); + // Optionally, call an API to cancel the backend task + // apiClient.post(`/download/cancel/${itemToRemove.taskId}`); + } + setItems(prev => prev.filter(item => item.id !== id)); + }, [items]); + + const clearQueue = useCallback(() => { + Object.values(pollingIntervals.current).forEach(clearInterval); + pollingIntervals.current = {}; + setItems([]); + // Optionally, call an API to cancel all tasks + }, []); + + const toggleVisibility = useCallback(() => setIsVisible(prev => !prev), []); + + const value = { items, isVisible, addItem, removeItem, clearQueue, toggleVisibility }; + + return ( + + {children} + + ); +} diff --git a/spotizerr-ui/src/contexts/SettingsProvider.tsx b/spotizerr-ui/src/contexts/SettingsProvider.tsx new file mode 100644 index 0000000..48ab045 --- /dev/null +++ b/spotizerr-ui/src/contexts/SettingsProvider.tsx @@ -0,0 +1,118 @@ +import { type ReactNode } from 'react'; +import apiClient from '../lib/api-client'; +import { SettingsContext, type AppSettings } from './settings-context'; +import { useQuery } from '@tanstack/react-query'; + +// --- Case Conversion Utility --- +// This is added here to simplify the fix and avoid module resolution issues. +function snakeToCamel(str: string): string { + return str.replace(/(_\w)/g, m => m[1].toUpperCase()); +} + +function convertKeysToCamelCase(obj: unknown): unknown { + if (Array.isArray(obj)) { + return obj.map(v => convertKeysToCamelCase(v)); + } + if (typeof obj === 'object' && obj !== null) { + return Object.keys(obj).reduce((acc: Record, key: string) => { + const camelKey = snakeToCamel(key); + acc[camelKey] = convertKeysToCamelCase((obj as Record)[key]); + return acc; + }, {}); + } + return obj; +} + +// Redefine AppSettings to match the flat structure of the API response +export type FlatAppSettings = { + service: 'spotify' | 'deezer'; + spotify: string; + spotifyQuality: 'NORMAL' | 'HIGH' | 'VERY_HIGH'; + deezer: string; + deezerQuality: 'MP3_128' | 'MP3_320' | 'FLAC'; + maxConcurrentDownloads: number; + realTime: boolean; + fallback: boolean; + convertTo: 'MP3' | 'AAC' | 'OGG' | 'OPUS' | 'FLAC' | 'WAV' | 'ALAC' | ''; + bitrate: string; + maxRetries: number; + retryDelaySeconds: number; + retryDelayIncrease: number; + customDirFormat: string; + customTrackFormat: string; + tracknumPadding: boolean; + saveCover: boolean; + explicitFilter: boolean; + // Add other fields from the old AppSettings as needed by other parts of the app + watch: AppSettings['watch']; + // Add defaults for the new download properties + threads: number; + path: string; + skipExisting: boolean; + m3u: boolean; + hlsThreads: number; + // Add defaults for the new formatting properties + track: string; + album: string; + playlist: string; + compilation: string; +}; + +const defaultSettings: FlatAppSettings = { + service: 'spotify', + spotify: '', + spotifyQuality: 'NORMAL', + deezer: '', + deezerQuality: 'MP3_128', + maxConcurrentDownloads: 3, + realTime: false, + fallback: false, + convertTo: '', + bitrate: '', + maxRetries: 3, + retryDelaySeconds: 5, + retryDelayIncrease: 5, + customDirFormat: '%ar_album%/%album%', + customTrackFormat: '%tracknum%. %music%', + tracknumPadding: true, + saveCover: true, + explicitFilter: false, + // Add defaults for the new download properties + threads: 4, + path: '/downloads', + skipExisting: true, + m3u: false, + hlsThreads: 8, + // Add defaults for the new formatting properties + track: '{artist_name}/{album_name}/{track_number} - {track_name}', + album: '{artist_name}/{album_name}', + playlist: 'Playlists/{playlist_name}', + compilation: 'Compilations/{album_name}', + watch: { + enabled: false, + }, +}; + +const fetchSettings = async (): Promise => { + const { data } = await apiClient.get('/config'); + // Transform the keys before returning the data + return convertKeysToCamelCase(data) as FlatAppSettings; +}; + +export function SettingsProvider({ children }: { children: ReactNode }) { + const { data: settings, isLoading, isError } = useQuery({ + queryKey: ['config'], + queryFn: fetchSettings, + staleTime: 1000 * 60 * 5, // 5 minutes + refetchOnWindowFocus: false, + }); + + // Use default settings on error to prevent app crash + const value = { settings: isError ? defaultSettings : (settings || null), isLoading }; + + return ( + + {children} + + ); +} diff --git a/spotizerr-ui/src/contexts/queue-context.ts b/spotizerr-ui/src/contexts/queue-context.ts new file mode 100644 index 0000000..d053986 --- /dev/null +++ b/spotizerr-ui/src/contexts/queue-context.ts @@ -0,0 +1,34 @@ +import { createContext, useContext } from 'react'; + +export interface QueueItem { + id: string; // This is the Spotify ID + type: 'track' | 'album' | 'artist' | 'playlist'; + name: string; + // --- Real-time progress fields --- + status: 'pending' | 'downloading' | 'completed' | 'error' | 'queued'; + taskId?: string; // The backend task ID for polling + progress?: number; + speed?: string; + size?: string; + eta?: string; + error?: string; +} + +export interface QueueContextType { + items: QueueItem[]; + isVisible: boolean; + addItem: (item: Omit) => void; + removeItem: (id: string) => void; + clearQueue: () => void; + toggleVisibility: () => void; +} + +export const QueueContext = createContext(undefined); + +export function useQueue() { + const context = useContext(QueueContext); + if (context === undefined) { + throw new Error('useQueue must be used within a QueueProvider'); + } + return context; +} diff --git a/spotizerr-ui/src/contexts/settings-context.ts b/spotizerr-ui/src/contexts/settings-context.ts new file mode 100644 index 0000000..52d2320 --- /dev/null +++ b/spotizerr-ui/src/contexts/settings-context.ts @@ -0,0 +1,54 @@ +import { createContext, useContext } from 'react'; + +// This new type reflects the flat structure of the /api/config response +export interface AppSettings { + service: 'spotify' | 'deezer'; + spotify: string; + spotifyQuality: 'NORMAL' | 'HIGH' | 'VERY_HIGH'; + deezer: string; + deezerQuality: 'MP3_128' | 'MP3_320' | 'FLAC'; + maxConcurrentDownloads: number; + realTime: boolean; + fallback: boolean; + convertTo: 'MP3' | 'AAC' | 'OGG' | 'OPUS' | 'FLAC' | 'WAV' | 'ALAC' | ''; + bitrate: string; + maxRetries: number; + retryDelaySeconds: number; + retryDelayIncrease: number; + customDirFormat: string; + customTrackFormat: string; + tracknumPadding: boolean; + saveCover: boolean; + explicitFilter: boolean; + // Properties from the old 'downloads' object + threads: number; + path: string; + skipExisting: boolean; + m3u: boolean; + hlsThreads: number; + // Properties from the old 'formatting' object + track: string; + album: string; + playlist: string; + compilation: string; + watch: { + enabled: boolean; + // Add other watch properties from the old type if they still exist in the API response + }; + // Add other root-level properties from the API if they exist +} + +export interface SettingsContextType { + settings: AppSettings | null; + isLoading: boolean; +} + +export const SettingsContext = createContext(undefined); + +export function useSettings() { + const context = useContext(SettingsContext); + if (context === undefined) { + throw new Error('useSettings must be used within a SettingsProvider'); + } + return context; +} diff --git a/spotizerr-ui/src/index.css b/spotizerr-ui/src/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/spotizerr-ui/src/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/spotizerr-ui/src/lib/api-client.ts b/spotizerr-ui/src/lib/api-client.ts new file mode 100644 index 0000000..7e43890 --- /dev/null +++ b/spotizerr-ui/src/lib/api-client.ts @@ -0,0 +1,41 @@ +import axios from 'axios'; +import { toast } from 'sonner'; + +const apiClient = axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'application/json', + }, + timeout: 10000, // 10 seconds timeout +}); + +// Response interceptor for error handling +apiClient.interceptors.response.use( + (response) => { + const contentType = response.headers['content-type']; + if (contentType && contentType.includes('application/json')) { + return response; + } + // If the response is not JSON, reject it to trigger the error handling + const error = new Error('Invalid response type. Expected JSON.'); + toast.error('API Error', { + description: 'Received an invalid response from the server. Expected JSON data.', + }); + return Promise.reject(error); + }, + (error) => { + if (error.code === 'ECONNABORTED') { + toast.error('Request Timed Out', { + description: 'The server did not respond in time. Please try again later.', + }); + } else { + const errorMessage = error.response?.data?.error || error.message || 'An unknown error occurred.'; + toast.error('API Error', { + description: errorMessage, + }); + } + return Promise.reject(error); + } +); + +export default apiClient; diff --git a/spotizerr-ui/src/main.tsx b/spotizerr-ui/src/main.tsx new file mode 100644 index 0000000..65c5ede --- /dev/null +++ b/spotizerr-ui/src/main.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { RouterProvider } from '@tanstack/react-router'; +import { router } from './router'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/spotizerr-ui/src/router.tsx b/spotizerr-ui/src/router.tsx new file mode 100644 index 0000000..ffd82e9 --- /dev/null +++ b/spotizerr-ui/src/router.tsx @@ -0,0 +1,81 @@ +import { createRouter, createRootRoute, createRoute } from '@tanstack/react-router'; +import { Root } from './routes/root'; +import { Album } from './routes/album'; +import { Artist } from './routes/artist'; +import { Track } from './routes/track'; +import { Home } from './routes/home'; +import { Config } from './routes/config'; +import { Playlist } from './routes/playlist'; +import { History } from './routes/history'; +import { Watchlist } from './routes/watchlist'; + +const rootRoute = createRootRoute({ + component: Root, +}); + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: Home, +}); + +const albumRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/album/$albumId', + component: Album, +}); + +const artistRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/artist/$artistId', + component: Artist, +}); + +const trackRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/track/$trackId', + component: Track, +}); + +const configRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/config', + component: Config, +}); + +const playlistRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/playlist/$playlistId', + component: Playlist, +}); + +const historyRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/history', + component: History, +}); + +const watchlistRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/watchlist', + component: Watchlist, +}); + +const routeTree = rootRoute.addChildren([ + indexRoute, + albumRoute, + artistRoute, + trackRoute, + configRoute, + playlistRoute, + historyRoute, + watchlistRoute, +]); + +export const router = createRouter({ routeTree }); + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router; + } +} diff --git a/spotizerr-ui/src/routes/album.tsx b/spotizerr-ui/src/routes/album.tsx new file mode 100644 index 0000000..bd33058 --- /dev/null +++ b/spotizerr-ui/src/routes/album.tsx @@ -0,0 +1,168 @@ +import { Link, useParams } from '@tanstack/react-router'; +import { useEffect, useState } from 'react'; +import apiClient from '../lib/api-client'; +import { useQueue } from '../contexts/queue-context'; +import { useSettings } from '../contexts/settings-context'; +import type { AlbumType, TrackType } from '../types/spotify'; + +export const Album = () => { + const { albumId } = useParams({ from: '/album/$albumId' }); + const [album, setAlbum] = useState(null); + const [error, setError] = useState(null); + const { addItem, toggleVisibility } = useQueue(); + const { settings } = useSettings(); + + useEffect(() => { + const fetchAlbum = async () => { + try { + const response = await apiClient.get(`/album/info?id=${albumId}`); + setAlbum(response.data); + } catch (err) { + setError('Failed to load album'); + console.error('Error fetching album:', err); + } + }; + + if (albumId) { + fetchAlbum(); + } + }, [albumId]); + + const handleDownloadTrack = (track: TrackType) => { + addItem({ id: track.id, type: 'track', name: track.name }); + toggleVisibility(); + }; + + const handleDownloadAlbum = () => { + if (!album) return; + addItem({ id: album.id, type: 'album', name: album.name }); + toggleVisibility(); + }; + + if (error) { + return
{error}
; + } + + if (!album) { + return
Loading...
; + } + + const isExplicitFilterEnabled = settings?.explicitFilter ?? false; + + // Show placeholder for an entirely explicit album + if (isExplicitFilterEnabled && album.explicit) { + return ( +
+

Explicit Content Filtered

+

This album has been filtered based on your settings.

+
+ ); + } + + const hasExplicitTrack = album.tracks.items.some(track => track.explicit); + + return ( +
+
+ {album.name} +
+

{album.name}

+

+ By{' '} + {album.artists.map((artist, index) => ( + + + {artist.name} + + {index < album.artists.length - 1 && ', '} + + ))} +

+

+ {new Date(album.release_date).getFullYear()} • {album.total_tracks} songs +

+

+ {album.label} +

+
+
+ +
+
+ +
+

Tracks

+
+ {album.tracks.items.map((track, index) => { + if (isExplicitFilterEnabled && track.explicit) { + return ( +
+
+ {index + 1} +

Explicit track filtered

+
+ --:-- +
+ ) + } + return ( +
+
+ {index + 1} +
+

{track.name}

+

+ {track.artists.map((artist, index) => ( + + + {artist.name} + + {index < track.artists.length - 1 && ', '} + + ))} +

+
+
+
+ + {Math.floor(track.duration_ms / 60000)}: + {((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, '0')} + + +
+
+ ) + })} +
+
+
+ ); +} diff --git a/spotizerr-ui/src/routes/artist.tsx b/spotizerr-ui/src/routes/artist.tsx new file mode 100644 index 0000000..9999738 --- /dev/null +++ b/spotizerr-ui/src/routes/artist.tsx @@ -0,0 +1,253 @@ +import { Link, useParams } from '@tanstack/react-router'; +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import apiClient from '../lib/api-client'; +import { useQueue } from '../contexts/queue-context'; +import type { AlbumType } from '../types/spotify'; + +interface ArtistInfo { + artist: { + name: string; + images: { url: string }[]; + followers: { total: number }; + }; + topTracks: Track[]; + albums: AlbumGroup; +} + +interface Track { + id: string; + name:string; + duration_ms: number; + album: { + id: string; + name: string; + images: { url: string }[]; + }; +} + +interface UAlbum extends AlbumType { + is_known?: boolean; +} + +interface AlbumGroup { + album: UAlbum[]; + single: UAlbum[]; + appears_on: UAlbum[]; +} + +export const Artist = () => { + const { artistId } = useParams({ from: '/artist/$artistId' }); + const [artistInfo, setArtistInfo] = useState(null); + const [isWatched, setIsWatched] = useState(false); + const [isWatchEnabled, setIsWatchEnabled] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const { addItem, toggleVisibility } = useQueue(); + + useEffect(() => { + const fetchAllData = async () => { + if (!artistId) return; + setIsLoading(true); + try { + const [infoRes, watchConfigRes, watchStatusRes] = await Promise.all([ + apiClient.get(`/artist/info?id=${artistId}`), + apiClient.get('/config/watch'), + apiClient.get(`/artist/watch/status?id=${artistId}`), + ]); + + setArtistInfo(infoRes.data); + setIsWatchEnabled(watchConfigRes.data.enabled); + setIsWatched(watchStatusRes.data.is_watched); + + } catch { + // The API client interceptor will now handle showing the error toast + } finally { + setIsLoading(false); + } + }; + fetchAllData(); + }, [artistId]); + + const handleDownloadTrack = (track: Track) => { + addItem({ id: track.id, type: 'track', name: track.name }); + toggleVisibility(); + }; + + const handleDownloadAll = () => { + if (!artistId || !artistInfo) return; + addItem({ id: artistId, type: 'artist', name: artistInfo.artist.name }); + toggleVisibility(); + }; + + const handleWatch = async () => { + if (!artistId) return; + const originalState = isWatched; + setIsWatched(!originalState); // Optimistic update + try { + await apiClient.post(originalState ? '/artist/unwatch' : '/artist/watch', { artistId }); + toast.success(`Artist ${originalState ? 'unwatched' : 'watched'} successfully.`); + } catch { + setIsWatched(originalState); // Revert on error + } + }; + + const handleSync = async () => { + if (!artistId) return; + toast.info('Syncing artist...', { id: 'sync-artist' }); + try { + await apiClient.post('/artist/sync', { artistId }); + toast.success('Artist sync completed.', { id: 'sync-artist' }); + } catch { + toast.error('Artist sync failed.', { id: 'sync-artist' }); + } + }; + + const handleMarkAsKnown = async (albumId: string, known: boolean) => { + if (!artistId) return; + try { + await apiClient.post('/artist/album/mark', { artistId, albumId, known }); + setArtistInfo(prev => { + if (!prev) return null; + const updateAlbums = (albums: UAlbum[]) => albums.map(a => a.id === albumId ? { ...a, is_known: known } : a); + return { + ...prev, + albums: { + album: updateAlbums(prev.albums.album), + single: updateAlbums(prev.albums.single), + appears_on: updateAlbums(prev.albums.appears_on), + } + } + }); + toast.success(`Album marked as ${known ? 'seen' : 'unseen'}.`); + } catch { + // Error toast handled by interceptor + } + }; + + if (isLoading) return
Loading artist...
; + if (!artistInfo) return
Could not load artist details.
; + + + const { artist, topTracks, albums } = artistInfo; + + const renderAlbumCard = (album: UAlbum) => ( +
+ + {album.name} +

{album.name}

+

{new Date(album.release_date).getFullYear()}

+ + {isWatched && ( + + )} +
+ ); + + return ( +
+ {/* Artist Header */} +
+ {artist.name} +
+

{artist.name}

+

{artist.followers.total.toLocaleString()} followers

+
+ + {isWatchEnabled && ( + <> + + {isWatched && ( + + )} + + )} +
+
+
+ + {/* Top Tracks */} +
+

Top Tracks

+
+ {topTracks.map((track) => ( +
+
+ {track.album.name} +
+

{track.name}

+

+ {Math.floor(track.duration_ms / 60000)}: + {((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, '0')} +

+
+
+ +
+ ))} +
+
+ + {/* Albums */} +
+

Albums

+
+ {albums.album.map(renderAlbumCard)} +
+
+ + {/* Singles */} +
+

Singles & EPs

+
+ {albums.single.map(renderAlbumCard)} +
+
+ + {/* Appears On */} +
+

Appears On

+
+ {albums.appears_on.map(renderAlbumCard)} +
+
+
+ ); +} diff --git a/spotizerr-ui/src/routes/config.tsx b/spotizerr-ui/src/routes/config.tsx new file mode 100644 index 0000000..16f1b47 --- /dev/null +++ b/spotizerr-ui/src/routes/config.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react'; +import { GeneralTab } from '../components/config/GeneralTab'; +import { DownloadsTab } from '../components/config/DownloadsTab'; +import { FormattingTab } from '../components/config/FormattingTab'; +import { AccountsTab } from '../components/config/AccountsTab'; +import { WatchTab } from '../components/config/WatchTab'; +import { ServerTab } from '../components/config/ServerTab'; +import { useSettings } from '../contexts/settings-context'; + +const ConfigComponent = () => { + const [activeTab, setActiveTab] = useState('general'); + + // Get settings from the context instead of fetching here + const { settings: config, isLoading } = useSettings(); + + const renderTabContent = () => { + if (isLoading) return

Loading configuration...

; + if (!config) return

Error loading configuration.

; + + switch (activeTab) { + case 'general': + return ; + case 'downloads': + return ; + case 'formatting': + return ; + case 'accounts': + return ; + case 'watch': + return ; + case 'server': + return ; + default: + return null; + } + }; + + + return ( +
+
+

Configuration

+

Manage application settings and services.

+
+ +
+ + +
+ {renderTabContent()} +
+
+
+ ); +}; + + +export const Config = () => { + return ( + + ) +}; diff --git a/spotizerr-ui/src/routes/history.tsx b/spotizerr-ui/src/routes/history.tsx new file mode 100644 index 0000000..6b50a2c --- /dev/null +++ b/spotizerr-ui/src/routes/history.tsx @@ -0,0 +1,203 @@ +import { useEffect, useState, useMemo } from 'react'; +import apiClient from '../lib/api-client'; +import { toast } from 'sonner'; +import { + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, + getSortedRowModel, + type SortingState, +} from '@tanstack/react-table'; + +// --- Type Definitions --- +type HistoryEntry = { + item_name: string; + item_artist: string; + download_type: 'track' | 'album' | 'playlist' | 'artist'; + service_used: string; + quality_profile: string; + status_final: 'COMPLETED' | 'ERROR' | 'CANCELLED'; + timestamp_completed: number; + error_message?: string; +}; + +// --- Column Definitions --- +const columnHelper = createColumnHelper(); +const columns = [ + columnHelper.accessor('item_name', { header: 'Name' }), + columnHelper.accessor('item_artist', { header: 'Artist' }), + columnHelper.accessor('download_type', { header: 'Type', cell: info => {info.getValue()} }), + 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 {status}; + }, + }), + 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() ? ( + + ) : null, + }) +]; + +export const History = () => { + const [data, setData] = useState([]); + const [totalEntries, setTotalEntries] = useState(0); + const [isLoading, setIsLoading] = useState(true); + + // State for TanStack Table + const [sorting, setSorting] = useState([{ id: 'timestamp_completed', desc: true }]); + const [{ pageIndex, pageSize }, setPagination] = useState({ pageIndex: 0, pageSize: 25 }); + + // State for filters + const [statusFilter, setStatusFilter] = useState(''); + const [typeFilter, setTypeFilter] = useState(''); + + const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]); + + useEffect(() => { + const fetchHistory = async () => { + setIsLoading(true); + try { + const params = new URLSearchParams({ + limit: `${pageSize}`, + offset: `${pageIndex * pageSize}`, + sort_by: sorting[0]?.id ?? 'timestamp_completed', + sort_order: sorting[0]?.desc ? 'DESC' : 'ASC', + }); + if (statusFilter) params.append('status_final', statusFilter); + if (typeFilter) params.append('download_type', typeFilter); + + const response = await apiClient.get<{ entries: HistoryEntry[], total_count: number }>(`/history?${params.toString()}`); + setData(response.data.entries); + setTotalEntries(response.data.total_count); + } catch { + toast.error('Failed to load history.'); + } finally { + setIsLoading(false); + } + }; + fetchHistory(); + }, [pageIndex, pageSize, sorting, statusFilter, typeFilter]); + + const table = useReactTable({ + data, + columns, + pageCount: Math.ceil(totalEntries / pageSize), + state: { sorting, pagination }, + onPaginationChange: setPagination, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + manualPagination: true, + manualSorting: true, + }); + + return ( +
+

Download History

+ + {/* Filter Controls */} +
+ + +
+ + {/* Table */} +
+ + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + + {isLoading ? ( + + ) : table.getRowModel().rows.length === 0 ? ( + + ) : ( + table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => ( + + ))} + + )) + )} + +
+ {header.isPlaceholder ? null : ( +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {{ asc: ' â–²', desc: ' â–¼'}[header.column.getIsSorted() as string] ?? null} +
+ )} +
Loading...
No history entries found.
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ + {/* Pagination Controls */} +
+ + + Page{' '} + + {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} + + + + +
+
+ ); +} diff --git a/spotizerr-ui/src/routes/home.tsx b/spotizerr-ui/src/routes/home.tsx new file mode 100644 index 0000000..63a3c64 --- /dev/null +++ b/spotizerr-ui/src/routes/home.tsx @@ -0,0 +1,150 @@ +import { useState, useEffect, useMemo } from 'react'; +import { Link } from '@tanstack/react-router'; +import { useDebounce } from 'use-debounce'; +import apiClient from '../lib/api-client'; +import { useQueue } from '../contexts/queue-context'; + +// --- Type Definitions --- +interface Image { url: string; } +interface BaseItem { id: string; name: string; } +interface Artist extends BaseItem { images?: Image[]; } +interface Album extends BaseItem { images?: Image[]; artists: Artist[]; } +interface Track extends BaseItem { album: Album; artists: Artist[]; } +interface Playlist extends BaseItem { images?: Image[]; owner: { display_name: string }; } + +type SearchResultItem = Artist | Album | Track | Playlist; +type SearchType = 'artist' | 'album' | 'track' | 'playlist'; + +// --- Component --- +export function Home() { + const [query, setQuery] = useState(''); + const [searchType, setSearchType] = useState('track'); + const [results, setResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [debouncedQuery] = useDebounce(query, 500); + const { addItem, toggleVisibility } = useQueue(); + + useEffect(() => { + const performSearch = async () => { + if (debouncedQuery.trim().length < 2) { + setResults([]); + return; + } + setIsLoading(true); + try { + const response = await apiClient.get<{ items: SearchResultItem[] }>('/search', { + params: { q: debouncedQuery, search_type: searchType, limit: 40 }, + }); + setResults(response.data.items); + } catch (error) { + console.error('Search failed:', error); + setResults([]); + } finally { + setIsLoading(false); + } + }; + performSearch(); + }, [debouncedQuery, searchType]); + + const handleDownloadTrack = (track: Track) => { + addItem({ id: track.id, type: 'track', name: track.name }); + toggleVisibility(); + }; + + const renderResult = (item: SearchResultItem) => { + switch (searchType) { + case 'track': { + const track = item as Track; + return ( +
+ {track.album.name} +
+

{track.name}

+

{track.artists.map(a => a.name).join(', ')}

+
+ +
+ ); + } + case 'album': { + const album = item as Album; + return ( + + {album.name} +

{album.name}

+

{album.artists.map(a => a.name).join(', ')}

+ + ); + } + case 'artist': { + const artist = item as Artist; + return ( + + {artist.name} +

{artist.name}

+ + ); + } + case 'playlist': { + const playlist = item as Playlist; + return ( + + {playlist.name} +

{playlist.name}

+

by {playlist.owner.display_name}

+ + ); + } + default: + return null; + } + }; + + const gridClass = useMemo(() => { + switch(searchType) { + case 'album': + case 'artist': + case 'playlist': + return "grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4"; + case 'track': + return "flex flex-col gap-1"; + default: + return ""; + } + }, [searchType]); + + return ( +
+
+ + setQuery(e.target.value)} + placeholder="Search for songs, albums, artists..." + className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-700 rounded-full bg-gray-100 dark:bg-gray-800" + /> + +
+ +
+ {isLoading &&

Loading...

} + {!isLoading && debouncedQuery && results.length === 0 &&

No results found.

} +
+ {results.map(renderResult)} +
+
+
+ ); +} diff --git a/spotizerr-ui/src/routes/playlist.tsx b/spotizerr-ui/src/routes/playlist.tsx new file mode 100644 index 0000000..8f13237 --- /dev/null +++ b/spotizerr-ui/src/routes/playlist.tsx @@ -0,0 +1,134 @@ +import { Link, useParams } from '@tanstack/react-router'; +import { useEffect, useState } from 'react'; +import apiClient from '../lib/api-client'; +import { useQueue } from '../contexts/queue-context'; +import { useSettings } from '../contexts/settings-context'; +import { toast } from 'sonner'; +import type { ImageType, TrackType } from '../types/spotify'; + +// --- Type Definitions --- +interface SimplifiedAlbumType { + id: string; + name: string; + images: ImageType[]; +} + +interface PlaylistTrackType extends TrackType { + album: SimplifiedAlbumType; +} +interface PlaylistItemType { track: PlaylistTrackType | null; } + +interface PlaylistDetailsType { + id:string; + name: string; + description: string | null; + images: ImageType[]; + owner: { display_name?: string }; + followers?: { total: number }; + tracks: { items: PlaylistItemType[]; total: number; }; +} + +export const Playlist = () => { + const { playlistId } = useParams({ from: '/playlist/$playlistId' }); + const [playlist, setPlaylist] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const { addItem, toggleVisibility } = useQueue(); + const { settings } = useSettings(); + + useEffect(() => { + const fetchPlaylist = async () => { + if (!playlistId) return; + setIsLoading(true); + try { + const response = await apiClient.get(`/playlist/info?id=${playlistId}`); + setPlaylist(response.data); + } catch { + toast.error('Failed to load playlist details.'); + } finally { + setIsLoading(false); + } + }; + fetchPlaylist(); + }, [playlistId]); + + const handleDownloadTrack = (track: PlaylistTrackType) => { + addItem({ id: track.id, type: 'track', name: track.name }); + toggleVisibility(); + }; + + const handleDownloadPlaylist = () => { + if (!playlist) return; + // This assumes a backend endpoint that can handle a whole playlist download by its ID + addItem({ id: playlist.id, type: 'playlist', name: playlist.name }); + toggleVisibility(); + toast.success(`Queued playlist: ${playlist.name}`); + } + + if (isLoading) return
Loading playlist...
; + if (!playlist) return
Playlist not found.
; + + const isExplicitFilterEnabled = settings?.explicitFilter ?? false; + const hasExplicitTrack = playlist.tracks.items.some(item => item.track?.explicit); + + return ( +
+
+ {playlist.name} +
+

{playlist.name}

+

By {playlist.owner.display_name}

+ {playlist.description &&

} +

{playlist.followers?.total.toLocaleString()} followers • {playlist.tracks.total} songs

+
+ +
+
+
+ +
+
+ {playlist.tracks.items.map(({ track }, index) => { + if (!track) return null; // Handle cases where a track might be unavailable + + if (isExplicitFilterEnabled && track.explicit) { + return ( +
+ {index + 1} + Explicit track filtered +
+ ); + } + + return ( +
+ {index + 1} + +
+

{track.name}

+

+ {track.artists.map(a => {a.name}).reduce((prev, curr) => <>{prev}, {curr})} + {' • '} + {track.album.name} +

+
+ + {Math.floor(track.duration_ms / 60000)}:{((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, '0')} + + +
+ ); + })} +
+
+
+ ); +} diff --git a/spotizerr-ui/src/routes/root.tsx b/spotizerr-ui/src/routes/root.tsx new file mode 100644 index 0000000..6966176 --- /dev/null +++ b/spotizerr-ui/src/routes/root.tsx @@ -0,0 +1,61 @@ +import { Outlet } from '@tanstack/react-router'; +import { QueueProvider } from '../contexts/QueueProvider'; +import { useQueue } from '../contexts/queue-context'; +import { Queue } from '../components/Queue'; +import { Link } from '@tanstack/react-router'; +import { SettingsProvider } from '../contexts/SettingsProvider'; +import { Toaster } from 'sonner'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +// Create a client +const queryClient = new QueryClient(); + +function AppLayout() { + const { toggleVisibility } = useQueue(); + + return ( + <> +
+
+
+ + Logo +

Spotizerr

+ +
+ + Watchlist + + + History + + + Settings + + +
+
+
+
+ +
+
+ + + + ); +} + +export function Root() { + return ( + + + + + + + + ); +} diff --git a/spotizerr-ui/src/routes/track.tsx b/spotizerr-ui/src/routes/track.tsx new file mode 100644 index 0000000..483182e --- /dev/null +++ b/spotizerr-ui/src/routes/track.tsx @@ -0,0 +1,92 @@ +import { Link, useParams } from '@tanstack/react-router'; +import { useEffect, useState } from 'react'; +import apiClient from '../lib/api-client'; +import { useQueue } from '../contexts/queue-context'; +import type { TrackType, ImageType } from '../types/spotify'; + +interface SimplifiedAlbum { + id: string; + name: string; + images: ImageType[]; + album_type: string; +} + +interface TrackDetails extends TrackType { + album: SimplifiedAlbum; +} + +export const Track = () => { + const { trackId } = useParams({ from: '/track/$trackId' }); + const [track, setTrack] = useState(null); + const [error, setError] = useState(null); + const { addItem, toggleVisibility } = useQueue(); + + useEffect(() => { + const fetchTrack = async () => { + if (!trackId) return; + try { + const response = await apiClient.get(`/track/info?id=${trackId}`); + setTrack(response.data); + } catch (err) { + setError('Failed to load track details.'); + console.error(err); + } + }; + fetchTrack(); + }, [trackId]); + + const handleDownload = () => { + if (!track) return; + addItem({ id: track.id, type: 'track', name: track.name }); + toggleVisibility(); + }; + + if (error) return
{error}
; + if (!track) return
Loading...
; + + const minutes = Math.floor(track.duration_ms / 60000); + const seconds = ((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, '0'); + + return ( +
+ {track.album.name} +
+

{track.name}

+

+ By{' '} + {track.artists.map((artist, index) => ( + + + {artist.name} + + {index < track.artists.length - 1 && ', '} + + ))} +

+

+ From the {track.album.album_type}{' '} + + {track.album.name} + +

+
+ {minutes}:{seconds} + {track.explicit && EXPLICIT} +
+
+ +
+
+
+ ); +}; diff --git a/spotizerr-ui/src/routes/watchlist.tsx b/spotizerr-ui/src/routes/watchlist.tsx new file mode 100644 index 0000000..c057022 --- /dev/null +++ b/spotizerr-ui/src/routes/watchlist.tsx @@ -0,0 +1,147 @@ +import { useState, useEffect, useCallback } from 'react'; +import apiClient from '../lib/api-client'; +import { toast } from 'sonner'; +import { useSettings } from '../contexts/settings-context'; +import { Link } from '@tanstack/react-router'; + +// --- Type Definitions --- +interface Image { + url: string; +} + +interface WatchedArtist { + itemType: 'artist'; + spotify_id: string; + name: string; + images?: Image[]; + total_albums?: number; +} + +interface WatchedPlaylist { + itemType: 'playlist'; + spotify_id: string; + name: string; + images?: Image[]; + owner?: { display_name?: string }; + total_tracks?: number; +} + +type WatchedItem = WatchedArtist | WatchedPlaylist; + +export const Watchlist = () => { + const { settings, isLoading: settingsLoading } = useSettings(); + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const fetchWatchlist = useCallback(async () => { + setIsLoading(true); + try { + const [artistsRes, playlistsRes] = await Promise.all([ + apiClient.get[]>('/artist/watch/list'), + apiClient.get[]>('/playlist/watch/list'), + ]); + + const artists: WatchedItem[] = artistsRes.data.map(a => ({ ...a, itemType: 'artist' })); + const playlists: WatchedItem[] = playlistsRes.data.map(p => ({ ...p, itemType: 'playlist' })); + + setItems([...artists, ...playlists]); + } catch { + toast.error('Failed to load watchlist.'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + if (!settingsLoading && settings?.watch?.enabled) { + fetchWatchlist(); + } else if (!settingsLoading) { + setIsLoading(false); + } + }, [settings, settingsLoading, fetchWatchlist]); + + const handleUnwatch = async (item: WatchedItem) => { + toast.promise( + apiClient.delete(`/${item.itemType}/watch/${item.spotify_id}`), { + loading: `Unwatching ${item.name}...`, + success: () => { + setItems(prev => prev.filter(i => i.spotify_id !== item.spotify_id)); + return `${item.name} has been unwatched.`; + }, + error: `Failed to unwatch ${item.name}.` + }); + }; + + const handleCheck = async (item: WatchedItem) => { + toast.promise( + apiClient.post(`/${item.itemType}/watch/trigger_check/${item.spotify_id}`), { + loading: `Checking ${item.name} for updates...`, + success: (res: { data: { message?: string }}) => res.data.message || `Check triggered for ${item.name}.`, + error: `Failed to trigger check for ${item.name}.`, + }); + }; + + const handleCheckAll = () => { + toast.promise(Promise.all([ + apiClient.post('/artist/watch/trigger_check'), + apiClient.post('/playlist/watch/trigger_check'), + ]), { + loading: 'Triggering checks for all watched items...', + success: 'Successfully triggered checks for all items.', + error: 'Failed to trigger one or more checks.' + }); + }; + + if (isLoading || settingsLoading) { + return
Loading Watchlist...
; + } + + if (!settings?.watch?.enabled) { + return ( +
+

Watchlist Disabled

+

The watchlist feature is currently disabled. You can enable it in the settings.

+ Go to Settings +
+ ); + } + + if (items.length === 0) { + return ( +
+

Watchlist is Empty

+

Start watching artists or playlists to see them here.

+
+ ); + } + + return ( +
+
+

Watched Artists & Playlists

+ +
+
+ {items.map(item => ( +
+ + {item.name} +

{item.name}

+

{item.itemType}

+
+
+ + +
+
+ ))} +
+
+ ); +} diff --git a/spotizerr-ui/src/types/settings.ts b/spotizerr-ui/src/types/settings.ts new file mode 100644 index 0000000..9bc9005 --- /dev/null +++ b/spotizerr-ui/src/types/settings.ts @@ -0,0 +1,37 @@ +// This new type reflects the flat structure of the /api/config response +export interface AppSettings { + service: 'spotify' | 'deezer'; + spotify: string; + spotifyQuality: 'NORMAL' | 'HIGH' | 'VERY_HIGH'; + deezer: string; + deezerQuality: 'MP3_128' | 'MP3_320' | 'FLAC'; + maxConcurrentDownloads: number; + realTime: boolean; + fallback: boolean; + convertTo: 'MP3' | 'AAC' | 'OGG' | 'OPUS' | 'FLAC' | 'WAV' | 'ALAC' | ''; + bitrate: string; + maxRetries: number; + retryDelaySeconds: number; + retryDelayIncrease: number; + customDirFormat: string; + customTrackFormat: string; + tracknumPadding: boolean; + saveCover: boolean; + explicitFilter: boolean; + // Properties from the old 'downloads' object + threads: number; + path: string; + skipExisting: boolean; + m3u: boolean; + hlsThreads: number; + // Properties from the old 'formatting' object + track: string; + album: string; + playlist: string; + compilation: string; + watch: { + enabled: boolean; + // Add other watch properties from the old type if they still exist in the API response + }; + // Add other root-level properties from the API if they exist +} diff --git a/spotizerr-ui/src/types/spotify.ts b/spotizerr-ui/src/types/spotify.ts new file mode 100644 index 0000000..f8012fb --- /dev/null +++ b/spotizerr-ui/src/types/spotify.ts @@ -0,0 +1,33 @@ +export interface ImageType { + url: string; + height?: number; + width?: number; +} + +export interface ArtistType { + id: string; + name: string; +} + +export interface TrackType { + id: string; + name: string; + artists: ArtistType[]; + duration_ms: number; + explicit: boolean; +} + +export interface AlbumType { + id: string; + name: string; + artists: ArtistType[]; + images: ImageType[]; + release_date: string; + total_tracks: number; + label: string; + copyrights: Array<{ text: string; type: string }>; + explicit: boolean; + tracks: { + items: TrackType[]; + }; +} diff --git a/spotizerr-ui/src/vite-env.d.ts b/spotizerr-ui/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/spotizerr-ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/spotizerr-ui/tsconfig.app.json b/spotizerr-ui/tsconfig.app.json new file mode 100644 index 0000000..c5ecbc5 --- /dev/null +++ b/spotizerr-ui/tsconfig.app.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Path Aliases */ + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitAny": false, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/spotizerr-ui/tsconfig.json b/spotizerr-ui/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/spotizerr-ui/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/spotizerr-ui/tsconfig.node.json b/spotizerr-ui/tsconfig.node.json new file mode 100644 index 0000000..ad375e9 --- /dev/null +++ b/spotizerr-ui/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitAny": false, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/spotizerr-ui/vite.config.ts b/spotizerr-ui/vite.config.ts new file mode 100644 index 0000000..49fc371 --- /dev/null +++ b/spotizerr-ui/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { fileURLToPath } from 'url' +import { dirname, resolve } from 'path' +import tailwindcss from '@tailwindcss/vite' + + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + '@': resolve(__dirname, './src'), + }, + }, + server: { + proxy: { + '/api': { + target: 'http://localhost:7171', + changeOrigin: true, + }, + }, + }, +}) diff --git a/src/js/album.ts b/src/js/album.ts deleted file mode 100644 index ddad930..0000000 --- a/src/js/album.ts +++ /dev/null @@ -1,409 +0,0 @@ -import { downloadQueue } from './queue.js'; - -// Define interfaces for API data -interface Image { - url: string; - height?: number; - width?: number; -} - -interface Artist { - id: string; - name: string; - external_urls: { - spotify: string; - }; -} - -interface Track { - id: string; - name: string; - artists: Artist[]; - duration_ms: number; - explicit: boolean; - external_urls: { - spotify: string; - }; -} - -interface Album { - id: string; - name: string; - artists: Artist[]; - images: Image[]; - release_date: string; - total_tracks: number; - label: string; - copyrights: { text: string; type: string }[]; - explicit: boolean; - tracks: { - items: Track[]; - // Add other properties from Spotify API if needed (e.g., total, limit, offset) - }; - external_urls: { - spotify: string; - }; - // Add other album properties if available -} - -document.addEventListener('DOMContentLoaded', () => { - const pathSegments = window.location.pathname.split('/'); - const albumId = pathSegments[pathSegments.indexOf('album') + 1]; - - if (!albumId) { - showError('No album ID provided.'); - return; - } - - // Fetch album info directly - fetch(`/api/album/info?id=${encodeURIComponent(albumId)}`) - .then(response => { - if (!response.ok) throw new Error('Network response was not ok'); - return response.json() as Promise; // Add Album type - }) - .then(data => renderAlbum(data)) - .catch(error => { - console.error('Error:', error); - showError('Failed to load album.'); - }); - - const queueIcon = document.getElementById('queueIcon'); - if (queueIcon) { - queueIcon.addEventListener('click', () => { - downloadQueue.toggleVisibility(); - }); - } - - // Attempt to set initial watchlist button visibility from cache - const watchlistButton = document.getElementById('watchlistButton') as HTMLAnchorElement | null; - if (watchlistButton) { - const cachedWatchEnabled = localStorage.getItem('spotizerr_watch_enabled_cached'); - if (cachedWatchEnabled === 'true') { - watchlistButton.classList.remove('hidden'); - } - } - - // Fetch watch config to determine if watchlist button should be visible - async function updateWatchlistButtonVisibility() { - if (watchlistButton) { - try { - const response = await fetch('/api/config/watch'); - if (response.ok) { - const watchConfig = await response.json(); - localStorage.setItem('spotizerr_watch_enabled_cached', watchConfig.enabled ? 'true' : 'false'); - if (watchConfig && watchConfig.enabled === false) { - watchlistButton.classList.add('hidden'); - } else { - watchlistButton.classList.remove('hidden'); // Ensure it's shown if enabled - } - } else { - console.error('Failed to fetch watch config, defaulting to hidden'); - // Don't update cache on error - watchlistButton.classList.add('hidden'); // Hide if config fetch fails - } - } catch (error) { - console.error('Error fetching watch config:', error); - // Don't update cache on error - watchlistButton.classList.add('hidden'); // Hide on error - } - } - } - updateWatchlistButtonVisibility(); -}); - -function renderAlbum(album: Album) { - // Hide loading and error messages. - const loadingEl = document.getElementById('loading'); - if (loadingEl) loadingEl.classList.add('hidden'); - - const errorSectionEl = document.getElementById('error'); // Renamed to avoid conflict with error var in catch - if (errorSectionEl) errorSectionEl.classList.add('hidden'); - - // Check if album itself is marked explicit and filter is enabled - const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled(); - if (isExplicitFilterEnabled && album.explicit) { - // Show placeholder for explicit album - const placeholderContent = ` -
-

Explicit Content Filtered

-

This album contains explicit content and has been filtered based on your settings.

-

The explicit content filter is controlled by environment variables.

-
- `; - - const contentContainer = document.getElementById('album-header'); - if (contentContainer) { - contentContainer.innerHTML = placeholderContent; - contentContainer.classList.remove('hidden'); - } - - return; // Stop rendering the actual album content - } - - const baseUrl = window.location.origin; - - // Set album header info. - const albumNameEl = document.getElementById('album-name'); - if (albumNameEl) { - albumNameEl.innerHTML = - `${album.name || 'Unknown Album'}`; - } - - const albumArtistEl = document.getElementById('album-artist'); - if (albumArtistEl) { - albumArtistEl.innerHTML = - `By ${album.artists?.map(artist => - `${artist?.name || 'Unknown Artist'}` - ).join(', ') || 'Unknown Artist'}`; - } - - const releaseYear = album.release_date ? new Date(album.release_date).getFullYear() : 'N/A'; - const albumStatsEl = document.getElementById('album-stats'); - if (albumStatsEl) { - albumStatsEl.textContent = - `${releaseYear} • ${album.total_tracks || '0'} songs • ${album.label || 'Unknown Label'}`; - } - - const albumCopyrightEl = document.getElementById('album-copyright'); - if (albumCopyrightEl) { - albumCopyrightEl.textContent = - album.copyrights?.map(c => c?.text || '').filter(text => text).join(' • ') || ''; - } - - const imageSrc = album.images?.[0]?.url || '/static/images/placeholder.jpg'; - const albumImageEl = document.getElementById('album-image') as HTMLImageElement | null; - if (albumImageEl) { - albumImageEl.src = imageSrc; - } - - // Create (if needed) the Home Button. - let homeButton = document.getElementById('homeButton') as HTMLButtonElement | null; - if (!homeButton) { - homeButton = document.createElement('button'); - homeButton.id = 'homeButton'; - homeButton.className = 'home-btn'; - - const homeIcon = document.createElement('img'); - homeIcon.src = '/static/images/home.svg'; - homeIcon.alt = 'Home'; - homeButton.appendChild(homeIcon); - - // Insert as first child of album-header. - const headerContainer = document.getElementById('album-header'); - if (headerContainer) { // Null check - headerContainer.insertBefore(homeButton, headerContainer.firstChild); - } - } - if (homeButton) { // Null check - homeButton.addEventListener('click', () => { - window.location.href = window.location.origin; - }); - } - - // Check if any track in the album is explicit when filter is enabled - let hasExplicitTrack = false; - if (isExplicitFilterEnabled && album.tracks?.items) { - hasExplicitTrack = album.tracks.items.some(track => track && track.explicit); - } - - // Create (if needed) the Download Album Button. - let downloadAlbumBtn = document.getElementById('downloadAlbumBtn') as HTMLButtonElement | null; - if (!downloadAlbumBtn) { - downloadAlbumBtn = document.createElement('button'); - downloadAlbumBtn.id = 'downloadAlbumBtn'; - downloadAlbumBtn.textContent = 'Download Full Album'; - downloadAlbumBtn.className = 'download-btn download-btn--main'; - const albumHeader = document.getElementById('album-header'); - if (albumHeader) albumHeader.appendChild(downloadAlbumBtn); // Null check - } - - if (downloadAlbumBtn) { // Null check for downloadAlbumBtn - if (isExplicitFilterEnabled && hasExplicitTrack) { - // Disable the album download button and display a message explaining why - downloadAlbumBtn.disabled = true; - downloadAlbumBtn.classList.add('download-btn--disabled'); - downloadAlbumBtn.innerHTML = `Album Contains Explicit Tracks`; - } else { - // Normal behavior when no explicit tracks are present - downloadAlbumBtn.addEventListener('click', () => { - // Remove any other download buttons (keeping the full-album button in place). - document.querySelectorAll('.download-btn').forEach(btn => { - if (btn.id !== 'downloadAlbumBtn') btn.remove(); - }); - - if (downloadAlbumBtn) { // Inner null check - downloadAlbumBtn.disabled = true; - downloadAlbumBtn.textContent = 'Queueing...'; - } - - downloadWholeAlbum(album) - .then(() => { - if (downloadAlbumBtn) downloadAlbumBtn.textContent = 'Queued!'; // Inner null check - }) - .catch(err => { - showError('Failed to queue album download: ' + (err?.message || 'Unknown error')); - if (downloadAlbumBtn) downloadAlbumBtn.disabled = false; // Inner null check - }); - }); - } - } - - // Render each track. - const tracksList = document.getElementById('tracks-list'); - if (tracksList) { // Null check - tracksList.innerHTML = ''; - - if (album.tracks?.items) { - album.tracks.items.forEach((track, index) => { - if (!track) return; // Skip null or undefined tracks - - // Skip explicit tracks if filter is enabled - if (isExplicitFilterEnabled && track.explicit) { - // Add a placeholder for filtered explicit tracks - const trackElement = document.createElement('div'); - trackElement.className = 'track track-filtered'; - trackElement.innerHTML = ` -
${index + 1}
-
-
Explicit Content Filtered
-
This track is not shown due to explicit content filter settings
-
-
--:--
- `; - tracksList.appendChild(trackElement); - return; - } - - const trackElement = document.createElement('div'); - trackElement.className = 'track'; - trackElement.innerHTML = ` -
${index + 1}
-
- -
- ${track.artists?.map(a => - `${a?.name || 'Unknown Artist'}` - ).join(', ') || 'Unknown Artist'} -
-
-
${msToTime(track.duration_ms || 0)}
- - `; - tracksList.appendChild(trackElement); - }); - } - } - - // Reveal header and track list. - const albumHeaderEl = document.getElementById('album-header'); - if (albumHeaderEl) albumHeaderEl.classList.remove('hidden'); - - const tracksContainerEl = document.getElementById('tracks-container'); - if (tracksContainerEl) tracksContainerEl.classList.remove('hidden'); - attachDownloadListeners(); - - // If on a small screen, re-arrange the action buttons. - if (window.innerWidth <= 480) { - let actionsContainer = document.getElementById('album-actions'); - if (!actionsContainer) { - actionsContainer = document.createElement('div'); - actionsContainer.id = 'album-actions'; - const albumHeader = document.getElementById('album-header'); - if (albumHeader) albumHeader.appendChild(actionsContainer); // Null check - } - if (actionsContainer) { // Null check for actionsContainer - actionsContainer.innerHTML = ''; // Clear any previous content - const homeBtn = document.getElementById('homeButton'); - if (homeBtn) actionsContainer.appendChild(homeBtn); // Null check - - const dlAlbumBtn = document.getElementById('downloadAlbumBtn'); - if (dlAlbumBtn) actionsContainer.appendChild(dlAlbumBtn); // Null check - - const queueToggle = document.querySelector('.queue-toggle'); - if (queueToggle) { - actionsContainer.appendChild(queueToggle); - } - } - } -} - -async function downloadWholeAlbum(album: Album) { - const albumIdToDownload = album.id || ''; - if (!albumIdToDownload) { - throw new Error('Missing album ID'); - } - - try { - // Use the centralized downloadQueue.download method - await downloadQueue.download(albumIdToDownload, 'album', { name: album.name || 'Unknown Album' }); - // Make the queue visible after queueing - downloadQueue.toggleVisibility(true); - } catch (error: any) { // Add type for error - showError('Album download failed: ' + (error?.message || 'Unknown error')); - throw error; - } -} - -function msToTime(duration: number): string { - const minutes = Math.floor(duration / 60000); - const seconds = ((duration % 60000) / 1000).toFixed(0); - return `${minutes}:${seconds.padStart(2, '0')}`; -} - -function showError(message: string) { - const errorEl = document.getElementById('error'); - if (errorEl) { // Null check - errorEl.textContent = message || 'An error occurred'; - errorEl.classList.remove('hidden'); - } -} - -function attachDownloadListeners() { - document.querySelectorAll('.download-btn').forEach((btn) => { - const button = btn as HTMLButtonElement; // Cast to HTMLButtonElement - if (button.id === 'downloadAlbumBtn') return; - button.addEventListener('click', (e) => { - e.stopPropagation(); - const currentTarget = e.currentTarget as HTMLButtonElement | null; // Cast currentTarget - if (!currentTarget) return; - - const itemId = currentTarget.dataset.id || ''; - const type = currentTarget.dataset.type || ''; - const name = currentTarget.dataset.name || 'Unknown'; - - if (!itemId) { - showError('Missing item ID for download in album page'); - return; - } - // Remove the button immediately after click. - currentTarget.remove(); - startDownload(itemId, type, { name }); // albumType will be undefined - }); - }); -} - -async function startDownload(itemId: string, type: string, item: { name: string }, albumType?: string) { // Add types and make albumType optional - if (!itemId || !type) { - showError('Missing ID or type for download'); - return Promise.reject(new Error('Missing ID or type for download')); // Return a rejected promise - } - - try { - // Use the centralized downloadQueue.download method - await downloadQueue.download(itemId, type, item, albumType); - - // Make the queue visible after queueing - downloadQueue.toggleVisibility(true); - } catch (error: any) { // Add type for error - showError('Download failed: ' + (error?.message || 'Unknown error')); - throw error; - } -} diff --git a/src/js/artist.ts b/src/js/artist.ts deleted file mode 100644 index 9970991..0000000 --- a/src/js/artist.ts +++ /dev/null @@ -1,854 +0,0 @@ -// Import the downloadQueue singleton -import { downloadQueue } from './queue.js'; - -// Define interfaces for API data -interface Image { - url: string; - height?: number; - width?: number; -} - -interface Artist { - id: string; - name: string; - external_urls: { - spotify: string; - }; -} - -interface Album { - id: string; - name: string; - artists: Artist[]; - images: Image[]; - album_type: string; // "album", "single", "compilation" - album_group?: string; // "album", "single", "compilation", "appears_on" - external_urls: { - spotify: string; - }; - explicit?: boolean; // Added to handle explicit filter - total_tracks?: number; - release_date?: string; - is_locally_known?: boolean; // Added for local DB status -} - -interface ArtistData { - items: Album[]; - total: number; - // Add other properties if available from the API - // For watch status, the artist object itself might have `is_watched` if we extend API - // For now, we fetch status separately. -} - -// Interface for watch status response -interface WatchStatusResponse { - is_watched: boolean; - artist_data?: any; // The artist data from DB if watched -} - -// Added: Interface for global watch config -interface GlobalWatchConfig { - enabled: boolean; - [key: string]: any; -} - -// Added: Helper function to fetch global watch config -async function getGlobalWatchConfig(): Promise { - try { - const response = await fetch('/api/config/watch'); - if (!response.ok) { - console.error('Failed to fetch global watch config, assuming disabled.'); - return { enabled: false }; // Default to disabled on error - } - return await response.json() as GlobalWatchConfig; - } catch (error) { - console.error('Error fetching global watch config:', error); - return { enabled: false }; // Default to disabled on error - } -} - -document.addEventListener('DOMContentLoaded', async () => { - const pathSegments = window.location.pathname.split('/'); - const artistId = pathSegments[pathSegments.indexOf('artist') + 1]; - - if (!artistId) { - showError('No artist ID provided.'); - return; - } - - const globalWatchConfig = await getGlobalWatchConfig(); // Fetch global config - const isGlobalWatchActuallyEnabled = globalWatchConfig.enabled; - - // Fetch artist info directly - fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`) - .then(response => { - if (!response.ok) throw new Error('Network response was not ok'); - return response.json() as Promise; - }) - .then(data => renderArtist(data, artistId, isGlobalWatchActuallyEnabled)) - .catch(error => { - console.error('Error:', error); - showError('Failed to load artist info.'); - }); - - const queueIcon = document.getElementById('queueIcon'); - if (queueIcon) { - queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility()); - } - - // Attempt to set initial watchlist button visibility from cache - const watchlistButton = document.getElementById('watchlistButton') as HTMLAnchorElement | null; - if (watchlistButton) { - const cachedWatchEnabled = localStorage.getItem('spotizerr_watch_enabled_cached'); - if (cachedWatchEnabled === 'true') { - watchlistButton.classList.remove('hidden'); - } - } - - // Fetch watch config to determine if watchlist button should be visible - async function updateWatchlistButtonVisibility() { - if (watchlistButton) { - try { - const response = await fetch('/api/config/watch'); - if (response.ok) { - const watchConfig = await response.json(); - localStorage.setItem('spotizerr_watch_enabled_cached', watchConfig.enabled ? 'true' : 'false'); - if (watchConfig && watchConfig.enabled === false) { - watchlistButton.classList.add('hidden'); - } else { - watchlistButton.classList.remove('hidden'); // Ensure it's shown if enabled - } - } else { - console.error('Failed to fetch watch config for artist page, defaulting to hidden'); - // Don't update cache on error - watchlistButton.classList.add('hidden'); // Hide if config fetch fails - } - } catch (error) { - console.error('Error fetching watch config for artist page:', error); - // Don't update cache on error - watchlistButton.classList.add('hidden'); // Hide on error - } - } - } - updateWatchlistButtonVisibility(); - - // Initialize the watch button after main artist rendering - // This is done inside renderArtist after button element is potentially created. -}); - -async function renderArtist(artistData: ArtistData, artistId: string, isGlobalWatchEnabled: boolean) { - const loadingEl = document.getElementById('loading'); - if (loadingEl) loadingEl.classList.add('hidden'); - - const errorEl = document.getElementById('error'); - if (errorEl) errorEl.classList.add('hidden'); - - // Fetch watch status upfront to avoid race conditions for album button rendering - let isArtistActuallyWatched = false; // Default - if (isGlobalWatchEnabled) { // Only fetch if globally enabled - isArtistActuallyWatched = await getArtistWatchStatus(artistId); - } - - // Check if explicit filter is enabled - const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled(); - - const firstAlbum = artistData.items?.[0]; - const artistName = firstAlbum?.artists?.[0]?.name || 'Unknown Artist'; - const artistImageSrc = firstAlbum?.images?.[0]?.url || '/static/images/placeholder.jpg'; - - const artistNameEl = document.getElementById('artist-name'); - if (artistNameEl) { - artistNameEl.innerHTML = - `${artistName}`; - } - const artistStatsEl = document.getElementById('artist-stats'); - if (artistStatsEl) { - artistStatsEl.textContent = `${artistData.total || '0'} albums`; - } - const artistImageEl = document.getElementById('artist-image') as HTMLImageElement | null; - if (artistImageEl) { - artistImageEl.src = artistImageSrc; - } - - const watchArtistBtn = document.getElementById('watchArtistBtn') as HTMLButtonElement | null; - const syncArtistBtn = document.getElementById('syncArtistBtn') as HTMLButtonElement | null; - - if (!isGlobalWatchEnabled) { - if (watchArtistBtn) { - watchArtistBtn.classList.add('hidden'); - watchArtistBtn.disabled = true; - } - if (syncArtistBtn) { - syncArtistBtn.classList.add('hidden'); - syncArtistBtn.disabled = true; - } - } else { - if (watchArtistBtn) { - initializeWatchButton(artistId, isArtistActuallyWatched); - } else { - console.warn("Watch artist button not found in HTML."); - } - // Sync button visibility is managed by initializeWatchButton - } - - // Define the artist URL (used by both full-discography and group downloads) - // const artistUrl = `https://open.spotify.com/artist/${artistId}`; // Not directly used here anymore - - // Home Button - let homeButton = document.getElementById('homeButton') as HTMLButtonElement | null; - if (!homeButton) { - homeButton = document.createElement('button'); - homeButton.id = 'homeButton'; - homeButton.className = 'home-btn'; - homeButton.innerHTML = `Home`; - const artistHeader = document.getElementById('artist-header'); - if (artistHeader) artistHeader.prepend(homeButton); - } - if (homeButton) { - homeButton.addEventListener('click', () => window.location.href = window.location.origin); - } - - // Download Whole Artist Button using the new artist API endpoint - let downloadArtistBtn = document.getElementById('downloadArtistBtn') as HTMLButtonElement | null; - if (!downloadArtistBtn) { - downloadArtistBtn = document.createElement('button'); - downloadArtistBtn.id = 'downloadArtistBtn'; - downloadArtistBtn.className = 'download-btn download-btn--main'; - downloadArtistBtn.textContent = 'Download All Discography'; - const artistHeader = document.getElementById('artist-header'); - if (artistHeader) artistHeader.appendChild(downloadArtistBtn); - } - - // When explicit filter is enabled, disable all download buttons - if (isExplicitFilterEnabled) { - if (downloadArtistBtn) { - downloadArtistBtn.disabled = true; - downloadArtistBtn.classList.add('download-btn--disabled'); - downloadArtistBtn.innerHTML = `Downloads Restricted`; - } - } else { - if (downloadArtistBtn) { - downloadArtistBtn.addEventListener('click', () => { - document.querySelectorAll('.download-btn:not(#downloadArtistBtn)').forEach(btn => btn.remove()); - if (downloadArtistBtn) { - downloadArtistBtn.disabled = true; - downloadArtistBtn.textContent = 'Queueing...'; - } - startDownload( - artistId, - 'artist', - { name: artistName, artist: artistName }, - 'album,single,compilation,appears_on' - ) - .then((taskIds) => { - if (downloadArtistBtn) { - downloadArtistBtn.textContent = 'Artist queued'; - downloadQueue.toggleVisibility(true); - if (Array.isArray(taskIds)) { - downloadArtistBtn.title = `${taskIds.length} albums queued for download`; - } - } - }) - .catch(err => { - if (downloadArtistBtn) { - downloadArtistBtn.textContent = 'Download All Discography'; - downloadArtistBtn.disabled = false; - } - showError('Failed to queue artist download: ' + (err?.message || 'Unknown error')); - }); - }); - } - } - - const albumGroups: Record = {}; - const appearingAlbums: Album[] = []; - - (artistData.items || []).forEach(album => { - if (!album) return; - if (isExplicitFilterEnabled && album.explicit) { - return; - } - if (album.album_group === 'appears_on') { - appearingAlbums.push(album); - } else { - const type = (album.album_type || 'unknown').toLowerCase(); - if (!albumGroups[type]) albumGroups[type] = []; - albumGroups[type].push(album); - } - }); - - const groupsContainer = document.getElementById('album-groups'); - if (groupsContainer) { - groupsContainer.innerHTML = ''; - - // Use the definitively fetched watch status for rendering album buttons - // const isArtistWatched = watchArtistBtn && watchArtistBtn.dataset.watching === 'true'; // Old way - // const useThisWatchStatusForAlbums = isArtistActuallyWatched; // Old way, now combination of global and individual - - for (const [groupType, albums] of Object.entries(albumGroups)) { - const groupSection = document.createElement('section'); - groupSection.className = 'album-group'; - - const groupHeaderHTML = isExplicitFilterEnabled ? - `
-

${capitalize(groupType)}s

-
Visit album pages to download content
-
` : - `
-

${capitalize(groupType)}s

- -
`; - - groupSection.innerHTML = groupHeaderHTML; - const albumsListContainer = document.createElement('div'); - albumsListContainer.className = 'albums-list'; - - albums.forEach(album => { - if (!album) return; - const albumElement = document.createElement('div'); - albumElement.className = 'album-card'; - albumElement.dataset.albumId = album.id; - - let albumCardHTML = ` - - Album cover - -
-
${album.name || 'Unknown Album'}
-
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
-
- `; - albumElement.innerHTML = albumCardHTML; - - const albumCardActions = document.createElement('div'); - albumCardActions.className = 'album-card-actions'; - - // Persistent Mark as Known/Missing button (if artist is watched) - Appears first (left) - if (isGlobalWatchEnabled && isArtistActuallyWatched && album.id) { - const toggleKnownBtn = document.createElement('button'); - toggleKnownBtn.className = 'toggle-known-status-btn persistent-album-action-btn'; - toggleKnownBtn.dataset.albumId = album.id; - - if (album.is_locally_known) { - toggleKnownBtn.dataset.status = 'known'; - toggleKnownBtn.innerHTML = 'Mark as missing'; - toggleKnownBtn.title = 'Mark album as not in local library (Missing)'; - toggleKnownBtn.classList.add('status-known'); // Green - } else { - toggleKnownBtn.dataset.status = 'missing'; - toggleKnownBtn.innerHTML = 'Mark as known'; - toggleKnownBtn.title = 'Mark album as in local library (Known)'; - toggleKnownBtn.classList.add('status-missing'); // Red - } - albumCardActions.appendChild(toggleKnownBtn); // Add to actions container - } - - // Persistent Download Button (if not explicit filter) - Appears second (right) - if (!isExplicitFilterEnabled) { - const downloadBtn = document.createElement('button'); - downloadBtn.className = 'download-btn download-btn--circle persistent-download-btn'; - downloadBtn.innerHTML = 'Download album'; - downloadBtn.title = 'Download this album'; - downloadBtn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - downloadBtn.disabled = true; - downloadBtn.innerHTML = 'Queueing...'; - startDownload(album.id, 'album', { name: album.name, artist: album.artists?.[0]?.name || 'Unknown Artist', type: 'album' }) - .then(() => { - downloadBtn.innerHTML = 'Queued'; - showNotification(`Album '${album.name}' queued for download.`); - downloadQueue.toggleVisibility(true); - }) - .catch(err => { - downloadBtn.disabled = false; - downloadBtn.innerHTML = 'Download album'; - showError(`Failed to queue album: ${err?.message || 'Unknown error'}`); - }); - }); - albumCardActions.appendChild(downloadBtn); // Add to actions container - } - - // Only append albumCardActions if it has any buttons - if (albumCardActions.hasChildNodes()) { - albumElement.appendChild(albumCardActions); - } - - albumsListContainer.appendChild(albumElement); - }); - groupSection.appendChild(albumsListContainer); - groupsContainer.appendChild(groupSection); - } - - if (appearingAlbums.length > 0) { - const featuringSection = document.createElement('section'); - featuringSection.className = 'album-group'; - const featuringHeaderHTML = isExplicitFilterEnabled ? - `
-

Featuring

-
Visit album pages to download content
-
` : - `
-

Featuring

- -
`; - featuringSection.innerHTML = featuringHeaderHTML; - const appearingAlbumsListContainer = document.createElement('div'); - appearingAlbumsListContainer.className = 'albums-list'; - - appearingAlbums.forEach(album => { - if (!album) return; - const albumElement = document.createElement('div'); - albumElement.className = 'album-card'; - albumElement.dataset.albumId = album.id; // Set dataset for appears_on albums too - - let albumCardHTML = ` - - Album cover - -
-
${album.name || 'Unknown Album'}
-
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
-
- `; - albumElement.innerHTML = albumCardHTML; - - const albumCardActions_AppearsOn = document.createElement('div'); - albumCardActions_AppearsOn.className = 'album-card-actions'; - - // Persistent Mark as Known/Missing button for appearing_on albums (if artist is watched) - Appears first (left) - if (isGlobalWatchEnabled && isArtistActuallyWatched && album.id) { - const toggleKnownBtn = document.createElement('button'); - toggleKnownBtn.className = 'toggle-known-status-btn persistent-album-action-btn'; - toggleKnownBtn.dataset.albumId = album.id; - if (album.is_locally_known) { - toggleKnownBtn.dataset.status = 'known'; - toggleKnownBtn.innerHTML = 'Mark as missing'; - toggleKnownBtn.title = 'Mark album as not in local library (Missing)'; - toggleKnownBtn.classList.add('status-known'); // Green - } else { - toggleKnownBtn.dataset.status = 'missing'; - toggleKnownBtn.innerHTML = 'Mark as known'; - toggleKnownBtn.title = 'Mark album as in local library (Known)'; - toggleKnownBtn.classList.add('status-missing'); // Red - } - albumCardActions_AppearsOn.appendChild(toggleKnownBtn); // Add to actions container - } - - // Persistent Download Button for appearing_on albums (if not explicit filter) - Appears second (right) - if (!isExplicitFilterEnabled) { - const downloadBtn = document.createElement('button'); - downloadBtn.className = 'download-btn download-btn--circle persistent-download-btn'; - downloadBtn.innerHTML = 'Download album'; - downloadBtn.title = 'Download this album'; - downloadBtn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - downloadBtn.disabled = true; - downloadBtn.innerHTML = 'Queueing...'; - startDownload(album.id, 'album', { name: album.name, artist: album.artists?.[0]?.name || 'Unknown Artist', type: 'album' }) - .then(() => { - downloadBtn.innerHTML = 'Queued'; - showNotification(`Album '${album.name}' queued for download.`); - downloadQueue.toggleVisibility(true); - }) - .catch(err => { - downloadBtn.disabled = false; - downloadBtn.innerHTML = 'Download album'; - showError(`Failed to queue album: ${err?.message || 'Unknown error'}`); - }); - }); - albumCardActions_AppearsOn.appendChild(downloadBtn); // Add to actions container - } - - // Only append albumCardActions_AppearsOn if it has any buttons - if (albumCardActions_AppearsOn.hasChildNodes()) { - albumElement.appendChild(albumCardActions_AppearsOn); - } - - appearingAlbumsListContainer.appendChild(albumElement); - }); - featuringSection.appendChild(appearingAlbumsListContainer); - groupsContainer.appendChild(featuringSection); - } - } - - const artistHeaderEl = document.getElementById('artist-header'); - if (artistHeaderEl) artistHeaderEl.classList.remove('hidden'); - const albumsContainerEl = document.getElementById('albums-container'); - if (albumsContainerEl) albumsContainerEl.classList.remove('hidden'); - - if (!isExplicitFilterEnabled) { - attachAlbumActionListeners(artistId, isGlobalWatchEnabled); - attachGroupDownloadListeners(artistId, artistName); - } -} - -function attachGroupDownloadListeners(artistId: string, artistName: string) { - document.querySelectorAll('.group-download-btn').forEach(btn => { - const button = btn as HTMLButtonElement; - button.addEventListener('click', async (e) => { - const target = e.target as HTMLButtonElement | null; - if (!target) return; - const groupType = target.dataset.groupType || 'album'; - target.disabled = true; - const displayType = groupType === 'appears_on' ? 'Featuring Albums' : `${capitalize(groupType)}s`; - target.textContent = `Queueing all ${displayType}...`; - try { - const taskIds = await startDownload( - artistId, - 'artist', - { name: artistName || 'Unknown Artist', artist: artistName || 'Unknown Artist' }, - groupType - ); - const totalQueued = Array.isArray(taskIds) ? taskIds.length : 0; - target.textContent = `Queued all ${displayType}`; - target.title = `${totalQueued} albums queued for download`; - downloadQueue.toggleVisibility(true); - } catch (error: any) { - target.textContent = `Download All ${displayType}`; - target.disabled = false; - showError(`Failed to queue download for all ${groupType}: ${error?.message || 'Unknown error'}`); - } - }); - }); -} - -function attachAlbumActionListeners(artistIdForContext: string, isGlobalWatchEnabled: boolean) { - const groupsContainer = document.getElementById('album-groups'); - if (!groupsContainer) return; - - groupsContainer.addEventListener('click', async (event) => { - const target = event.target as HTMLElement; - const button = target.closest('.toggle-known-status-btn') as HTMLButtonElement | null; - - if (button && button.dataset.albumId) { - if (!isGlobalWatchEnabled) { - showNotification("Watch feature is currently disabled globally."); - return; - } - const albumId = button.dataset.albumId; - const currentStatus = button.dataset.status; - - // Optimistic UI update - button.disabled = true; - const originalIcon = button.innerHTML; // Save original icon - button.innerHTML = 'Updating...'; - - try { - if (currentStatus === 'known') { - await handleMarkAlbumAsMissing(artistIdForContext, albumId); - button.dataset.status = 'missing'; - button.innerHTML = 'Mark as known'; // Update to missing.svg - button.title = 'Mark album as in local library (Known)'; - button.classList.remove('status-known'); - button.classList.add('status-missing'); - const albumCard = button.closest('.album-card') as HTMLElement | null; - if (albumCard) { - const coverImg = albumCard.querySelector('.album-cover') as HTMLImageElement | null; - if (coverImg) coverImg.classList.add('album-missing-in-db'); - } - showNotification(`Album marked as missing from local library.`); - } else { - await handleMarkAlbumAsKnown(artistIdForContext, albumId); - button.dataset.status = 'known'; - button.innerHTML = 'Mark as missing'; // Update to check.svg - button.title = 'Mark album as not in local library (Missing)'; - button.classList.remove('status-missing'); - button.classList.add('status-known'); - const albumCard = button.closest('.album-card') as HTMLElement | null; - if (albumCard) { - const coverImg = albumCard.querySelector('.album-cover') as HTMLImageElement | null; - if (coverImg) coverImg.classList.remove('album-missing-in-db'); - } - showNotification(`Album marked as present in local library.`); - } - } catch (error) { - console.error('Failed to update album status:', error); - showError('Failed to update album status. Please try again.'); - // Revert UI on error - button.dataset.status = currentStatus; // Revert status - button.innerHTML = originalIcon; // Revert icon - // Revert card style if needed (though if API failed, actual state is unchanged) - } finally { - button.disabled = false; // Re-enable button - } - } - }); -} - -async function handleMarkAlbumAsKnown(artistId: string, albumId: string) { - // Ensure albumId is a string and not undefined. - if (!albumId || typeof albumId !== 'string') { - console.error('Invalid albumId provided to handleMarkAlbumAsKnown:', albumId); - throw new Error('Invalid album ID.'); - } - const response = await fetch(`/api/artist/watch/${artistId}/albums`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify([albumId]) // API expects an array of album IDs - }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({ message: 'Failed to mark album as known.' })); - throw new Error(errorData.message || `HTTP error! status: ${response.status}`); - } - return response.json(); -} - -async function handleMarkAlbumAsMissing(artistId: string, albumId: string) { - // Ensure albumId is a string and not undefined. - if (!albumId || typeof albumId !== 'string') { - console.error('Invalid albumId provided to handleMarkAlbumAsMissing:', albumId); - throw new Error('Invalid album ID.'); - } - const response = await fetch(`/api/artist/watch/${artistId}/albums`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify([albumId]) // API expects an array of album IDs - }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({ message: 'Failed to mark album as missing.' })); - throw new Error(errorData.message || `HTTP error! status: ${response.status}`); - } - // For DELETE, Spotify often returns 204 No Content, or we might return custom JSON. - // If expecting JSON: - // return response.json(); - // If handling 204 or simple success message: - const result = await response.json(); // Assuming the backend sends a JSON response - console.log('Mark as missing result:', result); - return result; -} - -// Add startDownload function (similar to track.js and main.js) -/** - * Starts the download process via centralized download queue - */ -async function startDownload(itemId: string, type: string, item: { name: string, artist?: string, type?: string }, albumType?: string) { - if (!itemId || !type) { - showError('Missing ID or type for download'); - return Promise.reject(new Error('Missing ID or type for download')); // Return a rejected promise - } - - try { - // Use the centralized downloadQueue.download method for all downloads including artist downloads - const result = await downloadQueue.download(itemId, type, item, albumType); - - // Make the queue visible after queueing - downloadQueue.toggleVisibility(true); - - // Return the result for tracking - return result; - } catch (error: any) { // Add type for error - showError('Download failed: ' + (error?.message || 'Unknown error')); - throw error; - } -} - -// UI Helpers -function showError(message: string) { - const errorEl = document.getElementById('error'); - if (errorEl) { - errorEl.textContent = message || 'An error occurred'; - errorEl.classList.remove('hidden'); - } -} - -function capitalize(str: string) { - return str ? str.charAt(0).toUpperCase() + str.slice(1) : ''; -} - -async function getArtistWatchStatus(artistId: string): Promise { - try { - const response = await fetch(`/api/artist/watch/${artistId}/status`); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); // Catch if res not json - throw new Error(errorData.error || `HTTP error! status: ${response.status}`); - } - const data: WatchStatusResponse = await response.json(); - return data.is_watched; - } catch (error) { - console.error('Error fetching artist watch status:', error); - showError('Could not fetch watch status.'); - return false; // Assume not watching on error - } -} - -async function watchArtist(artistId: string): Promise { - try { - const response = await fetch(`/api/artist/watch/${artistId}`, { - method: 'PUT', - }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || `HTTP error! status: ${response.status}`); - } - // Optionally handle success message from response.json() - await response.json(); - } catch (error) { - console.error('Error watching artist:', error); - showError('Failed to watch artist.'); - throw error; // Re-throw to allow caller to handle UI update failure - } -} - -async function unwatchArtist(artistId: string): Promise { - try { - const response = await fetch(`/api/artist/watch/${artistId}`, { - method: 'DELETE', - }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || `HTTP error! status: ${response.status}`); - } - // Optionally handle success message - await response.json(); - } catch (error) { - console.error('Error unwatching artist:', error); - showError('Failed to unwatch artist.'); - throw error; // Re-throw - } -} - -function updateWatchButton(artistId: string, isWatching: boolean) { - const watchArtistBtn = document.getElementById('watchArtistBtn') as HTMLButtonElement | null; - const syncArtistBtn = document.getElementById('syncArtistBtn') as HTMLButtonElement | null; - - if (watchArtistBtn) { - const img = watchArtistBtn.querySelector('img'); - if (isWatching) { - if (img) img.src = '/static/images/eye-crossed.svg'; - watchArtistBtn.innerHTML = `Unwatch Unwatch Artist`; - watchArtistBtn.classList.add('watching'); - watchArtistBtn.title = "Stop watching this artist"; - if (syncArtistBtn) syncArtistBtn.classList.remove('hidden'); - } else { - if (img) img.src = '/static/images/eye.svg'; - watchArtistBtn.innerHTML = `Watch Watch Artist`; - watchArtistBtn.classList.remove('watching'); - watchArtistBtn.title = "Watch this artist for new releases"; - if (syncArtistBtn) syncArtistBtn.classList.add('hidden'); - } - watchArtistBtn.dataset.watching = isWatching ? 'true' : 'false'; - } -} - -async function initializeWatchButton(artistId: string, initialIsWatching: boolean) { - const watchArtistBtn = document.getElementById('watchArtistBtn') as HTMLButtonElement | null; - const syncArtistBtn = document.getElementById('syncArtistBtn') as HTMLButtonElement | null; - - if (!watchArtistBtn) return; - - try { - watchArtistBtn.disabled = true; - if (syncArtistBtn) syncArtistBtn.disabled = true; - - // const isWatching = await getArtistWatchStatus(artistId); // No longer fetch here, use parameter - updateWatchButton(artistId, initialIsWatching); // Use passed status - watchArtistBtn.disabled = false; - if (syncArtistBtn) syncArtistBtn.disabled = !(watchArtistBtn.dataset.watching === 'true'); - - watchArtistBtn.addEventListener('click', async () => { - const currentlyWatching = watchArtistBtn.dataset.watching === 'true'; - watchArtistBtn.disabled = true; - if (syncArtistBtn) syncArtistBtn.disabled = true; - try { - if (currentlyWatching) { - await unwatchArtist(artistId); - updateWatchButton(artistId, false); - // Re-fetch and re-render artist data, passing the global watch status again - const newArtistData = await (await fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`)).json() as ArtistData; - // Assuming renderArtist needs the global status, which it does. We need to get it or have it available. - // Since initializeWatchButton is called from renderArtist, we can assume isGlobalWatchEnabled is in that scope. - // This part is tricky as initializeWatchButton doesn't have isGlobalWatchEnabled. - // Let's re-fetch global config or rely on the fact that if this button is clickable, global is on. - // For simplicity, the re-render will pick up the global status from its own scope if called from top level. - // The click handler itself does not need to pass isGlobalWatchEnabled to renderArtist, renderArtist's caller does. - // Let's ensure renderArtist is called correctly after watch/unwatch. - const globalWatchConfig = await getGlobalWatchConfig(); // Re-fetch for re-render - renderArtist(newArtistData, artistId, globalWatchConfig.enabled); - } else { - await watchArtist(artistId); - updateWatchButton(artistId, true); - // Re-fetch and re-render artist data - const newArtistData = await (await fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`)).json() as ArtistData; - const globalWatchConfig = await getGlobalWatchConfig(); // Re-fetch for re-render - renderArtist(newArtistData, artistId, globalWatchConfig.enabled); - } - } catch (error) { - // On error, revert button to its state before the click attempt - updateWatchButton(artistId, currentlyWatching); - } - watchArtistBtn.disabled = false; - if (syncArtistBtn) syncArtistBtn.disabled = !(watchArtistBtn.dataset.watching === 'true'); - }); - - // Add event listener for the sync button - if (syncArtistBtn) { - syncArtistBtn.addEventListener('click', async () => { - syncArtistBtn.disabled = true; - const originalButtonContent = syncArtistBtn.innerHTML; // Store full HTML - const textNode = Array.from(syncArtistBtn.childNodes).find(node => node.nodeType === Node.TEXT_NODE); - const originalText = textNode ? textNode.nodeValue : 'Sync Watched Artist'; // Fallback text - - syncArtistBtn.innerHTML = `Sync Syncing...`; // Keep icon - try { - await triggerArtistSync(artistId); - showNotification('Artist sync triggered successfully.'); - } catch (error) { - // Error is shown by triggerArtistSync - } - syncArtistBtn.innerHTML = originalButtonContent; // Restore full original HTML - syncArtistBtn.disabled = false; - }); - } - - } catch (error) { - if (watchArtistBtn) watchArtistBtn.disabled = false; - if (syncArtistBtn) syncArtistBtn.disabled = true; - updateWatchButton(artistId, false); // On error fetching initial status (though now it's passed) - // This line might be less relevant if initialIsWatching is guaranteed by caller - // but as a fallback it sets to a non-watching state. - } -} - -// New function to trigger artist sync -async function triggerArtistSync(artistId: string): Promise { - try { - const response = await fetch(`/api/artist/watch/trigger_check/${artistId}`, { - method: 'POST', - }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || `HTTP error! status: ${response.status}`); - } - await response.json(); // Contains success message - } catch (error) { - console.error('Error triggering artist sync:', error); - showError('Failed to trigger artist sync.'); - throw error; // Re-throw - } -} - -/** - * Displays a temporary notification message. - */ -function showNotification(message: string) { - // Basic notification - consider a more robust solution for production - const notificationEl = document.createElement('div'); - notificationEl.className = 'notification'; // Ensure this class is styled - notificationEl.textContent = message; - document.body.appendChild(notificationEl); - setTimeout(() => { - notificationEl.remove(); - }, 3000); -} diff --git a/src/js/config.ts b/src/js/config.ts deleted file mode 100644 index 7442d94..0000000 --- a/src/js/config.ts +++ /dev/null @@ -1,1240 +0,0 @@ -import { downloadQueue } from './queue.js'; - -// Updated Interfaces for validator data -interface SpotifyFormData { - accountName: string; // Formerly username, maps to 'name' in backend - authBlob: string; // Formerly credentials, maps to 'blob_content' in backend - accountRegion?: string; // Maps to 'region' in backend -} - -interface DeezerFormData { - accountName: string; // Maps to 'name' in backend - arl: string; - accountRegion?: string; // Maps to 'region' in backend -} - -// Global service configuration object -const serviceConfig: Record = { - spotify: { - fields: [ - { id: 'accountName', label: 'Account Name', type: 'text' }, - { id: 'accountRegion', label: 'Region (ISO 3166-1 alpha-2)', type: 'text', placeholder: 'E.g., US, DE, GB (Optional)'}, - { id: 'authBlob', label: 'Auth Blob (JSON content)', type: 'textarea', rows: 5 } - ], - validator: (data: SpotifyFormData) => ({ - name: data.accountName, - region: data.accountRegion || null, // Send null if empty, backend might have default - blob_content: data.authBlob - }), - }, - deezer: { - fields: [ - { id: 'accountName', label: 'Account Name', type: 'text' }, - { id: 'accountRegion', label: 'Region (ISO 3166-1 alpha-2)', type: 'text', placeholder: 'E.g., US, DE, FR (Optional)'}, - { id: 'arl', label: 'ARL Token', type: 'text' } - ], - validator: (data: DeezerFormData) => ({ - name: data.accountName, - region: data.accountRegion || null, // Send null if empty - arl: data.arl - }) - } -}; - -let currentService = 'spotify'; -let currentCredential: string | null = null; -let isEditingSearch = false; - -// Global variables to hold the active accounts from the config response. -let activeSpotifyAccount = ''; -let activeDeezerAccount = ''; - -// Define available formats and their bitrates -const CONVERSION_FORMATS: Record = { - MP3: ['32k', '64k', '96k', '128k', '192k', '256k', '320k'], - AAC: ['32k', '64k', '96k', '128k', '192k', '256k'], - OGG: ['64k', '96k', '128k', '192k', '256k', '320k'], - OPUS: ['32k', '64k', '96k', '128k', '192k', '256k'], - FLAC: [], // No specific bitrates - WAV: [], // No specific bitrates - ALAC: [] // No specific bitrates -}; - -// Reference to the credentials form card and add button -let credentialsFormCard: HTMLElement | null = null; -let showAddAccountFormBtn: HTMLElement | null = null; -let cancelAddAccountBtn: HTMLElement | null = null; - -// Hint element references -let spotifyRegionHint: HTMLElement | null = null; -let deezerRegionHint: HTMLElement | null = null; - -// Ensure this is defined, typically at the top with other DOM element getters if used frequently -let spotifyApiConfigStatusDiv: HTMLElement | null = null; - -// Helper function to manage visibility of form and add button -function setFormVisibility(showForm: boolean) { - if (credentialsFormCard && showAddAccountFormBtn) { - credentialsFormCard.style.display = showForm ? 'block' : 'none'; - showAddAccountFormBtn.style.display = showForm ? 'none' : 'flex'; // Assuming flex for styled button - if (showForm) { - resetForm(); // Reset form to "add new" state when showing for add - const credentialNameInput = document.getElementById('credentialName') as HTMLInputElement | null; - if(credentialNameInput) credentialNameInput.focus(); - } - } -} - -async function loadConfig() { - try { - const response = await fetch('/api/config'); - if (!response.ok) throw new Error('Failed to load config'); - - const savedConfig = await response.json(); - - // Set default service selection - const defaultServiceSelect = document.getElementById('defaultServiceSelect') as HTMLSelectElement | null; - if (defaultServiceSelect) defaultServiceSelect.value = savedConfig.service || 'spotify'; - - // Update the service-specific options based on selected service - updateServiceSpecificOptions(); - - // Use the "spotify" and "deezer" properties from the API response to set the active accounts. - activeSpotifyAccount = savedConfig.spotify || ''; - activeDeezerAccount = savedConfig.deezer || ''; - - // (Optionally, if the account selects already exist you can set their values here, - // but updateAccountSelectors() will rebuild the options and set the proper values.) - const spotifySelect = document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null; - const deezerSelect = document.getElementById('deezerAccountSelect') as HTMLSelectElement | null; - const spotifyMessage = document.getElementById('spotifyAccountMessage') as HTMLElement | null; - const deezerMessage = document.getElementById('deezerAccountMessage') as HTMLElement | null; - if (spotifySelect) spotifySelect.value = activeSpotifyAccount; - if (deezerSelect) deezerSelect.value = activeDeezerAccount; - - // Update other configuration fields. - const fallbackToggle = document.getElementById('fallbackToggle') as HTMLInputElement | null; - if (fallbackToggle) fallbackToggle.checked = !!savedConfig.fallback; - const spotifyQualitySelect = document.getElementById('spotifyQualitySelect') as HTMLSelectElement | null; - if (spotifyQualitySelect) spotifyQualitySelect.value = savedConfig.spotifyQuality || 'NORMAL'; - const deezerQualitySelect = document.getElementById('deezerQualitySelect') as HTMLSelectElement | null; - if (deezerQualitySelect) deezerQualitySelect.value = savedConfig.deezerQuality || 'MP3_128'; - const realTimeToggle = document.getElementById('realTimeToggle') as HTMLInputElement | null; - if (realTimeToggle) realTimeToggle.checked = !!savedConfig.realTime; - const customDirFormat = document.getElementById('customDirFormat') as HTMLInputElement | null; - if (customDirFormat) customDirFormat.value = savedConfig.customDirFormat || '%ar_album%/%album%'; - const customTrackFormat = document.getElementById('customTrackFormat') as HTMLInputElement | null; - if (customTrackFormat) customTrackFormat.value = savedConfig.customTrackFormat || '%tracknum%. %music%'; - const maxConcurrentDownloads = document.getElementById('maxConcurrentDownloads') as HTMLInputElement | null; - if (maxConcurrentDownloads) maxConcurrentDownloads.value = savedConfig.maxConcurrentDownloads || '3'; - const maxRetries = document.getElementById('maxRetries') as HTMLInputElement | null; - if (maxRetries) maxRetries.value = savedConfig.maxRetries || '3'; - const retryDelaySeconds = document.getElementById('retryDelaySeconds') as HTMLInputElement | null; - if (retryDelaySeconds) retryDelaySeconds.value = savedConfig.retryDelaySeconds || '5'; - const retryDelayIncrease = document.getElementById('retryDelayIncrease') as HTMLInputElement | null; - if (retryDelayIncrease) retryDelayIncrease.value = savedConfig.retry_delay_increase || '5'; - const tracknumPaddingToggle = document.getElementById('tracknumPaddingToggle') as HTMLInputElement | null; - if (tracknumPaddingToggle) tracknumPaddingToggle.checked = savedConfig.tracknum_padding === undefined ? true : !!savedConfig.tracknum_padding; - const saveCoverToggle = document.getElementById('saveCoverToggle') as HTMLInputElement | null; - if (saveCoverToggle) saveCoverToggle.checked = savedConfig.save_cover === undefined ? true : !!savedConfig.save_cover; - - // Load conversion settings - const convertToSelect = document.getElementById('convertToSelect') as HTMLSelectElement | null; - if (convertToSelect) { - convertToSelect.value = savedConfig.convertTo || ''; - updateBitrateOptions(convertToSelect.value); - } - const bitrateSelect = document.getElementById('bitrateSelect') as HTMLSelectElement | null; - if (bitrateSelect && savedConfig.bitrate) { - if (Array.from(bitrateSelect.options).some(option => option.value === savedConfig.bitrate)) { - bitrateSelect.value = savedConfig.bitrate; - } - } else if (bitrateSelect) { - if (convertToSelect && !CONVERSION_FORMATS[convertToSelect.value]?.length) { - bitrateSelect.value = ''; - } - } - - // Update explicit filter status - updateExplicitFilterStatus(savedConfig.explicitFilter); - - // Load watch config - await loadWatchConfig(); - } catch (error: any) { - showConfigError('Error loading config: ' + error.message); - } -} - -document.addEventListener('DOMContentLoaded', async () => { - try { - await initConfig(); - setupServiceTabs(); - setupEventListeners(); - - // Setup for the collapsable "Add Account" form - credentialsFormCard = document.querySelector('.credentials-form.card'); - showAddAccountFormBtn = document.getElementById('showAddAccountFormBtn'); - cancelAddAccountBtn = document.getElementById('cancelAddAccountBtn'); - - // Get hint elements - spotifyRegionHint = document.getElementById('spotifyRegionHint'); - deezerRegionHint = document.getElementById('deezerRegionHint'); - - if (credentialsFormCard && showAddAccountFormBtn) { - // Initially hide form, show add button (default state handled by setFormVisibility if called) - credentialsFormCard.style.display = 'none'; - showAddAccountFormBtn.style.display = 'flex'; // Assuming styled button uses flex - } - - if (showAddAccountFormBtn) { - showAddAccountFormBtn.addEventListener('click', () => { - setFormVisibility(true); - }); - } - - if (cancelAddAccountBtn && credentialsFormCard && showAddAccountFormBtn) { - cancelAddAccountBtn.addEventListener('click', () => { - setFormVisibility(false); - resetForm(); // Also reset form state on cancel - }); - } - - const queueIcon = document.getElementById('queueIcon'); - if (queueIcon) { - queueIcon.addEventListener('click', () => { - downloadQueue.toggleVisibility(); - }); - } - - // Attempt to set initial watchlist button visibility from cache - const watchlistButton = document.getElementById('watchlistButton') as HTMLAnchorElement | null; - if (watchlistButton) { - const cachedWatchEnabled = localStorage.getItem('spotizerr_watch_enabled_cached'); - if (cachedWatchEnabled === 'true') { - watchlistButton.classList.remove('hidden'); - } - } - - // Fetch watch config to determine if watchlist button should be visible - async function updateWatchlistButtonVisibility() { - if (watchlistButton) { - try { - const response = await fetch('/api/config/watch'); - if (response.ok) { - const watchConfig = await response.json(); - localStorage.setItem('spotizerr_watch_enabled_cached', watchConfig.enabled ? 'true' : 'false'); - if (watchConfig && watchConfig.enabled === false) { - watchlistButton.classList.add('hidden'); - } else { - watchlistButton.classList.remove('hidden'); // Ensure it's shown if enabled - } - } else { - console.error('Failed to fetch watch config for config page, defaulting to hidden'); - // Don't update cache on error - watchlistButton.classList.add('hidden'); // Hide if config fetch fails - } - } catch (error) { - console.error('Error fetching watch config for config page:', error); - // Don't update cache on error - watchlistButton.classList.add('hidden'); // Hide on error - } - } - } - updateWatchlistButtonVisibility(); - - } catch (error: any) { - showConfigError(error.message); - } -}); - -async function initConfig() { - await loadConfig(); - await updateAccountSelectors(); - loadCredentials(currentService); - updateFormFields(); - await loadSpotifyApiConfig(); -} - -function setupServiceTabs() { - const serviceTabs = document.querySelectorAll('.tab-button'); - serviceTabs.forEach(tab => { - tab.addEventListener('click', () => { - serviceTabs.forEach(t => t.classList.remove('active')); - tab.classList.add('active'); - currentService = (tab as HTMLElement).dataset.service || 'spotify'; - loadCredentials(currentService); - updateFormFields(); - }); - }); -} - -function setupEventListeners() { - (document.getElementById('credentialForm') as HTMLFormElement | null)?.addEventListener('submit', handleCredentialSubmit); - (document.getElementById('saveSpotifyApiConfigBtn') as HTMLButtonElement | null)?.addEventListener('click', saveSpotifyApiConfig); - - // Config change listeners - (document.getElementById('defaultServiceSelect') as HTMLSelectElement | null)?.addEventListener('change', function() { - updateServiceSpecificOptions(); - saveConfig(); - }); - (document.getElementById('fallbackToggle') as HTMLInputElement | null)?.addEventListener('change', saveConfig); - (document.getElementById('realTimeToggle') as HTMLInputElement | null)?.addEventListener('change', saveConfig); - (document.getElementById('spotifyQualitySelect') as HTMLSelectElement | null)?.addEventListener('change', saveConfig); - (document.getElementById('deezerQualitySelect') as HTMLSelectElement | null)?.addEventListener('change', saveConfig); - (document.getElementById('tracknumPaddingToggle') as HTMLInputElement | null)?.addEventListener('change', saveConfig); - (document.getElementById('saveCoverToggle') as HTMLInputElement | null)?.addEventListener('change', saveConfig); - (document.getElementById('maxRetries') as HTMLInputElement | null)?.addEventListener('change', saveConfig); - (document.getElementById('retryDelaySeconds') as HTMLInputElement | null)?.addEventListener('change', saveConfig); - - // Conversion settings listeners - (document.getElementById('convertToSelect') as HTMLSelectElement | null)?.addEventListener('change', function() { - updateBitrateOptions(this.value); - saveConfig(); - }); - (document.getElementById('bitrateSelect') as HTMLSelectElement | null)?.addEventListener('change', saveConfig); - - // Update active account globals when the account selector is changed. - (document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null)?.addEventListener('change', (e: Event) => { - activeSpotifyAccount = (e.target as HTMLSelectElement).value; - saveConfig(); - }); - (document.getElementById('deezerAccountSelect') as HTMLSelectElement | null)?.addEventListener('change', (e: Event) => { - activeDeezerAccount = (e.target as HTMLSelectElement).value; - saveConfig(); - }); - - // Formatting settings - (document.getElementById('customDirFormat') as HTMLInputElement | null)?.addEventListener('change', saveConfig); - (document.getElementById('customTrackFormat') as HTMLInputElement | null)?.addEventListener('change', saveConfig); - - // Copy to clipboard when selecting placeholders - (document.getElementById('dirFormatHelp') as HTMLSelectElement | null)?.addEventListener('change', function() { - copyPlaceholderToClipboard(this as HTMLSelectElement); - }); - (document.getElementById('trackFormatHelp') as HTMLSelectElement | null)?.addEventListener('change', function() { - copyPlaceholderToClipboard(this as HTMLSelectElement); - }); - - // Max concurrent downloads change listener - (document.getElementById('maxConcurrentDownloads') as HTMLInputElement | null)?.addEventListener('change', saveConfig); - - // Watch options listeners - document.querySelectorAll('#watchedArtistAlbumGroupChecklist input[type="checkbox"]').forEach(checkbox => { - checkbox.addEventListener('change', saveWatchConfig); - }); - (document.getElementById('watchPollIntervalSeconds') as HTMLInputElement | null)?.addEventListener('change', saveWatchConfig); - (document.getElementById('watchEnabledToggle') as HTMLInputElement | null)?.addEventListener('change', () => { - const isEnabling = (document.getElementById('watchEnabledToggle') as HTMLInputElement)?.checked; - const alreadyShownFirstEnableNotice = localStorage.getItem('watchFeatureFirstEnableNoticeShown'); - - if (isEnabling && !alreadyShownFirstEnableNotice) { - const noticeDiv = document.getElementById('watchFeatureFirstEnableNotice'); - if (noticeDiv) noticeDiv.style.display = 'block'; - localStorage.setItem('watchFeatureFirstEnableNoticeShown', 'true'); - // Hide notice after a delay or on click if preferred - setTimeout(() => { - if (noticeDiv) noticeDiv.style.display = 'none'; - }, 15000); // Hide after 15 seconds - } else { - // If disabling, or if notice was already shown, ensure it's hidden - const noticeDiv = document.getElementById('watchFeatureFirstEnableNotice'); - if (noticeDiv) noticeDiv.style.display = 'none'; - } - saveWatchConfig(); - updateWatchWarningDisplay(); // Call this also when the watch enable toggle changes - }); - (document.getElementById('realTimeToggle') as HTMLInputElement | null)?.addEventListener('change', () => { - saveConfig(); - updateWatchWarningDisplay(); // Call this when realTimeToggle changes - }); -} - -function updateServiceSpecificOptions() { - // Get the selected service - const selectedService = (document.getElementById('defaultServiceSelect') as HTMLSelectElement | null)?.value; - - // Handle Spotify specific options - if (selectedService === 'spotify') { - // Highlight Spotify section - (document.getElementById('spotifyQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option'); - (document.getElementById('spotifyAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option'); - - // Remove highlight from Deezer - (document.getElementById('deezerQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option'); - (document.getElementById('deezerAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option'); - } - // Handle Deezer specific options - else if (selectedService === 'deezer') { - // Highlight Deezer section - (document.getElementById('deezerQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option'); - (document.getElementById('deezerAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option'); - - // Remove highlight from Spotify - (document.getElementById('spotifyQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option'); - (document.getElementById('spotifyAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option'); - } -} - -async function updateAccountSelectors() { - try { - const [spotifyResponse, deezerResponse] = await Promise.all([ - fetch('/api/credentials/spotify'), - fetch('/api/credentials/deezer') - ]); - - const spotifyAccounts = await spotifyResponse.json(); - const deezerAccounts = await deezerResponse.json(); - - // Get the select elements - const spotifySelect = document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null; - const deezerSelect = document.getElementById('deezerAccountSelect') as HTMLSelectElement | null; - const spotifyMessage = document.getElementById('spotifyAccountMessage') as HTMLElement | null; - const deezerMessage = document.getElementById('deezerAccountMessage') as HTMLElement | null; - - // Rebuild the Spotify selector options - if (spotifySelect && spotifyMessage) { - if (spotifyAccounts.length > 0) { - spotifySelect.innerHTML = spotifyAccounts - .map((a: string) => ``) - .join(''); - spotifySelect.style.display = ''; - spotifyMessage.style.display = 'none'; - - // Use the active account loaded from the config (activeSpotifyAccount) - if (activeSpotifyAccount && spotifyAccounts.includes(activeSpotifyAccount)) { - spotifySelect.value = activeSpotifyAccount; - } else { - spotifySelect.value = spotifyAccounts[0]; - activeSpotifyAccount = spotifyAccounts[0]; - await saveConfig(); // Save if we defaulted - } - } else { - spotifySelect.innerHTML = ''; - spotifySelect.style.display = 'none'; - spotifyMessage.textContent = 'No Spotify accounts available.'; - spotifyMessage.style.display = ''; - if (activeSpotifyAccount !== '') { // Clear active account if it was set - activeSpotifyAccount = ''; - await saveConfig(); - } - } - } - - // Rebuild the Deezer selector options - if (deezerSelect && deezerMessage) { - if (deezerAccounts.length > 0) { - deezerSelect.innerHTML = deezerAccounts - .map((a: string) => ``) - .join(''); - deezerSelect.style.display = ''; - deezerMessage.style.display = 'none'; - - if (activeDeezerAccount && deezerAccounts.includes(activeDeezerAccount)) { - deezerSelect.value = activeDeezerAccount; - } else { - deezerSelect.value = deezerAccounts[0]; - activeDeezerAccount = deezerAccounts[0]; - await saveConfig(); // Save if we defaulted - } - } else { - deezerSelect.innerHTML = ''; - deezerSelect.style.display = 'none'; - deezerMessage.textContent = 'No Deezer accounts available.'; - deezerMessage.style.display = ''; - if (activeDeezerAccount !== '') { // Clear active account if it was set - activeDeezerAccount = ''; - await saveConfig(); - } - } - } - } catch (error: any) { - showConfigError('Error updating accounts: ' + error.message); - } -} - -async function loadCredentials(service: string) { - try { - const response = await fetch(`/api/credentials/all/${service}`); - if (!response.ok) { - throw new Error(`Failed to load credentials: ${response.statusText}`); - } - - const credentials = await response.json(); - renderCredentialsList(service, credentials); - } catch (error: any) { - showConfigError(error.message); - } -} - -function renderCredentialsList(service: string, credentials: any[]) { - const list = document.querySelector('.credentials-list-items') as HTMLElement | null; - if (!list) return; - list.innerHTML = ''; - - if (!credentials.length) { - list.innerHTML = '
No accounts found. Add a new account below.
'; - return; - } - - credentials.forEach(credData => { - const credItem = document.createElement('div'); - credItem.className = 'credential-item'; - - credItem.innerHTML = ` -
- ${credData.name} -
-
- - -
- `; - - list.appendChild(credItem); - }); - - // Set up event handlers - list.querySelectorAll('.delete-btn').forEach(btn => { - btn.addEventListener('click', handleDeleteCredential as EventListener); - }); - - list.querySelectorAll('.edit-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - isEditingSearch = false; - handleEditCredential(e as MouseEvent); - }); - }); -} - -async function handleDeleteCredential(e: Event) { - try { - const target = e.target as HTMLElement; - const service = target.dataset.service; - const name = target.dataset.name; - - if (!service || !name) { - throw new Error('Missing credential information'); - } - - if (!confirm(`Are you sure you want to delete the ${name} account?`)) { - return; - } - - const response = await fetch(`/api/credentials/${service}/${name}`, { - method: 'DELETE' - }); - - if (!response.ok) { - throw new Error('Failed to delete credential'); - } - - // If the deleted credential is the active account, clear the selection. - const accountSelect = document.getElementById(`${service}AccountSelect`) as HTMLSelectElement | null; - if (accountSelect && accountSelect.value === name) { - accountSelect.value = ''; - if (service === 'spotify') { - activeSpotifyAccount = ''; - } else if (service === 'deezer') { - activeDeezerAccount = ''; - } - await saveConfig(); - } - - loadCredentials(service); - await updateAccountSelectors(); - } catch (error: any) { - showConfigError(error.message); - } -} - -async function handleEditCredential(e: MouseEvent) { - const target = e.target as HTMLElement; - const service = target.dataset.service; - const name = target.dataset.name; // This is the name of the credential being edited - - try { - (document.querySelector(`[data-service="${service}"]`) as HTMLElement | null)?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); - - setFormVisibility(true); - - const response = await fetch(`/api/credentials/${service}/${name}`); - if (!response.ok) { - throw new Error(`Failed to load credential: ${response.statusText}`); - } - - const data = await response.json(); // data = {name, region, blob_content/arl} - - currentCredential = name ? name : null; // Set the global currentCredential to the one being edited - - // Populate the dynamic fields created by updateFormFields - // including 'accountName', 'accountRegion', and 'authBlob' or 'arl'. - if (serviceConfig[service!] && serviceConfig[service!].fields) { - serviceConfig[service!].fields.forEach((fieldConf: { id: string; }) => { - const element = document.getElementById(fieldConf.id) as HTMLInputElement | HTMLTextAreaElement | null; - if (element) { - if (fieldConf.id === 'accountName') { - element.value = data.name || name || ''; // Use data.name from fetched, fallback to clicked name - (element as HTMLInputElement).disabled = true; // Disable editing of account name - } else if (fieldConf.id === 'accountRegion') { - element.value = data.region || ''; - } else if (fieldConf.id === 'authBlob' && service === 'spotify') { - // data.blob_content might be an object or string. Ensure textarea gets string. - element.value = typeof data.blob_content === 'object' ? JSON.stringify(data.blob_content, null, 2) : (data.blob_content || ''); - } else if (fieldConf.id === 'arl' && service === 'deezer') { - element.value = data.arl || ''; - } - // Add more specific population if other fields are introduced - } - }); - } - - (document.getElementById('formTitle') as HTMLElement | null)!.textContent = `Edit ${service!.charAt(0).toUpperCase() + service!.slice(1)} Account`; - (document.getElementById('submitCredentialBtn') as HTMLElement | null)!.textContent = 'Update Account'; - - toggleSearchFieldsVisibility(false); // Ensure old per-account search fields are hidden - } catch (error: any) { - showConfigError(error.message); - } -} - -async function handleEditSearchCredential(e: Event) { - const target = e.target as HTMLElement; - const service = target.dataset.service; - // const name = target.dataset.name; // Account name, not used here anymore - - if (service === 'spotify') { - showConfigError("Spotify API credentials are now managed globally in the 'Global Spotify API Credentials' section."); - // Optionally, scroll to or highlight the global section - const globalSection = document.querySelector('.global-spotify-api-config') as HTMLElement | null; - if (globalSection) globalSection.scrollIntoView({ behavior: 'smooth' }); - } else { - // If this function were ever used for other services, that logic would go here. - console.warn(`handleEditSearchCredential called for unhandled service: ${service} or function is obsolete.`); - } - setFormVisibility(false); // Ensure the main account form is hidden if it was opened. -} - -function toggleSearchFieldsVisibility(showSearchFields: boolean) { - const serviceFieldsDiv = document.getElementById('serviceFields') as HTMLElement | null; - const searchFieldsDiv = document.getElementById('searchFields') as HTMLElement | null; // This div might be removed from HTML if not used by other services - - // Simplified: Always show serviceFields, always hide (old) searchFields in this form context. - // The new global Spotify API fields are in a separate card and handled by different functions. - if(serviceFieldsDiv) serviceFieldsDiv.style.display = 'block'; - if(searchFieldsDiv) searchFieldsDiv.style.display = 'none'; - - // Ensure required attributes are set correctly for visible service fields - if (serviceConfig[currentService] && serviceConfig[currentService].fields) { - serviceConfig[currentService].fields.forEach((field: { id: string }) => { - const input = document.getElementById(field.id) as HTMLInputElement | null; - if (input) input.setAttribute('required', ''); - }); - } - - // Ensure required attributes are removed from (old) search fields as they are hidden - // This is mainly for cleanup if the searchFieldsDiv still exists for some reason. - if (currentService === 'spotify' && serviceConfig[currentService] && serviceConfig[currentService].searchFields) { // This condition will no longer be true for spotify - serviceConfig[currentService].searchFields.forEach((field: { id: string }) => { - const input = document.getElementById(field.id) as HTMLInputElement | null; - if (input) input.removeAttribute('required'); - }); - } -} - -function updateFormFields() { - const serviceFieldsDiv = document.getElementById('serviceFields') as HTMLElement | null; - - if(serviceFieldsDiv) serviceFieldsDiv.innerHTML = ''; - - if (serviceConfig[currentService] && serviceConfig[currentService].fields) { - serviceConfig[currentService].fields.forEach((field: { id: string; label: string; type: string; placeholder?: string; rows?: number; }) => { - const fieldDiv = document.createElement('div'); - fieldDiv.className = 'form-group'; - - let inputElementHTML = ''; - if (field.type === 'textarea') { - inputElementHTML = ``; - } else { - inputElementHTML = ``; - } - // Region field is optional, so remove 'required' if id is 'accountRegion' - if (field.id === 'accountRegion') { - inputElementHTML = inputElementHTML.replace(' required', ''); - } - - fieldDiv.innerHTML = ` - - ${inputElementHTML} - `; - serviceFieldsDiv?.appendChild(fieldDiv); - }); - } - - (document.getElementById('formTitle') as HTMLElement | null)!.textContent = `Add New ${currentService.charAt(0).toUpperCase() + currentService.slice(1)} Account`; - (document.getElementById('submitCredentialBtn') as HTMLElement | null)!.textContent = 'Save Account'; - - toggleSearchFieldsVisibility(false); - isEditingSearch = false; - - // Show/hide region hints based on current service - if (spotifyRegionHint && deezerRegionHint) { - if (currentService === 'spotify') { - spotifyRegionHint.style.display = 'block'; - deezerRegionHint.style.display = 'none'; - } else if (currentService === 'deezer') { - spotifyRegionHint.style.display = 'none'; - deezerRegionHint.style.display = 'block'; - } else { - // Fallback: hide both if service is unrecognized - spotifyRegionHint.style.display = 'none'; - deezerRegionHint.style.display = 'none'; - } - } -} - -function populateFormFields(service: string, data: Record) { - serviceConfig[service].fields.forEach((field: { id: string; }) => { - const element = document.getElementById(field.id) as HTMLInputElement | null; - if (element) element.value = data[field.id] || ''; - }); -} - -async function handleCredentialSubmit(e: Event) { - e.preventDefault(); - const service = (document.querySelector('.tab-button.active') as HTMLElement | null)?.dataset.service; - - // Get the account name from the 'accountName' field within the dynamically generated serviceFields - const accountNameInput = document.getElementById('accountName') as HTMLInputElement | null; - const accountNameValue = accountNameInput?.value.trim(); - - try { - // If we are editing (currentCredential is set), the name comes from currentCredential. - // If we are creating a new one, the name comes from the form's 'accountName' field. - if (!currentCredential && !accountNameValue) { - // Ensure accountNameInput is focused if it's empty during new credential creation - if(accountNameInput && !accountNameValue) accountNameInput.focus(); - throw new Error('Account Name is required'); - } - if (!service) { - throw new Error('Service not selected'); - } - - // For POST (new), endpointName is from form. For PUT (edit), it's from currentCredential. - const endpointName = currentCredential || accountNameValue; - if (!endpointName) { - // This should ideally not be reached if the above check for accountNameValue is done correctly. - throw new Error("Account name could not be determined."); - } - - let method: string, data: any, endpoint: string; - - const formData: Record = {}; - let isValid = true; - let firstInvalidField: HTMLInputElement | HTMLTextAreaElement | null = null; - - const currentServiceFields = serviceConfig[service!]?.fields as Array<{id: string, label: string, type: string}> | undefined; - - if (currentServiceFields) { - currentServiceFields.forEach((field: { id: string; }) => { - const input = document.getElementById(field.id) as HTMLInputElement | HTMLTextAreaElement | null; - const value = input ? input.value.trim() : ''; - formData[field.id] = value; - - const isRequired = input?.hasAttribute('required'); - if (isRequired && !value) { - isValid = false; - if (!firstInvalidField && input) firstInvalidField = input; - } - }); - } else { - throw new Error(`No fields configured for service: ${service}`); - } - - if (!isValid) { - if (firstInvalidField) { - const nonNullInvalidField = firstInvalidField as HTMLInputElement | HTMLTextAreaElement; - nonNullInvalidField.focus(); - const fieldName = (nonNullInvalidField as HTMLInputElement).labels?.[0]?.textContent || nonNullInvalidField.id || 'Unknown field'; - throw new Error(`Field '${fieldName}' is required.`); - } else { - throw new Error('All required fields must be filled, but a specific invalid field was not identified.'); - } - } - - // The validator in serviceConfig now expects fields like 'accountName', 'accountRegion', etc. - data = serviceConfig[service!].validator(formData); - - // If it's a new credential and the validator didn't explicitly set 'name' from 'accountName', - // (though it should: serviceConfig.spotify.validator expects data.accountName and sets 'name') - // we ensure the 'name' in the payload matches accountNameValue if it's a new POST. - // For PUT, the name is part of the URL and shouldn't be in the body unless changing it is allowed. - // The current validators *do* map e.g. data.accountName to data.name in the output object. - // So, `data` should already have the correct `name` field from `accountName` form field. - - endpoint = `/api/credentials/${service}/${endpointName}`; - method = currentCredential ? 'PUT' : 'POST'; - - const response = await fetch(endpoint, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) // Data should contain {name, region, blob_content/arl} - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to save credentials'); - } - - await updateAccountSelectors(); - loadCredentials(service!); - - showConfigSuccess('Account saved successfully'); - - setTimeout(() => { - setFormVisibility(false); - }, 2000); - } catch (error: any) { - showConfigError(error.message); - } -} - -function resetForm() { - currentCredential = null; - isEditingSearch = false; - // The static 'credentialName' input is gone. Resetting the form should clear dynamic fields. - (document.getElementById('credentialForm') as HTMLFormElement | null)?.reset(); - - // Enable the accountName field again if it was disabled during an edit operation - const accountNameInput = document.getElementById('accountName') as HTMLInputElement | null; - if (accountNameInput) { - accountNameInput.disabled = false; - } - - const convertToSelect = document.getElementById('convertToSelect') as HTMLSelectElement | null; - if (convertToSelect) { - convertToSelect.value = ''; - updateBitrateOptions(''); - } - - const serviceName = currentService.charAt(0).toUpperCase() + currentService.slice(1); - (document.getElementById('formTitle') as HTMLElement | null)!.textContent = `Add New ${serviceName} Account`; - (document.getElementById('submitCredentialBtn') as HTMLElement | null)!.textContent = 'Save Account'; - - toggleSearchFieldsVisibility(false); -} - -async function saveConfig() { - // Read active account values directly from the DOM (or from the globals which are kept in sync) - const config = { - service: (document.getElementById('defaultServiceSelect') as HTMLSelectElement | null)?.value, - spotify: (document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null)?.value, - deezer: (document.getElementById('deezerAccountSelect') as HTMLSelectElement | null)?.value, - fallback: (document.getElementById('fallbackToggle') as HTMLInputElement | null)?.checked, - spotifyQuality: (document.getElementById('spotifyQualitySelect') as HTMLSelectElement | null)?.value, - deezerQuality: (document.getElementById('deezerQualitySelect') as HTMLSelectElement | null)?.value, - realTime: (document.getElementById('realTimeToggle') as HTMLInputElement | null)?.checked, - customDirFormat: (document.getElementById('customDirFormat') as HTMLInputElement | null)?.value, - customTrackFormat: (document.getElementById('customTrackFormat') as HTMLInputElement | null)?.value, - maxConcurrentDownloads: parseInt((document.getElementById('maxConcurrentDownloads') as HTMLInputElement | null)?.value || '3', 10) || 3, - maxRetries: parseInt((document.getElementById('maxRetries') as HTMLInputElement | null)?.value || '3', 10) || 3, - retryDelaySeconds: parseInt((document.getElementById('retryDelaySeconds') as HTMLInputElement | null)?.value || '5', 10) || 5, - retry_delay_increase: parseInt((document.getElementById('retryDelayIncrease') as HTMLInputElement | null)?.value || '5', 10) || 5, - tracknum_padding: (document.getElementById('tracknumPaddingToggle') as HTMLInputElement | null)?.checked, - save_cover: (document.getElementById('saveCoverToggle') as HTMLInputElement | null)?.checked, - convertTo: (document.getElementById('convertToSelect') as HTMLSelectElement | null)?.value || null, // Get convertTo value - bitrate: (document.getElementById('bitrateSelect') as HTMLSelectElement | null)?.value || null // Get bitrate value - }; - - try { - const response = await fetch('/api/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config) - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to save config'); - } - - const savedConfig = await response.json(); - - // Set default service selection - const defaultServiceSelect = document.getElementById('defaultServiceSelect') as HTMLSelectElement | null; - if (defaultServiceSelect) defaultServiceSelect.value = savedConfig.service || 'spotify'; - - // Update the service-specific options based on selected service - updateServiceSpecificOptions(); - - // Use the "spotify" and "deezer" properties from the API response to set the active accounts. - activeSpotifyAccount = savedConfig.spotify || ''; - activeDeezerAccount = savedConfig.deezer || ''; - - // (Optionally, if the account selects already exist you can set their values here, - // but updateAccountSelectors() will rebuild the options and set the proper values.) - const spotifySelect = document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null; - const deezerSelect = document.getElementById('deezerAccountSelect') as HTMLSelectElement | null; - if (spotifySelect) spotifySelect.value = activeSpotifyAccount; - if (deezerSelect) deezerSelect.value = activeDeezerAccount; - - // Update other configuration fields. - const fallbackToggle = document.getElementById('fallbackToggle') as HTMLInputElement | null; - if (fallbackToggle) fallbackToggle.checked = !!savedConfig.fallback; - const spotifyQualitySelect = document.getElementById('spotifyQualitySelect') as HTMLSelectElement | null; - if (spotifyQualitySelect) spotifyQualitySelect.value = savedConfig.spotifyQuality || 'NORMAL'; - const deezerQualitySelect = document.getElementById('deezerQualitySelect') as HTMLSelectElement | null; - if (deezerQualitySelect) deezerQualitySelect.value = savedConfig.deezerQuality || 'MP3_128'; - const realTimeToggle = document.getElementById('realTimeToggle') as HTMLInputElement | null; - if (realTimeToggle) realTimeToggle.checked = !!savedConfig.realTime; - const customDirFormat = document.getElementById('customDirFormat') as HTMLInputElement | null; - if (customDirFormat) customDirFormat.value = savedConfig.customDirFormat || '%ar_album%/%album%'; - const customTrackFormat = document.getElementById('customTrackFormat') as HTMLInputElement | null; - if (customTrackFormat) customTrackFormat.value = savedConfig.customTrackFormat || '%tracknum%. %music%'; - const maxConcurrentDownloads = document.getElementById('maxConcurrentDownloads') as HTMLInputElement | null; - if (maxConcurrentDownloads) maxConcurrentDownloads.value = savedConfig.maxConcurrentDownloads || '3'; - const maxRetries = document.getElementById('maxRetries') as HTMLInputElement | null; - if (maxRetries) maxRetries.value = savedConfig.maxRetries || '3'; - const retryDelaySeconds = document.getElementById('retryDelaySeconds') as HTMLInputElement | null; - if (retryDelaySeconds) retryDelaySeconds.value = savedConfig.retryDelaySeconds || '5'; - const retryDelayIncrease = document.getElementById('retryDelayIncrease') as HTMLInputElement | null; - if (retryDelayIncrease) retryDelayIncrease.value = savedConfig.retry_delay_increase || '5'; - const tracknumPaddingToggle = document.getElementById('tracknumPaddingToggle') as HTMLInputElement | null; - if (tracknumPaddingToggle) tracknumPaddingToggle.checked = savedConfig.tracknum_padding === undefined ? true : !!savedConfig.tracknum_padding; - const saveCoverToggle = document.getElementById('saveCoverToggle') as HTMLInputElement | null; - if (saveCoverToggle) saveCoverToggle.checked = savedConfig.save_cover === undefined ? true : !!savedConfig.save_cover; - - // Load conversion settings after save - const convertToSelect = document.getElementById('convertToSelect') as HTMLSelectElement | null; - if (convertToSelect) { - convertToSelect.value = savedConfig.convertTo || ''; - updateBitrateOptions(convertToSelect.value); - } - const bitrateSelect = document.getElementById('bitrateSelect') as HTMLSelectElement | null; - if (bitrateSelect && savedConfig.bitrate) { - if (Array.from(bitrateSelect.options).some(option => option.value === savedConfig.bitrate)) { - bitrateSelect.value = savedConfig.bitrate; - } - } else if (bitrateSelect) { - if (convertToSelect && !CONVERSION_FORMATS[convertToSelect.value]?.length) { - bitrateSelect.value = ''; - } - } - - // Update explicit filter status - updateExplicitFilterStatus(savedConfig.explicitFilter); - - // Load watch config - await loadWatchConfig(); - } catch (error: any) { - showConfigError('Error loading config: ' + error.message); - } -} - -function updateExplicitFilterStatus(isEnabled: boolean) { - const statusElement = document.getElementById('explicitFilterStatus'); - if (statusElement) { - // Remove existing classes - statusElement.classList.remove('enabled', 'disabled'); - - // Add appropriate class and text based on whether filter is enabled - if (isEnabled) { - statusElement.textContent = 'Enabled'; - statusElement.classList.add('enabled'); - } else { - statusElement.textContent = 'Disabled'; - statusElement.classList.add('disabled'); - } - } -} - -function showConfigError(message: string) { - const errorDiv = document.getElementById('configError'); - if (errorDiv) errorDiv.textContent = message; - setTimeout(() => { if (errorDiv) errorDiv.textContent = '' }, 5000); -} - -function showConfigSuccess(message: string) { - const successDiv = document.getElementById('configSuccess'); - if (successDiv) successDiv.textContent = message; - setTimeout(() => { if (successDiv) successDiv.textContent = '' }, 5000); -} - -// Function to copy the selected placeholder to clipboard -function copyPlaceholderToClipboard(select: HTMLSelectElement) { - const placeholder = select.value; - - if (!placeholder) return; // If nothing selected - - // Copy to clipboard - navigator.clipboard.writeText(placeholder) - .then(() => { - // Show success notification - showCopyNotification(`Copied ${placeholder} to clipboard`); - - // Reset select to default after a short delay - setTimeout(() => { - select.selectedIndex = 0; - }, 500); - }) - .catch(err => { - console.error('Failed to copy: ', err); - }); -} - -// Function to show a notification when copying -function showCopyNotification(message: string) { - // Check if notification container exists, create if not - let notificationContainer = document.getElementById('copyNotificationContainer'); - if (!notificationContainer) { - notificationContainer = document.createElement('div'); - notificationContainer.id = 'copyNotificationContainer'; - document.body.appendChild(notificationContainer); - } - - // Create notification element - const notification = document.createElement('div'); - notification.className = 'copy-notification'; - notification.textContent = message; - - // Add to container - notificationContainer.appendChild(notification); - - // Trigger animation - setTimeout(() => { - notification.classList.add('show'); - }, 10); - - // Remove after animation completes - setTimeout(() => { - notification.classList.remove('show'); - setTimeout(() => { - notificationContainer.removeChild(notification); - }, 300); - }, 2000); -} - -async function loadWatchConfig() { - try { - const response = await fetch('/api/config/watch'); - if (!response.ok) throw new Error('Failed to load watch config'); - const watchConfig = await response.json(); - - const checklistContainer = document.getElementById('watchedArtistAlbumGroupChecklist'); - if (checklistContainer && watchConfig.watchedArtistAlbumGroup) { - const checkboxes = checklistContainer.querySelectorAll('input[type="checkbox"]') as NodeListOf; - checkboxes.forEach(checkbox => { - checkbox.checked = watchConfig.watchedArtistAlbumGroup.includes(checkbox.value); - }); - } - - const watchPollIntervalSecondsInput = document.getElementById('watchPollIntervalSeconds') as HTMLInputElement | null; - if (watchPollIntervalSecondsInput) { - watchPollIntervalSecondsInput.value = watchConfig.watchPollIntervalSeconds || '3600'; - } - - const watchEnabledToggle = document.getElementById('watchEnabledToggle') as HTMLInputElement | null; - if (watchEnabledToggle) { - watchEnabledToggle.checked = !!watchConfig.enabled; - } - - // Call this after the state of the toggles has been set based on watchConfig - updateWatchWarningDisplay(); - - } catch (error: any) { - showConfigError('Error loading watch config: ' + error.message); - } -} - -async function saveWatchConfig() { - const checklistContainer = document.getElementById('watchedArtistAlbumGroupChecklist'); - const selectedGroups: string[] = []; - if (checklistContainer) { - const checkedBoxes = checklistContainer.querySelectorAll('input[type="checkbox"]:checked') as NodeListOf; - checkedBoxes.forEach(checkbox => selectedGroups.push(checkbox.value)); - } - - const watchConfig = { - enabled: (document.getElementById('watchEnabledToggle') as HTMLInputElement | null)?.checked, - watchedArtistAlbumGroup: selectedGroups, - watchPollIntervalSeconds: parseInt((document.getElementById('watchPollIntervalSeconds') as HTMLInputElement | null)?.value || '3600', 10) || 3600, - }; - - try { - const response = await fetch('/api/config/watch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(watchConfig) - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to save watch config'); - } - showConfigSuccess('Watch settings saved successfully.'); - } catch (error: any) { - showConfigError('Error saving watch config: ' + error.message); - } -} - -// New function to manage the warning display -function updateWatchWarningDisplay() { - const watchEnabledToggle = document.getElementById('watchEnabledToggle') as HTMLInputElement | null; - const realTimeToggle = document.getElementById('realTimeToggle') as HTMLInputElement | null; - const warningDiv = document.getElementById('watchEnabledWarning') as HTMLElement | null; - - if (watchEnabledToggle && realTimeToggle && warningDiv) { - const isWatchEnabled = watchEnabledToggle.checked; - const isRealTimeEnabled = realTimeToggle.checked; - - if (isWatchEnabled && !isRealTimeEnabled) { - warningDiv.style.display = 'block'; - } else { - warningDiv.style.display = 'none'; - } - } - // Hide the first-enable notice if watch is disabled or if it was already dismissed by timeout/interaction - // The primary logic for showing first-enable notice is in the event listener for watchEnabledToggle - const firstEnableNoticeDiv = document.getElementById('watchFeatureFirstEnableNotice'); - if (firstEnableNoticeDiv && watchEnabledToggle && !watchEnabledToggle.checked) { - firstEnableNoticeDiv.style.display = 'none'; - } -} - -// Function to update bitrate options based on selected format -function updateBitrateOptions(selectedFormat: string) { - const bitrateSelect = document.getElementById('bitrateSelect') as HTMLSelectElement | null; - if (!bitrateSelect) return; - - bitrateSelect.innerHTML = ''; // Clear existing options - const currentBitrateValue = bitrateSelect.value; // Preserve current value if possible - - if (selectedFormat && CONVERSION_FORMATS[selectedFormat] && CONVERSION_FORMATS[selectedFormat].length > 0) { - bitrateSelect.disabled = false; - CONVERSION_FORMATS[selectedFormat].forEach(bRate => { - const option = document.createElement('option'); - option.value = bRate; - option.textContent = bRate; - bitrateSelect.appendChild(option); - }); - // Try to restore previous valid bitrate or set to first available - if (CONVERSION_FORMATS[selectedFormat].includes(currentBitrateValue)) { - bitrateSelect.value = currentBitrateValue; - } else { - bitrateSelect.value = CONVERSION_FORMATS[selectedFormat][0]; // Default to first available bitrate - } - } else { - // For formats with no specific bitrates (FLAC, WAV, ALAC) or 'No Conversion' - bitrateSelect.disabled = true; - const option = document.createElement('option'); - option.value = ''; - option.textContent = 'N/A'; - bitrateSelect.appendChild(option); - bitrateSelect.value = ''; - } -} - -// Function to load global Spotify API credentials -async function loadSpotifyApiConfig() { - const clientIdInput = document.getElementById('globalSpotifyClientId') as HTMLInputElement | null; - const clientSecretInput = document.getElementById('globalSpotifyClientSecret') as HTMLInputElement | null; - spotifyApiConfigStatusDiv = document.getElementById('spotifyApiConfigStatus') as HTMLElement | null; // Assign here or ensure it's globally available - - if (!clientIdInput || !clientSecretInput || !spotifyApiConfigStatusDiv) { - console.error("Global Spotify API config form elements not found."); - if(spotifyApiConfigStatusDiv) spotifyApiConfigStatusDiv.textContent = 'Error: Form elements missing.'; - return; - } - - try { - const response = await fetch('/api/credentials/spotify_api_config'); - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: 'Failed to load Spotify API config, server error.' })); - throw new Error(errorData.error || `HTTP error ${response.status}`); - } - const data = await response.json(); - clientIdInput.value = data.client_id || ''; - clientSecretInput.value = data.client_secret || ''; - if (data.warning) { - spotifyApiConfigStatusDiv.textContent = data.warning; - spotifyApiConfigStatusDiv.className = 'status-message warning'; - } else if (data.client_id && data.client_secret) { - spotifyApiConfigStatusDiv.textContent = 'Current API credentials loaded.'; - spotifyApiConfigStatusDiv.className = 'status-message success'; - } else { - spotifyApiConfigStatusDiv.textContent = 'Global Spotify API credentials are not set.'; - spotifyApiConfigStatusDiv.className = 'status-message neutral'; - } - } catch (error: any) { - console.error('Error loading Spotify API config:', error); - if(spotifyApiConfigStatusDiv) { - spotifyApiConfigStatusDiv.textContent = `Error loading config: ${error.message}`; - spotifyApiConfigStatusDiv.className = 'status-message error'; - } - } -} - -// Function to save global Spotify API credentials -async function saveSpotifyApiConfig() { - const clientIdInput = document.getElementById('globalSpotifyClientId') as HTMLInputElement | null; - const clientSecretInput = document.getElementById('globalSpotifyClientSecret') as HTMLInputElement | null; - // spotifyApiConfigStatusDiv should be already assigned by loadSpotifyApiConfig or be a global var - if (!spotifyApiConfigStatusDiv) { // Re-fetch if null, though it should not be if load ran. - spotifyApiConfigStatusDiv = document.getElementById('spotifyApiConfigStatus') as HTMLElement | null; - } - - if (!clientIdInput || !clientSecretInput || !spotifyApiConfigStatusDiv) { - console.error("Global Spotify API config form elements not found for saving."); - if(spotifyApiConfigStatusDiv) spotifyApiConfigStatusDiv.textContent = 'Error: Form elements missing.'; - return; - } - - const client_id = clientIdInput.value.trim(); - const client_secret = clientSecretInput.value.trim(); - - if (!client_id || !client_secret) { - spotifyApiConfigStatusDiv.textContent = 'Client ID and Client Secret cannot be empty.'; - spotifyApiConfigStatusDiv.className = 'status-message error'; - if(!client_id) clientIdInput.focus(); else clientSecretInput.focus(); - return; - } - - try { - spotifyApiConfigStatusDiv.textContent = 'Saving...'; - spotifyApiConfigStatusDiv.className = 'status-message neutral'; - - const response = await fetch('/api/credentials/spotify_api_config', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ client_id, client_secret }) - }); - - const responseData = await response.json(); // Try to parse JSON regardless of ok status for error messages - - if (!response.ok) { - throw new Error(responseData.error || `Failed to save Spotify API config. Status: ${response.status}`); - } - - spotifyApiConfigStatusDiv.textContent = responseData.message || 'Spotify API credentials saved successfully!'; - spotifyApiConfigStatusDiv.className = 'status-message success'; - } catch (error: any) { - console.error('Error saving Spotify API config:', error); - if(spotifyApiConfigStatusDiv) { - spotifyApiConfigStatusDiv.textContent = `Error saving: ${error.message}`; - spotifyApiConfigStatusDiv.className = 'status-message error'; - } - } -} diff --git a/src/js/history.ts b/src/js/history.ts deleted file mode 100644 index 0ec9984..0000000 --- a/src/js/history.ts +++ /dev/null @@ -1,188 +0,0 @@ -document.addEventListener('DOMContentLoaded', () => { - const historyTableBody = document.getElementById('history-table-body') as HTMLTableSectionElement | null; - const prevButton = document.getElementById('prev-page') as HTMLButtonElement | null; - const nextButton = document.getElementById('next-page') as HTMLButtonElement | null; - const pageInfo = document.getElementById('page-info') as HTMLSpanElement | null; - const limitSelect = document.getElementById('limit-select') as HTMLSelectElement | null; - const statusFilter = document.getElementById('status-filter') as HTMLSelectElement | null; - const typeFilter = document.getElementById('type-filter') as HTMLSelectElement | null; - - let currentPage = 1; - let limit = 25; - let totalEntries = 0; - let currentSortBy = 'timestamp_completed'; - let currentSortOrder = 'DESC'; - - async function fetchHistory(page = 1) { - if (!historyTableBody || !prevButton || !nextButton || !pageInfo || !limitSelect || !statusFilter || !typeFilter) { - console.error('One or more critical UI elements are missing for history page.'); - return; - } - - const offset = (page - 1) * limit; - let apiUrl = `/api/history?limit=${limit}&offset=${offset}&sort_by=${currentSortBy}&sort_order=${currentSortOrder}`; - - const statusVal = statusFilter.value; - if (statusVal) { - apiUrl += `&status_final=${statusVal}`; - } - const typeVal = typeFilter.value; - if (typeVal) { - apiUrl += `&download_type=${typeVal}`; - } - - try { - const response = await fetch(apiUrl); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data = await response.json(); - renderHistory(data.entries); - totalEntries = data.total_count; - currentPage = Math.floor(offset / limit) + 1; - updatePagination(); - updateSortIndicators(); - } catch (error) { - console.error('Error fetching history:', error); - if (historyTableBody) { - historyTableBody.innerHTML = 'Error loading history.'; - } - } - } - - function renderHistory(entries: any[]) { - if (!historyTableBody) return; - - historyTableBody.innerHTML = ''; // Clear existing rows - if (!entries || entries.length === 0) { - historyTableBody.innerHTML = 'No history entries found.'; - return; - } - - entries.forEach(entry => { - const row = historyTableBody.insertRow(); - row.insertCell().textContent = entry.item_name || 'N/A'; - row.insertCell().textContent = entry.item_artist || 'N/A'; - row.insertCell().textContent = entry.download_type ? entry.download_type.charAt(0).toUpperCase() + entry.download_type.slice(1) : 'N/A'; - row.insertCell().textContent = entry.service_used || 'N/A'; - // Construct Quality display string - let qualityDisplay = entry.quality_profile || 'N/A'; - if (entry.convert_to) { - qualityDisplay = `${entry.convert_to.toUpperCase()}`; - if (entry.bitrate) { - qualityDisplay += ` ${entry.bitrate}k`; - } - qualityDisplay += ` (${entry.quality_profile || 'Original'})`; - } else if (entry.bitrate) { // Case where convert_to might not be set, but bitrate is (e.g. for OGG Vorbis quality settings) - qualityDisplay = `${entry.bitrate}k (${entry.quality_profile || 'Profile'})`; - } - row.insertCell().textContent = qualityDisplay; - - const statusCell = row.insertCell(); - statusCell.textContent = entry.status_final || 'N/A'; - statusCell.className = `status-${entry.status_final}`; - - row.insertCell().textContent = entry.timestamp_added ? new Date(entry.timestamp_added * 1000).toLocaleString() : 'N/A'; - row.insertCell().textContent = entry.timestamp_completed ? new Date(entry.timestamp_completed * 1000).toLocaleString() : 'N/A'; - - const detailsCell = row.insertCell(); - const detailsButton = document.createElement('button'); - detailsButton.innerHTML = `Details`; - detailsButton.className = 'details-btn btn-icon'; - detailsButton.title = 'Show Details'; - detailsButton.onclick = () => showDetailsModal(entry); - detailsCell.appendChild(detailsButton); - - if (entry.status_final === 'ERROR' && entry.error_message) { - const errorSpan = document.createElement('span'); - errorSpan.textContent = ' (Show Error)'; - errorSpan.className = 'error-message-toggle'; - errorSpan.style.marginLeft = '5px'; - errorSpan.onclick = (e) => { - e.stopPropagation(); // Prevent click on row if any - let errorDetailsDiv = row.querySelector('.error-details') as HTMLElement | null; - if (!errorDetailsDiv) { - errorDetailsDiv = document.createElement('div'); - errorDetailsDiv.className = 'error-details'; - const newCell = row.insertCell(); // This will append to the end of the row - newCell.colSpan = 9; // Span across all columns - newCell.appendChild(errorDetailsDiv); - // Visually, this new cell will be after the 'Details' button cell. - // To make it appear as part of the status cell or below the row, more complex DOM manipulation or CSS would be needed. - } - errorDetailsDiv.textContent = entry.error_message; - // Toggle display by directly manipulating the style of the details div - errorDetailsDiv.style.display = errorDetailsDiv.style.display === 'none' ? 'block' : 'none'; - }; - statusCell.appendChild(errorSpan); - } - }); - } - - function updatePagination() { - if (!pageInfo || !prevButton || !nextButton) return; - - const totalPages = Math.ceil(totalEntries / limit) || 1; - pageInfo.textContent = `Page ${currentPage} of ${totalPages}`; - prevButton.disabled = currentPage === 1; - nextButton.disabled = currentPage === totalPages; - } - - function showDetailsModal(entry: any) { - const details = `Task ID: ${entry.task_id}\n` + - `Type: ${entry.download_type}\n` + - `Name: ${entry.item_name}\n` + - `Artist: ${entry.item_artist}\n` + - `Album: ${entry.item_album || 'N/A'}\n` + - `URL: ${entry.item_url}\n` + - `Spotify ID: ${entry.spotify_id || 'N/A'}\n` + - `Service Used: ${entry.service_used || 'N/A'}\n` + - `Quality Profile (Original): ${entry.quality_profile || 'N/A'}\n` + - `ConvertTo: ${entry.convert_to || 'N/A'}\n` + - `Bitrate: ${entry.bitrate ? entry.bitrate + 'k' : 'N/A'}\n` + - `Status: ${entry.status_final}\n` + - `Error: ${entry.error_message || 'None'}\n` + - `Added: ${new Date(entry.timestamp_added * 1000).toLocaleString()}\n` + - `Completed/Ended: ${new Date(entry.timestamp_completed * 1000).toLocaleString()}\n\n` + - `Original Request: ${JSON.stringify(JSON.parse(entry.original_request_json || '{}'), null, 2)}\n\n` + - `Last Status Object: ${JSON.stringify(JSON.parse(entry.last_status_obj_json || '{}'), null, 2)}`; - alert(details); - } - - document.querySelectorAll('th[data-sort]').forEach(headerCell => { - headerCell.addEventListener('click', () => { - const sortField = (headerCell as HTMLElement).dataset.sort; - if (!sortField) return; - - if (currentSortBy === sortField) { - currentSortOrder = currentSortOrder === 'ASC' ? 'DESC' : 'ASC'; - } else { - currentSortBy = sortField; - currentSortOrder = 'DESC'; - } - fetchHistory(1); - }); - }); - - function updateSortIndicators() { - document.querySelectorAll('th[data-sort]').forEach(headerCell => { - const th = headerCell as HTMLElement; - th.classList.remove('sort-asc', 'sort-desc'); - if (th.dataset.sort === currentSortBy) { - th.classList.add(currentSortOrder === 'ASC' ? 'sort-asc' : 'sort-desc'); - } - }); - } - - prevButton?.addEventListener('click', () => fetchHistory(currentPage - 1)); - nextButton?.addEventListener('click', () => fetchHistory(currentPage + 1)); - limitSelect?.addEventListener('change', (e) => { - limit = parseInt((e.target as HTMLSelectElement).value, 10); - fetchHistory(1); - }); - statusFilter?.addEventListener('change', () => fetchHistory(1)); - typeFilter?.addEventListener('change', () => fetchHistory(1)); - - // Initial fetch - fetchHistory(); -}); \ No newline at end of file diff --git a/src/js/main.ts b/src/js/main.ts deleted file mode 100644 index 1b2ec0f..0000000 --- a/src/js/main.ts +++ /dev/null @@ -1,626 +0,0 @@ -// main.ts -import { downloadQueue } from './queue.js'; - -// Define interfaces for API data and search results -interface Image { - url: string; - height?: number; - width?: number; -} - -interface Artist { - id?: string; // Artist ID might not always be present in search results for track artists - name: string; - external_urls?: { spotify?: string }; - genres?: string[]; // For artist type results -} - -interface Album { - id?: string; // Album ID might not always be present - name: string; - images?: Image[]; - album_type?: string; // Used in startDownload - artists?: Artist[]; // Album can have artists too - total_tracks?: number; - release_date?: string; - external_urls?: { spotify?: string }; -} - -interface Track { - id: string; - name: string; - artists: Artist[]; - album: Album; - duration_ms?: number; - explicit?: boolean; - external_urls: { spotify: string }; - href?: string; // Some spotify responses use href -} - -interface Playlist { - id: string; - name: string; - owner: { display_name?: string; id?: string }; - images?: Image[]; - tracks: { total: number }; // Simplified for search results - external_urls: { spotify: string }; - href?: string; // Some spotify responses use href - explicit?: boolean; // Playlists themselves aren't explicit, but items can be -} - -// Specific item types for search results -interface TrackResultItem extends Track {} -interface AlbumResultItem extends Album { id: string; images?: Image[]; explicit?: boolean; external_urls: { spotify: string }; href?: string; } -interface PlaylistResultItem extends Playlist {} -interface ArtistResultItem extends Artist { id: string; images?: Image[]; explicit?: boolean; external_urls: { spotify: string }; href?: string; followers?: { total: number }; } - -// Union type for any search result item -type SearchResultItem = TrackResultItem | AlbumResultItem | PlaylistResultItem | ArtistResultItem; - -// Interface for the API response structure -interface SearchResponse { - items: SearchResultItem[]; - // Add other top-level properties from the search API if needed (e.g., total, limit, offset) -} - -// Interface for the item passed to downloadQueue.download -interface DownloadQueueItem { - name: string; - artist?: string; - album?: { name: string; album_type?: string }; -} - -document.addEventListener('DOMContentLoaded', function() { - // DOM elements - const searchInput = document.getElementById('searchInput') as HTMLInputElement | null; - const searchButton = document.getElementById('searchButton') as HTMLButtonElement | null; - const searchType = document.getElementById('searchType') as HTMLSelectElement | null; - const resultsContainer = document.getElementById('resultsContainer'); - const queueIcon = document.getElementById('queueIcon'); - const emptyState = document.getElementById('emptyState'); - const loadingResults = document.getElementById('loadingResults'); - const watchlistButton = document.getElementById('watchlistButton') as HTMLAnchorElement | null; - - // Initialize the queue - if (queueIcon) { - queueIcon.addEventListener('click', () => { - downloadQueue.toggleVisibility(); - }); - } - - // Add event listeners - if (searchButton) { - searchButton.addEventListener('click', performSearch); - } - - if (searchInput) { - searchInput.addEventListener('keypress', function(e: KeyboardEvent) { - if (e.key === 'Enter') { - performSearch(); - } - }); - - // Auto-detect and handle pasted Spotify URLs - searchInput.addEventListener('input', function(e: Event) { - const target = e.target as HTMLInputElement; - const inputVal = target.value.trim(); - if (isSpotifyUrl(inputVal)) { - const details = getSpotifyResourceDetails(inputVal); - if (details && searchType) { - searchType.value = details.type; - } - } - }); - } - - // Restore last search type if no URL override - const savedType = localStorage.getItem('lastSearchType'); - if (searchType && savedType && ['track','album','playlist','artist'].includes(savedType)) { - searchType.value = savedType; - } - // Save last selection on change - if (searchType) { - searchType.addEventListener('change', () => { - localStorage.setItem('lastSearchType', searchType.value); - }); - } - - // Attempt to set initial watchlist button visibility from cache - if (watchlistButton) { - const cachedWatchEnabled = localStorage.getItem('spotizerr_watch_enabled_cached'); - if (cachedWatchEnabled === 'true') { - watchlistButton.classList.remove('hidden'); - } - } - - // Fetch watch config to determine if watchlist button should be visible - async function updateWatchlistButtonVisibility() { - if (watchlistButton) { - try { - const response = await fetch('/api/config/watch'); - if (response.ok) { - const watchConfig = await response.json(); - localStorage.setItem('spotizerr_watch_enabled_cached', watchConfig.enabled ? 'true' : 'false'); - if (watchConfig && watchConfig.enabled === false) { - watchlistButton.classList.add('hidden'); - } else { - watchlistButton.classList.remove('hidden'); // Ensure it's shown if enabled - } - } else { - console.error('Failed to fetch watch config, defaulting to hidden'); - // Don't update cache on error, rely on default hidden or previous cache state until success - watchlistButton.classList.add('hidden'); // Hide if config fetch fails - } - } catch (error) { - console.error('Error fetching watch config:', error); - // Don't update cache on error - watchlistButton.classList.add('hidden'); // Hide on error - } - } - } - updateWatchlistButtonVisibility(); - - // Check for URL parameters - const urlParams = new URLSearchParams(window.location.search); - const query = urlParams.get('q'); - const type = urlParams.get('type'); - - if (query && searchInput) { - searchInput.value = query; - if (type && searchType && ['track', 'album', 'playlist', 'artist'].includes(type)) { - searchType.value = type; - } - performSearch(); - } else { - // Show empty state if no query - showEmptyState(true); - } - - /** - * Performs the search based on input values - */ - async function performSearch() { - const currentQuery = searchInput?.value.trim(); - if (!currentQuery) return; - - // Handle direct Spotify URLs - if (isSpotifyUrl(currentQuery)) { - const details = getSpotifyResourceDetails(currentQuery); - if (details && details.id) { - // Redirect to the appropriate page - window.location.href = `/${details.type}/${details.id}`; - return; - } - } - - // Update URL without reloading page - const currentSearchType = searchType?.value || 'track'; - const newUrl = `${window.location.pathname}?q=${encodeURIComponent(currentQuery)}&type=${currentSearchType}`; - window.history.pushState({ path: newUrl }, '', newUrl); - - // Show loading state - showEmptyState(false); - showLoading(true); - if(resultsContainer) resultsContainer.innerHTML = ''; - - try { - const url = `/api/search?q=${encodeURIComponent(currentQuery)}&search_type=${currentSearchType}&limit=40`; - const response = await fetch(url); - - if (!response.ok) { - throw new Error('Network response was not ok'); - } - - const data = await response.json() as SearchResponse; // Assert type for API response - - // Hide loading indicator - showLoading(false); - - // Render results - if (data && data.items && data.items.length > 0) { - if(resultsContainer) resultsContainer.innerHTML = ''; - - // Filter out items with null/undefined essential display parameters - const validItems = filterValidItems(data.items, currentSearchType); - - if (validItems.length === 0) { - // No valid items found after filtering - if(resultsContainer) resultsContainer.innerHTML = ` -
-

No valid results found for "${currentQuery}"

-
- `; - return; - } - - validItems.forEach((item, index) => { - const cardElement = createResultCard(item, currentSearchType, index); - - // Store the item data directly on the button element - const downloadBtn = cardElement.querySelector('.download-btn') as HTMLButtonElement | null; - if (downloadBtn) { - downloadBtn.dataset.itemIndex = index.toString(); - } - - if(resultsContainer) resultsContainer.appendChild(cardElement); - }); - - // Attach download handlers to the newly created cards - attachDownloadListeners(validItems); - } else { - // No results found - if(resultsContainer) resultsContainer.innerHTML = ` -
-

No results found for "${currentQuery}"

-
- `; - } - } catch (error: any) { - console.error('Error:', error); - showLoading(false); - if(resultsContainer) resultsContainer.innerHTML = ` -
-

Error searching: ${error.message}

-
- `; - } - } - - /** - * Filters out items with null/undefined essential display parameters based on search type - */ - function filterValidItems(items: SearchResultItem[], type: string): SearchResultItem[] { - if (!items) return []; - - return items.filter(item => { - // Skip null/undefined items - if (!item) return false; - - // Skip explicit content if filter is enabled - if (downloadQueue.isExplicitFilterEnabled() && ('explicit' in item && item.explicit === true)) { - return false; - } - - // Check essential parameters based on search type - switch (type) { - case 'track': - const trackItem = item as TrackResultItem; - return ( - trackItem.name && - trackItem.artists && - trackItem.artists.length > 0 && - trackItem.artists[0] && - trackItem.artists[0].name && - trackItem.album && - trackItem.album.name && - trackItem.external_urls && - trackItem.external_urls.spotify - ); - - case 'album': - const albumItem = item as AlbumResultItem; - return ( - albumItem.name && - albumItem.artists && - albumItem.artists.length > 0 && - albumItem.artists[0] && - albumItem.artists[0].name && - albumItem.external_urls && - albumItem.external_urls.spotify - ); - - case 'playlist': - const playlistItem = item as PlaylistResultItem; - return ( - playlistItem.name && - playlistItem.owner && - playlistItem.owner.display_name && - playlistItem.tracks && - playlistItem.external_urls && - playlistItem.external_urls.spotify - ); - - case 'artist': - const artistItem = item as ArtistResultItem; - return ( - artistItem.name && - artistItem.external_urls && - artistItem.external_urls.spotify - ); - - default: - // Default case - just check if the item exists (already handled by `if (!item) return false;`) - return true; - } - }); - } - - /** - * Attaches download handlers to result cards - */ - function attachDownloadListeners(items: SearchResultItem[]) { - document.querySelectorAll('.download-btn').forEach((btnElm) => { - const btn = btnElm as HTMLButtonElement; - btn.addEventListener('click', (e: Event) => { - e.stopPropagation(); - - // Get the item index from the button's dataset - const itemIndexStr = btn.dataset.itemIndex; - if (!itemIndexStr) return; - const itemIndex = parseInt(itemIndexStr, 10); - - // Get the corresponding item - const item = items[itemIndex]; - if (!item) return; - - const currentSearchType = searchType?.value || 'track'; - let itemId = item.id || ''; // Use item.id directly - - if (!itemId) { // Check if ID was found - showError('Could not determine download ID'); - return; - } - - // Prepare metadata for the download - let metadata: DownloadQueueItem; - if (currentSearchType === 'track') { - const trackItem = item as TrackResultItem; - metadata = { - name: trackItem.name || 'Unknown', - artist: trackItem.artists ? trackItem.artists[0]?.name : undefined, - album: trackItem.album ? { name: trackItem.album.name, album_type: trackItem.album.album_type } : undefined - }; - } else if (currentSearchType === 'album') { - const albumItem = item as AlbumResultItem; - metadata = { - name: albumItem.name || 'Unknown', - artist: albumItem.artists ? albumItem.artists[0]?.name : undefined, - album: { name: albumItem.name, album_type: albumItem.album_type} - }; - } else if (currentSearchType === 'playlist') { - const playlistItem = item as PlaylistResultItem; - metadata = { - name: playlistItem.name || 'Unknown', - // artist for playlist is owner - artist: playlistItem.owner?.display_name - }; - } else if (currentSearchType === 'artist') { - const artistItem = item as ArtistResultItem; - metadata = { - name: artistItem.name || 'Unknown', - artist: artistItem.name // For artist type, artist is the item name itself - }; - } else { - metadata = { name: item.name || 'Unknown' }; // Fallback - } - - // Disable the button and update text - btn.disabled = true; - - // For artist downloads, show a different message since it will queue multiple albums - if (currentSearchType === 'artist') { - btn.innerHTML = 'Queueing albums...'; - } else { - btn.innerHTML = 'Queueing...'; - } - - // Start the download - startDownload(itemId, currentSearchType, metadata, - (item as AlbumResultItem).album_type || ((item as TrackResultItem).album ? (item as TrackResultItem).album.album_type : null)) - .then(() => { - // For artists, show how many albums were queued - if (currentSearchType === 'artist') { - btn.innerHTML = 'Albums queued!'; - // Open the queue automatically for artist downloads - downloadQueue.toggleVisibility(true); - } else { - btn.innerHTML = 'Queued!'; - } - }) - .catch((error: any) => { - btn.disabled = false; - btn.innerHTML = 'Download'; - showError('Failed to queue download: ' + error.message); - }); - }); - }); - } - - /** - * Starts the download process via API - */ - async function startDownload(itemId: string, type: string, item: DownloadQueueItem, albumType: string | null | undefined) { - if (!itemId || !type) { - showError('Missing ID or type for download'); - return; - } - - try { - // Use the centralized downloadQueue.download method - await downloadQueue.download(itemId, type, item, albumType); - - // Make the queue visible after queueing - downloadQueue.toggleVisibility(true); - } catch (error: any) { - showError('Download failed: ' + (error.message || 'Unknown error')); - throw error; - } - } - - /** - * Shows an error message - */ - function showError(message: string) { - const errorDiv = document.createElement('div'); - errorDiv.className = 'error'; - errorDiv.textContent = message; - document.body.appendChild(errorDiv); - - // Auto-remove after 5 seconds - setTimeout(() => errorDiv.remove(), 5000); - } - - /** - * Shows a success message - */ - function showSuccess(message: string) { - const successDiv = document.createElement('div'); - successDiv.className = 'success'; - successDiv.textContent = message; - document.body.appendChild(successDiv); - - // Auto-remove after 5 seconds - setTimeout(() => successDiv.remove(), 5000); - } - - /** - * Checks if a string is a valid Spotify URL - */ - function isSpotifyUrl(url: string): boolean { - return url.includes('open.spotify.com') || - url.includes('spotify:') || - url.includes('link.tospotify.com'); - } - - /** - * Extracts details from a Spotify URL - */ - function getSpotifyResourceDetails(url: string): { type: string; id: string } | null { - // Allow optional path segments (e.g. intl-fr) before resource type - const regex = /spotify\.com\/(?:[^\/]+\/)??(track|album|playlist|artist)\/([a-zA-Z0-9]+)/i; - const match = url.match(regex); - - if (match) { - return { - type: match[1], - id: match[2] - }; - } - return null; - } - - /** - * Formats milliseconds to MM:SS - */ - function msToMinutesSeconds(ms: number | undefined): string { - if (!ms) return '0:00'; - - const minutes = Math.floor(ms / 60000); - const seconds = ((ms % 60000) / 1000).toFixed(0); - return `${minutes}:${seconds.padStart(2, '0')}`; - } - - /** - * Creates a result card element - */ - function createResultCard(item: SearchResultItem, type: string, index: number): HTMLDivElement { - const cardElement = document.createElement('div'); - cardElement.className = 'result-card'; - - // Set cursor to pointer for clickable cards - cardElement.style.cursor = 'pointer'; - - // Get the appropriate image URL - let imageUrl = '/static/images/placeholder.jpg'; - // Type guards to safely access images - if (type === 'album' || type === 'artist') { - const albumOrArtistItem = item as AlbumResultItem | ArtistResultItem; - if (albumOrArtistItem.images && albumOrArtistItem.images.length > 0) { - imageUrl = albumOrArtistItem.images[0].url; - } - } else if (type === 'track') { - const trackItem = item as TrackResultItem; - if (trackItem.album && trackItem.album.images && trackItem.album.images.length > 0) { - imageUrl = trackItem.album.images[0].url; - } - } else if (type === 'playlist') { - const playlistItem = item as PlaylistResultItem; - if (playlistItem.images && playlistItem.images.length > 0) { - imageUrl = playlistItem.images[0].url; - } - } - - // Get the appropriate details based on type - let subtitle = ''; - let details = ''; - - switch (type) { - case 'track': - { - const trackItem = item as TrackResultItem; - subtitle = trackItem.artists ? trackItem.artists.map((a: Artist) => a.name).join(', ') : 'Unknown Artist'; - details = trackItem.album ? `${trackItem.album.name}${msToMinutesSeconds(trackItem.duration_ms)}` : ''; - } - break; - case 'album': - { - const albumItem = item as AlbumResultItem; - subtitle = albumItem.artists ? albumItem.artists.map((a: Artist) => a.name).join(', ') : 'Unknown Artist'; - details = `${albumItem.total_tracks || 0} tracks${albumItem.release_date ? new Date(albumItem.release_date).getFullYear() : ''}`; - } - break; - case 'playlist': - { - const playlistItem = item as PlaylistResultItem; - subtitle = `By ${playlistItem.owner ? playlistItem.owner.display_name : 'Unknown'}`; - details = `${playlistItem.tracks && playlistItem.tracks.total ? playlistItem.tracks.total : 0} tracks`; - } - break; - case 'artist': - { - const artistItem = item as ArtistResultItem; - subtitle = 'Artist'; - details = artistItem.genres ? `${artistItem.genres.slice(0, 2).join(', ')}` : ''; - } - break; - } - - // Build the HTML - cardElement.innerHTML = ` -
- ${item.name || 'Item'} -
-
${item.name || 'Unknown'}
-
${subtitle}
-
${details}
- - `; - - // Add click event to navigate to the item's detail page - cardElement.addEventListener('click', (e: MouseEvent) => { - // Don't trigger if the download button was clicked - const target = e.target as HTMLElement; - if (target.classList.contains('download-btn') || - target.parentElement?.classList.contains('download-btn')) { - return; - } - - if (item.id) { - window.location.href = `/${type}/${item.id}`; - } - }); - - return cardElement; - } - - /** - * Show/hide the empty state - */ - function showEmptyState(show: boolean) { - if (emptyState) { - emptyState.style.display = show ? 'flex' : 'none'; - } - } - - /** - * Show/hide the loading indicator - */ - function showLoading(show: boolean) { - if (loadingResults) { - loadingResults.classList.toggle('hidden', !show); - } - } -}); diff --git a/src/js/playlist.ts b/src/js/playlist.ts deleted file mode 100644 index a24a207..0000000 --- a/src/js/playlist.ts +++ /dev/null @@ -1,864 +0,0 @@ -// Import the downloadQueue singleton from your working queue.js implementation. -import { downloadQueue } from './queue.js'; - -// Define interfaces for API data -interface Image { - url: string; - height?: number; - width?: number; -} - -interface Artist { - id: string; - name: string; - external_urls?: { spotify?: string }; -} - -interface Album { - id: string; - name: string; - images?: Image[]; - external_urls?: { spotify?: string }; -} - -interface Track { - id: string; - name: string; - artists: Artist[]; - album: Album; - duration_ms: number; - explicit: boolean; - external_urls?: { spotify?: string }; - is_locally_known?: boolean; // Added for local DB status -} - -interface PlaylistItem { - track: Track | null; - // Add other playlist item properties like added_at, added_by if needed -} - -interface Playlist { - id: string; - name: string; - description: string | null; - owner: { - display_name?: string; - id?: string; - }; - images: Image[]; - tracks: { - items: PlaylistItem[]; - total: number; - }; - followers?: { - total: number; - }; - external_urls?: { spotify?: string }; -} - -interface WatchedPlaylistStatus { - is_watched: boolean; - playlist_data?: Playlist; // Optional, present if watched -} - -// Added: Interface for global watch config -interface GlobalWatchConfig { - enabled: boolean; - [key: string]: any; -} - -interface DownloadQueueItem { - name: string; - artist?: string; // Can be a simple string for the queue - album?: { name: string }; // Match QueueItem's album structure - owner?: string; // For playlists, owner can be a string - // Add any other properties your item might have, compatible with QueueItem -} - -// Added: Helper function to fetch global watch config -async function getGlobalWatchConfig(): Promise { - try { - const response = await fetch('/api/config/watch'); - if (!response.ok) { - console.error('Failed to fetch global watch config, assuming disabled.'); - return { enabled: false }; // Default to disabled on error - } - return await response.json() as GlobalWatchConfig; - } catch (error) { - console.error('Error fetching global watch config:', error); - return { enabled: false }; // Default to disabled on error - } -} - -document.addEventListener('DOMContentLoaded', async () => { - // Parse playlist ID from URL - const pathSegments = window.location.pathname.split('/'); - const playlistId = pathSegments[pathSegments.indexOf('playlist') + 1]; - - if (!playlistId) { - showError('No playlist ID provided.'); - return; - } - - const globalWatchConfig = await getGlobalWatchConfig(); // Fetch global config - const isGlobalWatchActuallyEnabled = globalWatchConfig.enabled; - - // Fetch playlist info directly - fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}`) - .then(response => { - if (!response.ok) throw new Error('Network response was not ok'); - return response.json() as Promise; - }) - .then(data => renderPlaylist(data, isGlobalWatchActuallyEnabled)) - .catch(error => { - console.error('Error:', error); - showError('Failed to load playlist.'); - }); - - // Fetch initial watch status for the specific playlist - if (isGlobalWatchActuallyEnabled) { - fetchWatchStatus(playlistId); // This function then calls updateWatchButtons - } else { - // If global watch is disabled, ensure watch-related buttons are hidden/disabled - const watchBtn = document.getElementById('watchPlaylistBtn') as HTMLButtonElement; - const syncBtn = document.getElementById('syncPlaylistBtn') as HTMLButtonElement; - if (watchBtn) { - watchBtn.classList.add('hidden'); - watchBtn.disabled = true; - // Remove any existing event listener to prevent actions - watchBtn.onclick = null; - } - if (syncBtn) { - syncBtn.classList.add('hidden'); - syncBtn.disabled = true; - syncBtn.onclick = null; - } - } - - const queueIcon = document.getElementById('queueIcon'); - if (queueIcon) { - queueIcon.addEventListener('click', () => { - downloadQueue.toggleVisibility(); - }); - } - - // Attempt to set initial watchlist button visibility from cache - const watchlistButton = document.getElementById('watchlistButton') as HTMLAnchorElement | null; - if (watchlistButton) { - const cachedWatchEnabled = localStorage.getItem('spotizerr_watch_enabled_cached'); - if (cachedWatchEnabled === 'true') { - watchlistButton.classList.remove('hidden'); - } - } - - // Fetch watch config to determine if watchlist button should be visible - async function updateWatchlistButtonVisibility() { - if (watchlistButton) { - try { - const response = await fetch('/api/config/watch'); - if (response.ok) { - const watchConfig = await response.json(); - localStorage.setItem('spotizerr_watch_enabled_cached', watchConfig.enabled ? 'true' : 'false'); - if (watchConfig && watchConfig.enabled === false) { - watchlistButton.classList.add('hidden'); - } else { - watchlistButton.classList.remove('hidden'); // Ensure it's shown if enabled - } - } else { - console.error('Failed to fetch watch config for playlist page, defaulting to hidden'); - // Don't update cache on error - watchlistButton.classList.add('hidden'); // Hide if config fetch fails - } - } catch (error) { - console.error('Error fetching watch config for playlist page:', error); - // Don't update cache on error - watchlistButton.classList.add('hidden'); // Hide on error - } - } - } - updateWatchlistButtonVisibility(); -}); - -/** - * Renders playlist header and tracks. - */ -function renderPlaylist(playlist: Playlist, isGlobalWatchEnabled: boolean) { - // Hide loading and error messages - const loadingEl = document.getElementById('loading'); - if (loadingEl) loadingEl.classList.add('hidden'); - const errorEl = document.getElementById('error'); - if (errorEl) errorEl.classList.add('hidden'); - - // Check if explicit filter is enabled - const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled(); - - // Update header info - const playlistNameEl = document.getElementById('playlist-name'); - if (playlistNameEl) playlistNameEl.textContent = playlist.name || 'Unknown Playlist'; - const playlistOwnerEl = document.getElementById('playlist-owner'); - if (playlistOwnerEl) playlistOwnerEl.textContent = `By ${playlist.owner?.display_name || 'Unknown User'}`; - const playlistStatsEl = document.getElementById('playlist-stats'); - if (playlistStatsEl) playlistStatsEl.textContent = - `${playlist.followers?.total || '0'} followers • ${playlist.tracks?.total || '0'} songs`; - const playlistDescriptionEl = document.getElementById('playlist-description'); - if (playlistDescriptionEl) playlistDescriptionEl.textContent = playlist.description || ''; - const image = playlist.images?.[0]?.url || '/static/images/placeholder.jpg'; - const playlistImageEl = document.getElementById('playlist-image') as HTMLImageElement; - if (playlistImageEl) playlistImageEl.src = image; - - // --- Add Home Button --- - let homeButton = document.getElementById('homeButton') as HTMLButtonElement; - if (!homeButton) { - homeButton = document.createElement('button'); - homeButton.id = 'homeButton'; - homeButton.className = 'home-btn'; - // Use an tag to display the SVG icon. - homeButton.innerHTML = `Home`; - // Insert the home button at the beginning of the header container. - const headerContainer = document.getElementById('playlist-header'); - if (headerContainer) { - headerContainer.insertBefore(homeButton, headerContainer.firstChild); - } - } - homeButton.addEventListener('click', () => { - // Navigate to the site's base URL. - window.location.href = window.location.origin; - }); - - // Check if any track in the playlist is explicit when filter is enabled - let hasExplicitTrack = false; - if (isExplicitFilterEnabled && playlist.tracks?.items) { - hasExplicitTrack = playlist.tracks.items.some((item: PlaylistItem) => item?.track && item.track.explicit); - } - - // --- Add "Download Whole Playlist" Button --- - let downloadPlaylistBtn = document.getElementById('downloadPlaylistBtn') as HTMLButtonElement; - if (!downloadPlaylistBtn) { - downloadPlaylistBtn = document.createElement('button'); - downloadPlaylistBtn.id = 'downloadPlaylistBtn'; - downloadPlaylistBtn.textContent = 'Download Whole Playlist'; - downloadPlaylistBtn.className = 'download-btn download-btn--main'; - // Insert the button into the header container. - const headerContainer = document.getElementById('playlist-header'); - if (headerContainer) { - headerContainer.appendChild(downloadPlaylistBtn); - } - } - - // --- Add "Download Playlist's Albums" Button --- - let downloadAlbumsBtn = document.getElementById('downloadAlbumsBtn') as HTMLButtonElement; - if (!downloadAlbumsBtn) { - downloadAlbumsBtn = document.createElement('button'); - downloadAlbumsBtn.id = 'downloadAlbumsBtn'; - downloadAlbumsBtn.textContent = "Download Playlist's Albums"; - downloadAlbumsBtn.className = 'download-btn download-btn--main'; - // Insert the new button into the header container. - const headerContainer = document.getElementById('playlist-header'); - if (headerContainer) { - headerContainer.appendChild(downloadAlbumsBtn); - } - } - - if (isExplicitFilterEnabled && hasExplicitTrack) { - // Disable both playlist buttons and display messages explaining why - if (downloadPlaylistBtn) { - downloadPlaylistBtn.disabled = true; - downloadPlaylistBtn.classList.add('download-btn--disabled'); - downloadPlaylistBtn.innerHTML = `Playlist Contains Explicit Tracks`; - } - - if (downloadAlbumsBtn) { - downloadAlbumsBtn.disabled = true; - downloadAlbumsBtn.classList.add('download-btn--disabled'); - downloadAlbumsBtn.innerHTML = `Albums Access Restricted`; - } - } else { - // Normal behavior when no explicit tracks are present - if (downloadPlaylistBtn) { - downloadPlaylistBtn.addEventListener('click', () => { - // Remove individual track download buttons (but leave the whole playlist button). - document.querySelectorAll('.download-btn').forEach(btn => { - if (btn.id !== 'downloadPlaylistBtn') { - btn.remove(); - } - }); - - // Disable the whole playlist button to prevent repeated clicks. - downloadPlaylistBtn.disabled = true; - downloadPlaylistBtn.textContent = 'Queueing...'; - - // Initiate the playlist download. - downloadWholePlaylist(playlist).then(() => { - downloadPlaylistBtn.textContent = 'Queued!'; - }).catch((err: any) => { - showError('Failed to queue playlist download: ' + (err?.message || 'Unknown error')); - if (downloadPlaylistBtn) downloadPlaylistBtn.disabled = false; // Re-enable on error - }); - }); - } - - if (downloadAlbumsBtn) { - downloadAlbumsBtn.addEventListener('click', () => { - // Remove individual track download buttons (but leave this album button). - document.querySelectorAll('.download-btn').forEach(btn => { - if (btn.id !== 'downloadAlbumsBtn') btn.remove(); - }); - - downloadAlbumsBtn.disabled = true; - downloadAlbumsBtn.textContent = 'Queueing...'; - - downloadPlaylistAlbums(playlist) - .then(() => { - if (downloadAlbumsBtn) downloadAlbumsBtn.textContent = 'Queued!'; - }) - .catch((err: any) => { - showError('Failed to queue album downloads: ' + (err?.message || 'Unknown error')); - if (downloadAlbumsBtn) downloadAlbumsBtn.disabled = false; // Re-enable on error - }); - }); - } - } - - // Render tracks list - const tracksList = document.getElementById('tracks-list'); - if (!tracksList) return; - - tracksList.innerHTML = ''; // Clear any existing content - - // Determine if the playlist is being watched to show/hide management buttons - const watchPlaylistButton = document.getElementById('watchPlaylistBtn') as HTMLButtonElement; - // isIndividuallyWatched checks if the button is visible and has the 'watching' class. - // This implies global watch is enabled if the button is even interactable for individual status. - const isIndividuallyWatched = watchPlaylistButton && - watchPlaylistButton.classList.contains('watching') && - !watchPlaylistButton.classList.contains('hidden'); - - if (playlist.tracks?.items) { - playlist.tracks.items.forEach((item: PlaylistItem, index: number) => { - if (!item || !item.track) return; // Skip null/undefined tracks - - const track = item.track; - - // Skip explicit tracks if filter is enabled - if (isExplicitFilterEnabled && track.explicit) { - // Add a placeholder for filtered explicit tracks - const trackElement = document.createElement('div'); - trackElement.className = 'track track-filtered'; - trackElement.innerHTML = ` -
${index + 1}
-
-
Explicit Content Filtered
-
This track is not shown due to explicit content filter settings
-
-
Not available
-
--:--
- `; - tracksList.appendChild(trackElement); - return; - } - - const trackLink = `/track/${track.id || ''}`; - const artistLink = `/artist/${track.artists?.[0]?.id || ''}`; - const albumLink = `/album/${track.album?.id || ''}`; - - const trackElement = document.createElement('div'); - trackElement.className = 'track'; - let trackHTML = ` -
${index + 1}
- - -
${msToTime(track.duration_ms || 0)}
- `; - - const actionsContainer = document.createElement('div'); - actionsContainer.className = 'track-actions-container'; - - if (!(isExplicitFilterEnabled && hasExplicitTrack)) { - const downloadBtnHTML = ` - - `; - actionsContainer.innerHTML += downloadBtnHTML; - } - - if (isGlobalWatchEnabled && isIndividuallyWatched) { // Check global and individual watch status - // Initial state is set based on track.is_locally_known - const isKnown = track.is_locally_known === true; // Ensure boolean check, default to false if undefined - const initialStatus = isKnown ? "known" : "missing"; - const initialIcon = isKnown ? "/static/images/check.svg" : "/static/images/missing.svg"; - const initialTitle = isKnown ? "Click to mark as missing from DB" : "Click to mark as known in DB"; - - const toggleKnownBtnHTML = ` - - `; - actionsContainer.innerHTML += toggleKnownBtnHTML; - } - - trackElement.innerHTML = trackHTML; - trackElement.appendChild(actionsContainer); - tracksList.appendChild(trackElement); - }); - } - - // Reveal header and tracks container - const playlistHeaderEl = document.getElementById('playlist-header'); - if (playlistHeaderEl) playlistHeaderEl.classList.remove('hidden'); - const tracksContainerEl = document.getElementById('tracks-container'); - if (tracksContainerEl) tracksContainerEl.classList.remove('hidden'); - - // Attach download listeners to newly rendered download buttons - attachTrackActionListeners(isGlobalWatchEnabled); -} - -/** - * Converts milliseconds to minutes:seconds. - */ -function msToTime(duration: number) { - if (!duration || isNaN(duration)) return '0:00'; - - const minutes = Math.floor(duration / 60000); - const seconds = ((duration % 60000) / 1000).toFixed(0); - return `${minutes}:${seconds.padStart(2, '0')}`; -} - -/** - * Displays an error message in the UI. - */ -function showError(message: string) { - const errorEl = document.getElementById('error'); - if (errorEl) { - errorEl.textContent = message || 'An error occurred'; - errorEl.classList.remove('hidden'); - } -} - -/** - * Attaches event listeners to all individual track action buttons (download, mark known, mark missing). - */ -function attachTrackActionListeners(isGlobalWatchEnabled: boolean) { - document.querySelectorAll('.track-download-btn').forEach((btn) => { - btn.addEventListener('click', (e: Event) => { - e.stopPropagation(); - const currentTarget = e.currentTarget as HTMLButtonElement; - const itemId = currentTarget.dataset.id || ''; - const type = currentTarget.dataset.type || 'track'; - const name = currentTarget.dataset.name || 'Unknown'; - if (!itemId) { - showError('Missing item ID for download on playlist page'); - return; - } - currentTarget.remove(); - startDownload(itemId, type, { name }, ''); - }); - }); - - document.querySelectorAll('.toggle-known-status-btn').forEach((btn) => { - btn.addEventListener('click', async (e: Event) => { - e.stopPropagation(); - const button = e.currentTarget as HTMLButtonElement; - const trackId = button.dataset.id || ''; - const playlistId = button.dataset.playlistId || ''; - const currentStatus = button.dataset.status; - const img = button.querySelector('img'); - - if (!trackId || !playlistId || !img) { - showError('Missing data for toggling track status'); - return; - } - - if (!isGlobalWatchEnabled) { // Added check - showNotification("Watch feature is currently disabled globally. Cannot change track status."); - return; - } - - button.disabled = true; - try { - if (currentStatus === 'missing') { - await handleMarkTrackAsKnown(playlistId, trackId); - button.dataset.status = 'known'; - img.src = '/static/images/check.svg'; - button.title = 'Click to mark as missing from DB'; - } else { - await handleMarkTrackAsMissing(playlistId, trackId); - button.dataset.status = 'missing'; - img.src = '/static/images/missing.svg'; - button.title = 'Click to mark as known in DB'; - } - } catch (error) { - // Revert UI on error if needed, error is shown by handlers - showError('Failed to update track status. Please try again.'); - } - button.disabled = false; - }); - }); -} - -async function handleMarkTrackAsKnown(playlistId: string, trackId: string) { - try { - const response = await fetch(`/api/playlist/watch/${playlistId}/tracks`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify([trackId]), - }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || `HTTP error! status: ${response.status}`); - } - const result = await response.json(); - showNotification(result.message || 'Track marked as known.'); - } catch (error: any) { - showError(`Failed to mark track as known: ${error.message}`); - throw error; // Re-throw for the caller to handle button state if needed - } -} - -async function handleMarkTrackAsMissing(playlistId: string, trackId: string) { - try { - const response = await fetch(`/api/playlist/watch/${playlistId}/tracks`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify([trackId]), - }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || `HTTP error! status: ${response.status}`); - } - const result = await response.json(); - showNotification(result.message || 'Track marked as missing.'); - } catch (error: any) { - showError(`Failed to mark track as missing: ${error.message}`); - throw error; // Re-throw - } -} - -/** - * Initiates the whole playlist download by calling the playlist endpoint. - */ -async function downloadWholePlaylist(playlist: Playlist) { - if (!playlist) { - throw new Error('Invalid playlist data'); - } - - const playlistId = playlist.id || ''; - if (!playlistId) { - throw new Error('Missing playlist ID'); - } - - try { - // Use the centralized downloadQueue.download method - await downloadQueue.download(playlistId, 'playlist', { - name: playlist.name || 'Unknown Playlist', - owner: playlist.owner?.display_name // Pass owner as a string - // total_tracks can also be passed if QueueItem supports it directly - }); - // Make the queue visible after queueing - downloadQueue.toggleVisibility(true); - } catch (error: any) { - showError('Playlist download failed: ' + (error?.message || 'Unknown error')); - throw error; - } -} - -/** - * Initiates album downloads for each unique album in the playlist, - * adding a 20ms delay between each album download and updating the button - * with the progress (queued_albums/total_albums). - */ -async function downloadPlaylistAlbums(playlist: Playlist) { - if (!playlist?.tracks?.items) { - showError('No tracks found in this playlist.'); - return; - } - - // Build a map of unique albums (using album ID as the key). - const albumMap = new Map(); - playlist.tracks.items.forEach((item: PlaylistItem) => { - if (!item?.track?.album) return; - - const album = item.track.album; - if (album && album.id) { - albumMap.set(album.id, album); - } - }); - - const uniqueAlbums = Array.from(albumMap.values()); - const totalAlbums = uniqueAlbums.length; - if (totalAlbums === 0) { - showError('No albums found in this playlist.'); - return; - } - - // Get a reference to the "Download Playlist's Albums" button. - const downloadAlbumsBtn = document.getElementById('downloadAlbumsBtn') as HTMLButtonElement | null; - if (downloadAlbumsBtn) { - // Initialize the progress display. - downloadAlbumsBtn.textContent = `0/${totalAlbums}`; - } - - try { - // Process each album sequentially. - for (let i = 0; i < totalAlbums; i++) { - const album = uniqueAlbums[i]; - if (!album) continue; - - const albumUrl = album.external_urls?.spotify || ''; - if (!albumUrl) continue; - - // Use the centralized downloadQueue.download method - await downloadQueue.download( - album.id, // Pass album ID directly - 'album', - { - name: album.name || 'Unknown Album', - // If artist information is available on album objects from playlist, pass it - // artist: album.artists?.[0]?.name - } - ); - - // Update button text with current progress. - if (downloadAlbumsBtn) { - downloadAlbumsBtn.textContent = `${i + 1}/${totalAlbums}`; - } - - // Wait 20 milliseconds before processing the next album. - await new Promise(resolve => setTimeout(resolve, 20)); - } - - // Once all albums have been queued, update the button text. - if (downloadAlbumsBtn) { - downloadAlbumsBtn.textContent = 'Queued!'; - } - - // Make the queue visible after queueing all albums - downloadQueue.toggleVisibility(true); - } catch (error: any) { - // Propagate any errors encountered. - throw error; - } -} - -/** - * Starts the download process using the centralized download method from the queue. - */ -async function startDownload(itemId: string, type: string, item: DownloadQueueItem, albumType?: string) { - if (!itemId || !type) { - showError('Missing ID or type for download'); - return; - } - - try { - // Use the centralized downloadQueue.download method - await downloadQueue.download(itemId, type, item, albumType); - - // Make the queue visible after queueing - downloadQueue.toggleVisibility(true); - } catch (error: any) { - showError('Download failed: ' + (error?.message || 'Unknown error')); - throw error; - } -} - -/** - * A helper function to extract a display name from the URL. - */ -function extractName(url: string | null): string { - return url || 'Unknown'; -} - -/** - * Fetches the watch status of the current playlist and updates the UI. - */ -async function fetchWatchStatus(playlistId: string) { - if (!playlistId) return; - try { - const response = await fetch(`/api/playlist/watch/${playlistId}/status`); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to fetch watch status'); - } - const data: WatchedPlaylistStatus = await response.json(); - updateWatchButtons(data.is_watched, playlistId); - } catch (error) { - console.error('Error fetching watch status:', error); - // Don't show a blocking error, but maybe a small notification or log - // For now, assume not watched if status fetch fails, or keep buttons in default state - updateWatchButtons(false, playlistId); - } -} - -/** - * Updates the Watch/Unwatch and Sync buttons based on the playlist's watch status. - */ -function updateWatchButtons(isWatched: boolean, playlistId: string) { - const watchBtn = document.getElementById('watchPlaylistBtn') as HTMLButtonElement; - const syncBtn = document.getElementById('syncPlaylistBtn') as HTMLButtonElement; - - if (!watchBtn || !syncBtn) return; - - const watchBtnImg = watchBtn.querySelector('img'); - - if (isWatched) { - watchBtn.innerHTML = `Unwatch Unwatch Playlist`; - watchBtn.classList.add('watching'); - watchBtn.onclick = () => unwatchPlaylist(playlistId); - syncBtn.classList.remove('hidden'); - syncBtn.onclick = () => syncPlaylist(playlistId); - } else { - watchBtn.innerHTML = `Watch Watch Playlist`; - watchBtn.classList.remove('watching'); - watchBtn.onclick = () => watchPlaylist(playlistId); - syncBtn.classList.add('hidden'); - } - watchBtn.disabled = false; // Enable after status is known -} - -/** - * Adds the current playlist to the watchlist. - */ -async function watchPlaylist(playlistId: string) { - const watchBtn = document.getElementById('watchPlaylistBtn') as HTMLButtonElement; - if (watchBtn) watchBtn.disabled = true; - // This function should only be callable if global watch is enabled. - // We can add a check here or rely on the UI not presenting the button. - // For safety, let's check global config again before proceeding. - const globalConfig = await getGlobalWatchConfig(); - if (!globalConfig.enabled) { - showError("Cannot watch playlist, feature is disabled globally."); - if (watchBtn) { - watchBtn.disabled = false; // Re-enable if it was somehow clicked - updateWatchButtons(false, playlistId); // Reset button to non-watching state - } - return; - } - - try { - const response = await fetch(`/api/playlist/watch/${playlistId}`, { method: 'PUT' }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to watch playlist'); - } - updateWatchButtons(true, playlistId); - // Re-fetch and re-render playlist data - const newPlaylistInfoResponse = await fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}`); - if (!newPlaylistInfoResponse.ok) throw new Error('Failed to re-fetch playlist info after watch.'); - const newPlaylistData = await newPlaylistInfoResponse.json() as Playlist; - renderPlaylist(newPlaylistData, globalConfig.enabled); // Pass current global enabled state - - showNotification(`Playlist added to watchlist. Tracks are being updated.`); - } catch (error: any) { - showError(`Error watching playlist: ${error.message}`); - if (watchBtn) watchBtn.disabled = false; // Re-enable on error before potential UI revert - } -} - -/** - * Removes the current playlist from the watchlist. - */ -async function unwatchPlaylist(playlistId: string) { - const watchBtn = document.getElementById('watchPlaylistBtn') as HTMLButtonElement; - if (watchBtn) watchBtn.disabled = true; - // Similarly, check global config - const globalConfig = await getGlobalWatchConfig(); - if (!globalConfig.enabled) { - // This case should be rare if UI behaves, but good for robustness - showError("Cannot unwatch playlist, feature is disabled globally."); - if (watchBtn) { - watchBtn.disabled = false; - // updateWatchButtons(true, playlistId); // Or keep as is if it was 'watching' - } - return; - } - - try { - const response = await fetch(`/api/playlist/watch/${playlistId}`, { method: 'DELETE' }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to unwatch playlist'); - } - updateWatchButtons(false, playlistId); - // Re-fetch and re-render playlist data - const newPlaylistInfoResponse = await fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}`); - if (!newPlaylistInfoResponse.ok) throw new Error('Failed to re-fetch playlist info after unwatch.'); - const newPlaylistData = await newPlaylistInfoResponse.json() as Playlist; - renderPlaylist(newPlaylistData, globalConfig.enabled); // Pass current global enabled state - - showNotification('Playlist removed from watchlist. Track statuses updated.'); - } catch (error: any) { - showError(`Error unwatching playlist: ${error.message}`); - if (watchBtn) watchBtn.disabled = false; // Re-enable on error before potential UI revert - } -} - -/** - * Triggers a manual sync for the watched playlist. - */ -async function syncPlaylist(playlistId: string) { - const syncBtn = document.getElementById('syncPlaylistBtn') as HTMLButtonElement; - let originalButtonContent = ''; // Define outside - // Check global config - const globalConfig = await getGlobalWatchConfig(); - if (!globalConfig.enabled) { - showError("Cannot sync playlist, feature is disabled globally."); - return; - } - - if (syncBtn) { - syncBtn.disabled = true; - originalButtonContent = syncBtn.innerHTML; // Store full HTML - syncBtn.innerHTML = `Sync Syncing...`; // Keep icon - } - - try { - const response = await fetch(`/api/playlist/watch/trigger_check/${playlistId}`, { method: 'POST' }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to trigger sync'); - } - showNotification('Playlist sync triggered successfully.'); - } catch (error: any) { - showError(`Error triggering sync: ${error.message}`); - } finally { - if (syncBtn) { - syncBtn.disabled = false; - syncBtn.innerHTML = originalButtonContent; // Restore full original HTML - } - } -} - -/** - * Displays a temporary notification message. - */ -function showNotification(message: string) { - // Basic notification - consider a more robust solution for production - const notificationEl = document.createElement('div'); - notificationEl.className = 'notification'; - notificationEl.textContent = message; - document.body.appendChild(notificationEl); - setTimeout(() => { - notificationEl.remove(); - }, 3000); -} diff --git a/src/js/queue.ts b/src/js/queue.ts deleted file mode 100644 index eeab46f..0000000 --- a/src/js/queue.ts +++ /dev/null @@ -1,2755 +0,0 @@ -// --- MODIFIED: Custom URLSearchParams class that does not encode anything --- -class CustomURLSearchParams { - params: Record; - constructor() { - this.params = {}; - } - append(key: string, value: string): void { - this.params[key] = value; - } - toString(): string { - return Object.entries(this.params) - .map(([key, value]: [string, string]) => `${key}=${value}`) - .join('&'); - } -} -// --- END MODIFIED --- - -// Interfaces for complex objects -interface QueueItem { - name?: string; - music?: string; - song?: string; - artist?: string; - artists?: { name: string }[]; - album?: { name: string }; - owner?: string | { display_name?: string }; - total_tracks?: number; - url?: string; - type?: string; // Added for artist downloads - parent?: ParentInfo; // For tracks within albums/playlists - // For PRG file loading - display_title?: string; - display_artist?: string; - endpoint?: string; - download_type?: string; - [key: string]: any; // Allow other properties -} - -interface ParentInfo { - type: 'album' | 'playlist'; - title?: string; // for album - artist?: string; // for album - name?: string; // for playlist - owner?: string; // for playlist - total_tracks?: number; - url?: string; - [key: string]: any; // Allow other properties -} - -interface StatusData { - type?: string; - status?: string; - name?: string; - song?: string; - music?: string; - title?: string; - artist?: string; - artist_name?: string; - album?: string; - owner?: string; - total_tracks?: number | string; - current_track?: number | string; - parsed_current_track?: string; // Make sure these are handled if they are strings - parsed_total_tracks?: string; // Make sure these are handled if they are strings - progress?: number | string; // Can be string initially - percentage?: number | string; // Can be string initially - percent?: number | string; // Can be string initially - time_elapsed?: number; - error?: string; - can_retry?: boolean; - retry_count?: number; - max_retries?: number; // from config potentially - seconds_left?: number; - prg_file?: string; - url?: string; - reason?: string; // for skipped - parent?: ParentInfo; - original_url?: string; - position?: number; // For queued items - original_request?: { - url?: string; - retry_url?: string; - name?: string; - artist?: string; - type?: string; - endpoint?: string; - download_type?: string; - display_title?: string; - display_type?: string; - display_artist?: string; - service?: string; - [key: string]: any; // For other potential original_request params - }; - event?: string; // from SSE - overall_progress?: number; - display_type?: string; // from PRG data - [key: string]: any; // Allow other properties -} - -interface QueueEntry { - item: QueueItem; - type: string; - prgFile: string; - requestUrl: string | null; - element: HTMLElement; - lastStatus: StatusData; - lastUpdated: number; - hasEnded: boolean; - intervalId: number | null; // NodeJS.Timeout for setInterval/clearInterval - uniqueId: string; - retryCount: number; - autoRetryInterval: number | null; - isNew: boolean; - status: string; - lastMessage: string; - parentInfo: ParentInfo | null; - isRetrying?: boolean; - progress?: number; // for multi-track overall progress - realTimeStallDetector: { count: number; lastStatusJson: string }; - [key: string]: any; // Allow other properties -} - -interface AppConfig { - downloadQueueVisible?: boolean; - maxRetries?: number; - retryDelaySeconds?: number; - retry_delay_increase?: number; - explicitFilter?: boolean; - [key: string]: any; // Allow other config properties -} - -// Ensure DOM elements are queryable -declare global { - interface Document { - getElementById(elementId: string): HTMLElement | null; - } -} - -export class DownloadQueue { - // Constants read from the server config - MAX_RETRIES: number = 3; // Default max retries - RETRY_DELAY: number = 5; // Default retry delay in seconds - RETRY_DELAY_INCREASE: number = 5; // Default retry delay increase in seconds - - // Cache for queue items - queueCache: Record = {}; - - // Queue entry objects - queueEntries: Record = {}; - - // Polling intervals for progress tracking - pollingIntervals: Record = {}; // NodeJS.Timeout for setInterval - - // DOM elements cache (Consider if this is still needed or how it's used) - elements: Record = {}; // Example type, adjust as needed - - // Event handlers (Consider if this is still needed or how it's used) - eventHandlers: Record = {}; // Example type, adjust as needed - - // Configuration - config: AppConfig = {}; // Initialize with an empty object or a default config structure - - // Load the saved visible count (or default to 10) - visibleCount: number; - - constructor() { - const storedVisibleCount = localStorage.getItem("downloadQueueVisibleCount"); - this.visibleCount = storedVisibleCount ? parseInt(storedVisibleCount, 10) : 10; - - this.queueCache = JSON.parse(localStorage.getItem("downloadQueueCache") || "{}"); - - // Constants read from the server config - this.MAX_RETRIES = 3; // Default max retries - this.RETRY_DELAY = 5; // Default retry delay in seconds - this.RETRY_DELAY_INCREASE = 5; // Default retry delay increase in seconds - - // Cache for queue items - // this.queueCache = {}; // Already initialized above - - // Queue entry objects - this.queueEntries = {}; - - // Polling intervals for progress tracking - this.pollingIntervals = {}; - - // DOM elements cache - this.elements = {}; - - // Event handlers - this.eventHandlers = {}; - - // Configuration - this.config = {}; // Initialize config - - // Load the saved visible count (or default to 10) - This block is redundant - // const storedVisibleCount = localStorage.getItem("downloadQueueVisibleCount"); - // this.visibleCount = storedVisibleCount ? parseInt(storedVisibleCount, 10) : 10; - - // Load the cached status info (object keyed by prgFile) - This is also redundant - // this.queueCache = JSON.parse(localStorage.getItem("downloadQueueCache") || "{}"); - - // Wait for initDOM to complete before setting up event listeners and loading existing PRG files. - this.initDOM().then(() => { - this.initEventListeners(); - this.loadExistingPrgFiles(); - // Start periodic sync - setInterval(() => this.periodicSyncWithServer(), 10000); // Sync every 10 seconds - }); - } - - /* DOM Management */ - async initDOM() { - // New HTML structure for the download queue. - const queueHTML = ` - - `; - document.body.insertAdjacentHTML('beforeend', queueHTML); - - // Load initial config from the server. - await this.loadConfig(); - - // Override the server value with locally persisted queue visibility (if present). - const storedVisible = localStorage.getItem("downloadQueueVisible"); - if (storedVisible !== null) { - // Ensure config is not null before assigning - if (this.config) { - this.config.downloadQueueVisible = storedVisible === "true"; - } - } - - const queueSidebar = document.getElementById('downloadQueue'); - // Ensure config is not null and queueSidebar exists - if (this.config && queueSidebar) { - queueSidebar.hidden = !this.config.downloadQueueVisible; - queueSidebar.classList.toggle('active', !!this.config.downloadQueueVisible); - } - - // Initialize the queue icon based on sidebar visibility - const queueIcon = document.getElementById('queueIcon'); - if (queueIcon && this.config) { - if (this.config.downloadQueueVisible) { - queueIcon.innerHTML = 'Close queue'; - queueIcon.setAttribute('aria-expanded', 'true'); - queueIcon.classList.add('queue-icon-active'); // Add red tint class - } else { - queueIcon.innerHTML = 'Queue Icon'; - queueIcon.setAttribute('aria-expanded', 'false'); - queueIcon.classList.remove('queue-icon-active'); // Remove red tint class - } - } - } - - /* Event Handling */ - initEventListeners() { - // Toggle queue visibility via Escape key. - document.addEventListener('keydown', async (e: KeyboardEvent) => { - const queueSidebar = document.getElementById('downloadQueue'); - if (e.key === 'Escape' && queueSidebar?.classList.contains('active')) { - await this.toggleVisibility(); - } - }); - - // "Cancel all" button. - const cancelAllBtn = document.getElementById('cancelAllBtn'); - if (cancelAllBtn) { - cancelAllBtn.addEventListener('click', () => { - for (const queueId in this.queueEntries) { - const entry = this.queueEntries[queueId]; - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; - if (entry && !entry.hasEnded && entry.prgFile) { - // Mark as cancelling visually - if (entry.element) { - entry.element.classList.add('cancelling'); - } - if (logElement) { - logElement.textContent = "Cancelling..."; - } - - // Cancel each active download - fetch(`/api/${entry.type}/download/cancel?prg_file=${entry.prgFile}`) - .then(response => response.json()) - .then(data => { - // API returns status 'cancelled' when cancellation succeeds - if (data.status === "cancelled" || data.status === "cancel") { - entry.hasEnded = true; - if (entry.intervalId) { - clearInterval(entry.intervalId as number); // Cast to number for clearInterval - entry.intervalId = null; - } - // Remove the entry as soon as the API confirms cancellation - this.cleanupEntry(queueId); - } - }) - .catch(error => console.error('Cancel error:', error)); - } - } - this.clearAllPollingIntervals(); - }); - } - - // Close all SSE connections when the page is about to unload - window.addEventListener('beforeunload', () => { - this.clearAllPollingIntervals(); - }); - } - - /* Public API */ - async toggleVisibility(force?: boolean) { - const queueSidebar = document.getElementById('downloadQueue'); - if (!queueSidebar) return; // Guard against null - // If force is provided, use that value, otherwise toggle the current state - const isVisible = force !== undefined ? force : !queueSidebar.classList.contains('active'); - - queueSidebar.classList.toggle('active', isVisible); - queueSidebar.hidden = !isVisible; - - // Update the queue icon to show X when visible or queue icon when hidden - const queueIcon = document.getElementById('queueIcon'); - if (queueIcon && this.config) { - if (isVisible) { - // Replace the image with an X and add red tint - queueIcon.innerHTML = 'Close queue'; - queueIcon.setAttribute('aria-expanded', 'true'); - queueIcon.classList.add('queue-icon-active'); // Add red tint class - } else { - // Restore the original queue icon and remove red tint - queueIcon.innerHTML = 'Queue Icon'; - queueIcon.setAttribute('aria-expanded', 'false'); - queueIcon.classList.remove('queue-icon-active'); // Remove red tint class - } - } - - // Persist the state locally so it survives refreshes. - localStorage.setItem("downloadQueueVisible", String(isVisible)); - - try { - await this.loadConfig(); - const updatedConfig = { ...this.config, downloadQueueVisible: isVisible }; - await this.saveConfig(updatedConfig); - this.dispatchEvent('queueVisibilityChanged', { visible: isVisible }); - } catch (error) { - console.error('Failed to save queue visibility:', error); - // Revert UI if save failed. - queueSidebar.classList.toggle('active', !isVisible); - queueSidebar.hidden = isVisible; - // Also revert the icon back - if (queueIcon && this.config) { - if (!isVisible) { - queueIcon.innerHTML = 'Close queue'; - queueIcon.setAttribute('aria-expanded', 'true'); - queueIcon.classList.add('queue-icon-active'); // Add red tint class - } else { - queueIcon.innerHTML = 'Close queue'; - queueIcon.setAttribute('aria-expanded', 'true'); - queueIcon.classList.add('queue-icon-active'); // Add red tint class - } - } - this.dispatchEvent('queueVisibilityChanged', { visible: !isVisible }); - this.showError('Failed to save queue visibility'); - } - - if (isVisible) { - // If the queue is now visible, ensure all visible items are being polled. - this.startMonitoringActiveEntries(); - } - } - - showError(message: string) { - const errorDiv = document.createElement('div'); - errorDiv.className = 'queue-error'; - errorDiv.textContent = message; - document.getElementById('queueItems')?.prepend(errorDiv); // Optional chaining - setTimeout(() => errorDiv.remove(), 3000); - } - - /** - * Adds a new download entry. - */ - addDownload(item: QueueItem, type: string, prgFile: string, requestUrl: string | null = null, startMonitoring: boolean = false): string { - const queueId = this.generateQueueId(); - const entry = this.createQueueEntry(item, type, prgFile, queueId, requestUrl); - this.queueEntries[queueId] = entry; - // Re-render and update which entries are processed. - this.updateQueueOrder(); - - // Start monitoring if explicitly requested, regardless of visibility - if (startMonitoring) { - this.startDownloadStatusMonitoring(queueId); - } - - this.dispatchEvent('downloadAdded', { queueId, item, type }); - return queueId; // Return the queueId so callers can reference it - } - - /* Start processing the entry. Removed visibility check to ensure all entries are monitored. */ - async startDownloadStatusMonitoring(queueId: string) { - const entry = this.queueEntries[queueId]; - if (!entry || entry.hasEnded) return; - - // Don't restart monitoring if polling interval already exists - if (this.pollingIntervals[queueId]) return; - - // Ensure entry has data containers for parent info - entry.parentInfo = entry.parentInfo || null; - - // Show a preparing message for new entries - if (entry.isNew) { - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; - if (logElement) { - logElement.textContent = "Initializing download..."; - } - } - - console.log(`Starting monitoring for ${entry.type} with PRG file: ${entry.prgFile}`); - - // For backward compatibility, first try to get initial status from the REST API - try { - const response = await fetch(`/api/prgs/${entry.prgFile}`); - if (response.ok) { - const data: StatusData = await response.json(); // Add type to data - - // Update entry type if available - if (data.type) { - entry.type = data.type; - - // Update type display if element exists - const typeElement = entry.element.querySelector('.type') as HTMLElement | null; - if (typeElement) { - typeElement.textContent = data.type.charAt(0).toUpperCase() + data.type.slice(1); - typeElement.className = `type ${data.type}`; - } - } - - // Update request URL if available - if (!entry.requestUrl && data.original_request) { - const params = new CustomURLSearchParams(); - for (const key in data.original_request) { - params.append(key, data.original_request[key]); - } - entry.requestUrl = `/api/${entry.type}/download?${params.toString()}`; - } - - // Override requestUrl with server original_url if provided - if (data.original_url) { - entry.requestUrl = data.original_url; - } - - // Process the initial status - if (data.last_line) { - entry.lastStatus = data.last_line; - entry.lastUpdated = Date.now(); - entry.status = data.last_line.status || 'unknown'; // Ensure status is not undefined - - // Update status message without recreating the element - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; - if (logElement) { - const statusMessage = this.getStatusMessage(data.last_line); - logElement.textContent = statusMessage; - } - - // Apply appropriate CSS classes based on status - this.applyStatusClasses(entry, data.last_line); - - // Save updated status to cache, ensuring we preserve parent data - this.queueCache[entry.prgFile] = { - ...data.last_line, - // Ensure parent data is preserved - parent: data.last_line.parent || entry.lastStatus?.parent - }; - - // If this is a track with a parent, update the display elements to match the parent - if (data.last_line.type === 'track' && data.last_line.parent) { - const parent = data.last_line.parent; - entry.parentInfo = parent; - - // Update type and UI to reflect the parent type - if (parent.type === 'album' || parent.type === 'playlist') { - // Only change type if it's not already set to the parent type - if (entry.type !== parent.type) { - entry.type = parent.type; - - // Update the type indicator - const typeEl = entry.element.querySelector('.type') as HTMLElement | null; - if (typeEl) { - const displayType = parent.type.charAt(0).toUpperCase() + parent.type.slice(1); - typeEl.textContent = displayType; - typeEl.className = `type ${parent.type}`; - } - - // Update the title and subtitle based on parent type - const titleEl = entry.element.querySelector('.title') as HTMLElement | null; - const artistEl = entry.element.querySelector('.artist') as HTMLElement | null; - - if (parent.type === 'album') { - if (titleEl) titleEl.textContent = parent.title || 'Unknown album'; - if (artistEl) artistEl.textContent = parent.artist || 'Unknown artist'; - } else if (parent.type === 'playlist') { - if (titleEl) titleEl.textContent = parent.name || 'Unknown playlist'; - if (artistEl) artistEl.textContent = parent.owner || 'Unknown creator'; - } - } - } - } - - localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); - - // If the entry is already in a terminal state, don't set up polling - if (['error', 'complete', 'cancel', 'cancelled', 'done'].includes(data.last_line.status || '')) { // Add null check for status - entry.hasEnded = true; - this.handleDownloadCompletion(entry, queueId, data.last_line); - return; - } - } - } - } catch (error) { - console.error('Initial status check failed:', error); - } - - // Set up polling interval for real-time updates - this.setupPollingInterval(queueId); - } - - /* Helper Methods */ - generateQueueId() { - return Date.now().toString() + Math.random().toString(36).substr(2, 9); - } - - /** - * Creates a new queue entry. It checks localStorage for any cached info. - */ - createQueueEntry(item: QueueItem, type: string, prgFile: string, queueId: string, requestUrl: string | null): QueueEntry { - console.log(`Creating queue entry with initial type: ${type}`); - - // Get cached data if it exists - const cachedData: StatusData | undefined = this.queueCache[prgFile]; // Add type - - // If we have cached data, use it to determine the true type and item properties - if (cachedData) { - // If this is a track with a parent, update type and item to match the parent - if (cachedData.type === 'track' && cachedData.parent) { - if (cachedData.parent.type === 'album') { - type = 'album'; - item = { - name: cachedData.parent.title, - artist: cachedData.parent.artist, - total_tracks: cachedData.parent.total_tracks, - url: cachedData.parent.url - }; - } else if (cachedData.parent.type === 'playlist') { - type = 'playlist'; - item = { - name: cachedData.parent.name, - owner: cachedData.parent.owner, - total_tracks: cachedData.parent.total_tracks, - url: cachedData.parent.url - }; - } - } - // If we're reconstructing an album or playlist directly - else if (cachedData.type === 'album') { - item = { - name: cachedData.title || cachedData.album || 'Unknown album', - artist: cachedData.artist || 'Unknown artist', - total_tracks: typeof cachedData.total_tracks === 'string' ? parseInt(cachedData.total_tracks, 10) : cachedData.total_tracks || 0 - }; - } else if (cachedData.type === 'playlist') { - item = { - name: cachedData.name || 'Unknown playlist', - owner: cachedData.owner || 'Unknown creator', - total_tracks: typeof cachedData.total_tracks === 'string' ? parseInt(cachedData.total_tracks, 10) : cachedData.total_tracks || 0 - }; - } - } - - // Build the basic entry with possibly updated type and item - const entry: QueueEntry = { // Add type to entry - item, - type, - prgFile, - requestUrl, // for potential retry - element: this.createQueueItem(item, type, prgFile, queueId), - lastStatus: { - // Initialize with basic item metadata for immediate display - type, - status: 'initializing', - name: item.name || 'Unknown', - artist: item.artist || item.artists?.[0]?.name || '', - album: item.album?.name || '', - title: item.name || '', - owner: typeof item.owner === 'string' ? item.owner : item.owner?.display_name || '', - total_tracks: item.total_tracks || 0 - }, - lastUpdated: Date.now(), - hasEnded: false, - intervalId: null, - uniqueId: queueId, - retryCount: 0, - autoRetryInterval: null, - isNew: true, // Add flag to track if this is a new entry - status: 'initializing', - lastMessage: `Initializing ${type} download...`, - parentInfo: null, // Will store parent data for tracks that are part of albums/playlists - realTimeStallDetector: { count: 0, lastStatusJson: '' } // For detecting stalled real_time downloads - }; - - // If cached info exists for this PRG file, use it. - if (cachedData) { - entry.lastStatus = cachedData; - const logEl = entry.element.querySelector('.log') as HTMLElement | null; - - // Store parent information if available - if (cachedData.parent) { - entry.parentInfo = cachedData.parent; - } - - // Render status message for cached data - if (logEl) { // Check if logEl is not null - logEl.textContent = this.getStatusMessage(entry.lastStatus); - } - } - - // Store it in our queue object - this.queueEntries[queueId] = entry; - - return entry; - } - - /** - * Returns an HTML element for the queue entry with modern UI styling. - */ -createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string): HTMLElement { - // Track whether this is a multi-track item (album or playlist) - const isMultiTrack = type === 'album' || type === 'playlist'; - const defaultMessage = (type === 'playlist') ? 'Reading track list' : 'Initializing download...'; - - // Use display values if available, or fall back to standard fields - const displayTitle = item.name || item.music || item.song || 'Unknown'; - const displayArtist = item.artist || ''; - const displayType = type.charAt(0).toUpperCase() + type.slice(1); - - const div = document.createElement('article') as HTMLElement; // Cast to HTMLElement - div.className = 'queue-item queue-item-new'; // Add the animation class - div.setAttribute('aria-live', 'polite'); - div.setAttribute('aria-atomic', 'true'); - div.setAttribute('data-type', type); - - // Create modern HTML structure with better visual hierarchy - let innerHtml = ` -
-
-
${displayTitle}
- ${displayArtist ? `
${displayArtist}
` : ''} -
${displayType}
-
- -
- -
-
${defaultMessage}
- - - - -
- -
-
-
- - -
-
-
`; - - // For albums and playlists, add an overall progress container - if (isMultiTrack) { - innerHtml += ` -
-
- Overall Progress - 0/0 -
-
-
-
-
`; - } - - div.innerHTML = innerHtml; - - (div.querySelector('.cancel-btn') as HTMLButtonElement | null)?.addEventListener('click', (e: MouseEvent) => this.handleCancelDownload(e)); // Add types and optional chaining - - // Remove the animation class after animation completes - setTimeout(() => { - div.classList.remove('queue-item-new'); - }, 300); // Match the animation duration - - return div; -} - - // Add a helper method to apply the right CSS classes based on status - applyStatusClasses(entry: QueueEntry, statusData: StatusData) { // Add types for statusData - // If no element, nothing to do - if (!entry.element) return; - - // Remove all status classes first - entry.element.classList.remove( - 'queued', 'initializing', 'downloading', 'processing', - 'error', 'complete', 'cancelled', 'progress' - ); - - // Handle various status types - switch (statusData.status) { // Use statusData.status - case 'queued': - entry.element.classList.add('queued'); - break; - case 'initializing': - entry.element.classList.add('initializing'); - break; - case 'processing': - case 'downloading': - entry.element.classList.add('processing'); - break; - case 'progress': - case 'track_progress': - case 'real_time': - entry.element.classList.add('progress'); - break; - case 'error': - entry.element.classList.add('error'); - // Hide error-details to prevent duplicate error display - const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; - if (errorDetailsContainer) { - errorDetailsContainer.style.display = 'none'; - } - break; - case 'complete': - case 'done': - entry.element.classList.add('complete'); - // Hide error details if present - if (entry.element) { - const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; - if (errorDetailsContainer) { - errorDetailsContainer.style.display = 'none'; - } - } - break; - case 'cancelled': - entry.element.classList.add('cancelled'); - // Hide error details if present - if (entry.element) { - const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; - if (errorDetailsContainer) { - errorDetailsContainer.style.display = 'none'; - } - } - break; - } - } - - async handleCancelDownload(e: MouseEvent) { // Add type for e - const btn = (e.target as HTMLElement).closest('button') as HTMLButtonElement | null; // Add types and null check - if (!btn) return; // Guard clause - btn.style.display = 'none'; - const { prg, type, queueid } = btn.dataset; - if (!prg || !type || !queueid) return; // Guard against undefined dataset properties - - try { - // Get the queue item element - const entry = this.queueEntries[queueid]; - if (entry && entry.element) { - // Add a visual indication that it's being cancelled - entry.element.classList.add('cancelling'); - } - - // Show cancellation in progress - const logElement = document.getElementById(`log-${queueid}-${prg}`) as HTMLElement | null; - if (logElement) { - logElement.textContent = "Cancelling..."; - } - - // First cancel the download - const response = await fetch(`/api/${type}/download/cancel?prg_file=${prg}`); - const data = await response.json(); - // API returns status 'cancelled' when cancellation succeeds - if (data.status === "cancelled" || data.status === "cancel") { - if (entry) { - entry.hasEnded = true; - - // Close any active connections - this.clearPollingInterval(queueid); - - if (entry.intervalId) { - clearInterval(entry.intervalId as number); // Cast to number - entry.intervalId = null; - } - - // Mark as cancelled in the cache to prevent re-loading on page refresh - entry.status = "cancelled"; - this.queueCache[prg] = { status: "cancelled" }; - localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); - - // Immediately remove the item from the UI - this.cleanupEntry(queueid); - } - } - } catch (error) { - console.error('Cancel error:', error); - } - } - - /* Reorders the queue display, updates the total count, and handles "Show more" */ - updateQueueOrder() { - const container = document.getElementById('queueItems'); - const footer = document.getElementById('queueFooter'); - if (!container || !footer) return; // Guard against null - const entries = Object.values(this.queueEntries); - - // Sorting: errors/canceled first (group 0), ongoing next (group 1), queued last (group 2, sorted by position). - entries.sort((a: QueueEntry, b: QueueEntry) => { - const getGroup = (entry: QueueEntry) => { // Add type - if (entry.lastStatus && (entry.lastStatus.status === "error" || entry.lastStatus.status === "cancel")) { - return 0; - } else if (entry.lastStatus && entry.lastStatus.status === "queued") { - return 2; - } else { - return 1; - } - }; - const groupA = getGroup(a); - const groupB = getGroup(b); - if (groupA !== groupB) { - return groupA - groupB; - } else { - if (groupA === 2) { - const posA = a.lastStatus && a.lastStatus.position ? a.lastStatus.position : Infinity; - const posB = b.lastStatus && b.lastStatus.position ? b.lastStatus.position : Infinity; - return posA - posB; - } - return a.lastUpdated - b.lastUpdated; - } - }); - - // Update the header with just the total count - const queueTotalCountEl = document.getElementById('queueTotalCount') as HTMLElement | null; - if (queueTotalCountEl) { - queueTotalCountEl.textContent = entries.length.toString(); - } - - // Remove subtitle with detailed stats if it exists - const subtitleEl = document.getElementById('queueSubtitle'); - if (subtitleEl) { - subtitleEl.remove(); - } - - // Only recreate the container content if really needed - const visibleEntries = entries.slice(0, this.visibleCount); - - // Handle empty state - if (entries.length === 0) { - container.innerHTML = ` -
- Empty queue -

Your download queue is empty

-
- `; - } else { - // Get currently visible items - const visibleItems = Array.from(container.children).filter(el => el.classList.contains('queue-item')); - - // Update container more efficiently - if (visibleItems.length === 0) { - // No items in container, append all visible entries - container.innerHTML = ''; // Clear any empty state - visibleEntries.forEach((entry: QueueEntry) => { - // We no longer automatically start monitoring here - // Monitoring is now explicitly started by the methods that create downloads - container.appendChild(entry.element); - }); - } else { - // Container already has items, update more efficiently - - // Create a map of current DOM elements by queue ID - const existingElementMap: { [key: string]: HTMLElement } = {}; - visibleItems.forEach(el => { - const queueId = (el.querySelector('.cancel-btn') as HTMLElement | null)?.dataset.queueid; // Optional chaining - if (queueId) existingElementMap[queueId] = el as HTMLElement; // Cast to HTMLElement - }); - - // Clear container to re-add in correct order - container.innerHTML = ''; - - // Add visible entries in correct order - visibleEntries.forEach((entry: QueueEntry) => { - // We no longer automatically start monitoring here - container.appendChild(entry.element); - - // Mark the entry as not new anymore - entry.isNew = false; - }); - } - } - - // We no longer start or stop monitoring based on visibility changes here - // This allows the explicit monitoring control from the download methods - - // Ensure all currently visible and active entries are being polled - // This is important for items that become visible after "Show More" or other UI changes - Object.values(this.queueEntries).forEach(entry => { - if (this.isEntryVisible(entry.uniqueId) && !entry.hasEnded && !this.pollingIntervals[entry.uniqueId]) { - console.log(`updateQueueOrder: Ensuring polling for visible/active entry ${entry.uniqueId} (${entry.prgFile})`); - this.setupPollingInterval(entry.uniqueId); - } - }); - - // Update footer - footer.innerHTML = ''; - if (entries.length > this.visibleCount) { - const remaining = entries.length - this.visibleCount; - const showMoreBtn = document.createElement('button'); - showMoreBtn.textContent = `Show ${remaining} more`; - showMoreBtn.addEventListener('click', () => { - this.visibleCount += 10; - localStorage.setItem("downloadQueueVisibleCount", this.visibleCount.toString()); // toString - this.updateQueueOrder(); - }); - footer.appendChild(showMoreBtn); - } - } - - /* Checks if an entry is visible in the queue display. */ - isEntryVisible(queueId: string): boolean { // Add return type - const entries = Object.values(this.queueEntries); - entries.sort((a: QueueEntry, b: QueueEntry) => { - const getGroup = (entry: QueueEntry) => { // Add type - if (entry.lastStatus && (entry.lastStatus.status === "error" || entry.lastStatus.status === "cancel")) { - return 0; - } else if (entry.lastStatus && entry.lastStatus.status === "queued") { - return 2; - } else { - return 1; - } - }; - const groupA = getGroup(a); - const groupB = getGroup(b); - if (groupA !== groupB) { - return groupA - groupB; - } else { - if (groupA === 2) { - const posA = a.lastStatus && a.lastStatus.position ? a.lastStatus.position : Infinity; - const posB = b.lastStatus && b.lastStatus.position ? b.lastStatus.position : Infinity; - return posA - posB; - } - return a.lastUpdated - b.lastUpdated; - } - }); - const index = entries.findIndex((e: QueueEntry) => e.uniqueId === queueId); - return index >= 0 && index < this.visibleCount; - } - - async cleanupEntry(queueId: string) { - const entry = this.queueEntries[queueId]; - if (entry) { - // Close any polling interval - this.clearPollingInterval(queueId); - - // Clean up any intervals - if (entry.intervalId) { - clearInterval(entry.intervalId as number); // Cast to number - } - if (entry.autoRetryInterval) { - clearInterval(entry.autoRetryInterval as number); // Cast to number - } - - // Remove from the DOM - entry.element.remove(); - - // Delete from in-memory queue - delete this.queueEntries[queueId]; - - // Remove the cached info - if (this.queueCache[entry.prgFile]) { - delete this.queueCache[entry.prgFile]; - localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); - } - - // Update the queue display - this.updateQueueOrder(); - } - } - - /* Event Dispatching */ - dispatchEvent(name: string, detail: any) { // Add type for name - document.dispatchEvent(new CustomEvent(name, { detail })); - } - - /* Status Message Handling */ - getStatusMessage(data: StatusData): string { // Add types - // Determine the true display type - if this is a track with a parent, we may want to - // show it as part of the parent's download process - let displayType = data.type || 'unknown'; - let isChildTrack = false; - - // If this is a track that's part of an album/playlist, note that - if (data.type === 'track' && data.parent) { - isChildTrack = true; - // We'll still use track-specific info but note it's part of a parent - } - - // Find the queue item this status belongs to - let queueItem: QueueEntry | null = null; - const prgFile = data.prg_file || Object.keys(this.queueCache).find(key => - this.queueCache[key].status === data.status && this.queueCache[key].type === data.type - ); - - if (prgFile) { - const queueId = Object.keys(this.queueEntries).find(id => - this.queueEntries[id].prgFile === prgFile - ); - if (queueId) { - queueItem = this.queueEntries[queueId]; - } - } - - // Extract common fields - const trackName = data.song || data.music || data.name || data.title || - (queueItem?.item?.name) || 'Unknown'; - const artist = data.artist || data.artist_name || - (queueItem?.item?.artist) || ''; - const albumTitle = data.title || data.album || data.parent?.title || data.name || - (queueItem?.item?.name) || ''; - const playlistName = data.name || data.parent?.name || - (queueItem?.item?.name) || ''; - const playlistOwner = data.owner || data.parent?.owner || - (queueItem?.item?.owner) || ''; // Add type check if item.owner is object - const currentTrack = data.current_track || data.parsed_current_track || ''; - const totalTracks = data.total_tracks || data.parsed_total_tracks || data.parent?.total_tracks || - (queueItem?.item?.total_tracks) || ''; - - // Format percentage for display when available - let formattedPercentage = '0'; - if (data.progress !== undefined) { - formattedPercentage = parseFloat(data.progress as string).toFixed(1); // Cast to string - } else if (data.percentage) { - formattedPercentage = (parseFloat(data.percentage as string) * 100).toFixed(1); // Cast to string - } else if (data.percent) { - formattedPercentage = (parseFloat(data.percent as string) * 100).toFixed(1); // Cast to string - } - - // Helper for constructing info about the parent item - const getParentInfo = (): string => { // Add return type - if (!data.parent) return ''; - - if (data.parent.type === 'album') { - return ` from album "${data.parent.title}"`; - } else if (data.parent.type === 'playlist') { - return ` from playlist "${data.parent.name}" by ${data.parent.owner}`; - } - return ''; - }; - - // Status-based message generation - switch (data.status) { - case 'queued': - if (data.type === 'track') { - return `Queued track "${trackName}"${artist ? ` by ${artist}` : ''}${getParentInfo()}`; - } else if (data.type === 'album') { - return `Queued album "${albumTitle}"${artist ? ` by ${artist}` : ''} (${totalTracks || '?'} tracks)`; - } else if (data.type === 'playlist') { - return `Queued playlist "${playlistName}"${playlistOwner ? ` by ${playlistOwner}` : ''} (${totalTracks || '?'} tracks)`; - } - return `Queued ${data.type}`; - - case 'initializing': - return `Preparing to download...`; - - case 'processing': - // Special case: If this is a track that's part of an album/playlist - if (data.type === 'track' && data.parent) { - if (data.parent.type === 'album') { - return `Processing track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} (from album "${data.parent.title}")`; - } else if (data.parent.type === 'playlist') { - return `Processing track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} (from playlist "${data.parent.name}")`; - } - } - - // Regular standalone track - if (data.type === 'track') { - return `Processing track "${trackName}"${artist ? ` by ${artist}` : ''}${getParentInfo()}`; - } - // Album download - else if (data.type === 'album') { - // For albums, show current track info if available - if (trackName && artist && currentTrack && totalTracks) { - return `Processing track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist}`; - } else if (currentTrack && totalTracks) { - // If we have track numbers but not names - return `Processing track ${currentTrack} of ${totalTracks} from album "${albumTitle}"`; - } else if (totalTracks) { - return `Processing album "${albumTitle}" (${totalTracks} tracks)`; - } - return `Processing album "${albumTitle}"...`; - } - // Playlist download - else if (data.type === 'playlist') { - // For playlists, show current track info if available - if (trackName && artist && currentTrack && totalTracks) { - return `Processing track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist}`; - } else if (currentTrack && totalTracks) { - // If we have track numbers but not names - return `Processing track ${currentTrack} of ${totalTracks} from playlist "${playlistName}"`; - } else if (totalTracks) { - return `Processing playlist "${playlistName}" (${totalTracks} tracks)`; - } - return `Processing playlist "${playlistName}"...`; - } - return `Processing ${data.type}...`; - - case 'progress': - // Special case: If this is a track that's part of an album/playlist - if (data.type === 'track' && data.parent) { - if (data.parent.type === 'album') { - return `Downloading track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} (from album "${data.parent.title}")`; - } else if (data.parent.type === 'playlist') { - return `Downloading track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} (from playlist "${data.parent.name}")`; - } - } - - // Regular standalone track - if (data.type === 'track') { - return `Downloading track "${trackName}"${artist ? ` by ${artist}` : ''}${getParentInfo()}`; - } - // Album download - else if (data.type === 'album') { - // For albums, show current track info if available - if (trackName && artist && currentTrack && totalTracks) { - return `Downloading track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist}`; - } else if (currentTrack && totalTracks) { - // If we have track numbers but not names - return `Downloading track ${currentTrack} of ${totalTracks} from album "${albumTitle}"`; - } else if (totalTracks) { - return `Downloading album "${albumTitle}" (${totalTracks} tracks)`; - } - return `Downloading album "${albumTitle}"...`; - } - // Playlist download - else if (data.type === 'playlist') { - // For playlists, show current track info if available - if (trackName && artist && currentTrack && totalTracks) { - return `Downloading track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist}`; - } else if (currentTrack && totalTracks) { - // If we have track numbers but not names - return `Downloading track ${currentTrack} of ${totalTracks} from playlist "${playlistName}"`; - } else if (totalTracks) { - return `Downloading playlist "${playlistName}" (${totalTracks} tracks)`; - } - return `Downloading playlist "${playlistName}"...`; - } - return `Downloading ${data.type}...`; - - case 'real-time': - case 'real_time': - // Special case: If this is a track that's part of an album/playlist - if (data.type === 'track' && data.parent) { - if (data.parent.type === 'album') { - return `Downloading track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} - ${formattedPercentage}% (from album "${data.parent.title}")`; - } else if (data.parent.type === 'playlist') { - return `Downloading track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} - ${formattedPercentage}% (from playlist "${data.parent.name}")`; - } - } - - // Regular standalone track - if (data.type === 'track') { - return `Downloading "${trackName}" - ${formattedPercentage}%${getParentInfo()}`; - } - // Album with track info - else if (data.type === 'album' && trackName && artist) { - return `Downloading ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} - ${formattedPercentage}%`; - } - // Playlist with track info - else if (data.type === 'playlist' && trackName && artist) { - return `Downloading ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} - ${formattedPercentage}%`; - } - // Generic with percentage - else { - const itemName = data.type === 'album' ? albumTitle : - (data.type === 'playlist' ? playlistName : data.type); - return `Downloading ${data.type} "${itemName}" - ${formattedPercentage}%`; - } - - case 'done': - case 'complete': - if (data.type === 'track') { - return `Downloaded "${trackName}"${artist ? ` by ${artist}` : ''} successfully${getParentInfo()}`; - } else if (data.type === 'album') { - return `Downloaded album "${albumTitle}"${artist ? ` by ${artist}` : ''} successfully (${totalTracks} tracks)`; - } else if (data.type === 'playlist') { - return `Downloaded playlist "${playlistName}"${playlistOwner ? ` by ${playlistOwner}` : ''} successfully (${totalTracks} tracks)`; - } - return `Downloaded ${data.type} successfully`; - - case 'skipped': - return `${trackName}${artist ? ` by ${artist}` : ''} was skipped: ${data.reason || 'Unknown reason'}`; - - case 'error': - // Enhanced error message handling using the new format - let errorMsg = `Error: ${data.error}`; - - // Add position information for tracks in collections - if (data.current_track && data.total_tracks) { - errorMsg = `Error on track ${data.current_track}/${data.total_tracks}: ${data.error}`; - } - - // Add retry information if available - if (data.retry_count !== undefined) { - errorMsg += ` (Attempt ${data.retry_count}/${this.MAX_RETRIES})`; - } else if (data.can_retry !== undefined) { - if (data.can_retry) { - errorMsg += ` (Can be retried)`; - } else { - errorMsg += ` (Max retries reached)`; - } - } - - // Add parent information if this is a track with a parent - if (data.type === 'track' && data.parent) { - if (data.parent.type === 'album') { - errorMsg += `\nFrom album: "${data.parent.title}" by ${data.parent.artist || 'Unknown artist'}`; - } else if (data.parent.type === 'playlist') { - errorMsg += `\nFrom playlist: "${data.parent.name}" by ${data.parent.owner || 'Unknown creator'}`; - } - } - - // Add URL for troubleshooting if available - if (data.url) { - errorMsg += `\nSource: ${data.url}`; - } - - return errorMsg; - - case 'retrying': - let retryMsg = 'Retrying'; - if (data.retry_count) { - retryMsg += ` (${data.retry_count}/${this.MAX_RETRIES})`; - } - if (data.seconds_left) { - retryMsg += ` in ${data.seconds_left}s`; - } - if (data.error) { - retryMsg += `: ${data.error}`; - } - return retryMsg; - - case 'cancelled': - case 'cancel': - return 'Cancelling...'; - - default: - return data.status || 'Unknown status'; - } - } - - /* New Methods to Handle Terminal State, Inactivity and Auto-Retry */ - handleDownloadCompletion(entry: QueueEntry, queueId: string, progress: StatusData | number) { // Add types - // Mark the entry as ended - entry.hasEnded = true; - - // Update progress bar if available - if (typeof progress === 'number') { - const progressBar = entry.element.querySelector('.progress-bar') as HTMLElement | null; - if (progressBar) { - progressBar.style.width = '100%'; - progressBar.setAttribute('aria-valuenow', "100"); // Use string for aria-valuenow - progressBar.classList.add('bg-success'); - } - } - - // Stop polling - this.clearPollingInterval(queueId); - - // Use 3 seconds cleanup delay for completed, 10 seconds for other terminal states like errors - const cleanupDelay = (progress && typeof progress !== 'number' && (progress.status === 'complete' || progress.status === 'done')) ? 3000 : - (progress && typeof progress !== 'number' && (progress.status === 'cancelled' || progress.status === 'cancel' || progress.status === 'skipped')) ? 20000 : - 10000; // Default for other errors if not caught by the more specific error handler delay - - // Clean up after the appropriate delay - setTimeout(() => { - this.cleanupEntry(queueId); - }, cleanupDelay); - } - - handleInactivity(entry: QueueEntry, queueId: string, logElement: HTMLElement | null) { // Add types - if (entry.lastStatus && entry.lastStatus.status === 'queued') { - if (logElement) { - logElement.textContent = this.getStatusMessage(entry.lastStatus); - } - return; - } - const now = Date.now(); - if (now - entry.lastUpdated > 300000) { - const progressData: StatusData = { status: 'error', error: 'Inactivity timeout' }; // Use error property - this.handleDownloadCompletion(entry, queueId, progressData); // Pass StatusData - } else { - if (logElement) { - logElement.textContent = this.getStatusMessage(entry.lastStatus); - } - } - } - - async retryDownload(queueId: string, logElement: HTMLElement | null) { // Add type - const entry = this.queueEntries[queueId]; - if (!entry) { - console.warn(`Retry called for non-existent queueId: ${queueId}`); - return; - } - - // The retry button is already showing "Retrying..." and is disabled by the click handler. - // We will update the error message div within logElement if retry fails. - const errorMessageDiv = logElement?.querySelector('.error-message') as HTMLElement | null; - const retryBtn = logElement?.querySelector('.retry-btn') as HTMLButtonElement | null; - - entry.isRetrying = true; // Mark the original entry as being retried. - - // Determine if we should use parent information for retry (existing logic) - let useParent = false; - let parentType: string | null = null; // Add type - let parentUrl: string | null = null; // Add type - if (entry.lastStatus && entry.lastStatus.parent) { - const parent = entry.lastStatus.parent; - if (parent.type && parent.url) { - useParent = true; - parentType = parent.type; - parentUrl = parent.url; - console.log(`Using parent info for retry: ${parentType} with URL: ${parentUrl}`); - } - } - - const getRetryUrl = (): string | null => { // Add return type - if (entry.lastStatus && entry.lastStatus.original_url) return entry.lastStatus.original_url; - if (useParent && parentUrl) return parentUrl; - if (entry.requestUrl) return entry.requestUrl; - if (entry.lastStatus && entry.lastStatus.original_request) { - if (entry.lastStatus.original_request.retry_url) return entry.lastStatus.original_request.retry_url; - if (entry.lastStatus.original_request.url) return entry.lastStatus.original_request.url; - } - if (entry.lastStatus && entry.lastStatus.url) return entry.lastStatus.url; - return null; - }; - - const retryUrl = getRetryUrl(); - - if (!retryUrl) { - if (errorMessageDiv) errorMessageDiv.textContent = 'Retry not available: missing URL information.'; - entry.isRetrying = false; - if (retryBtn) { - retryBtn.disabled = false; - retryBtn.innerHTML = 'Retry'; // Reset button text - } - return; - } - - // Store details needed for the new entry BEFORE any async operations - const originalItem: QueueItem = { ...entry.item }; // Shallow copy, add type - const apiTypeForNewEntry = useParent && parentType ? parentType : entry.type; // Ensure parentType is not null - console.log(`Retrying download using type: ${apiTypeForNewEntry} with base URL: ${retryUrl}`); - - let fullRetryUrl; - if (retryUrl.startsWith('http') || retryUrl.startsWith('/api/')) { // if it's already a full URL or an API path - fullRetryUrl = retryUrl; - } else { - // Construct full URL if retryUrl is just a resource identifier - fullRetryUrl = `/api/${apiTypeForNewEntry}/download?url=${encodeURIComponent(retryUrl)}`; - // Append metadata if retryUrl is raw resource URL - if (originalItem && originalItem.name) { - fullRetryUrl += `&name=${encodeURIComponent(originalItem.name)}`; - } - if (originalItem && originalItem.artist) { - fullRetryUrl += `&artist=${encodeURIComponent(originalItem.artist)}`; - } - } - const requestUrlForNewEntry = fullRetryUrl; - - try { - // Clear polling for the old entry before making the request - this.clearPollingInterval(queueId); - - const retryResponse = await fetch(fullRetryUrl); - if (!retryResponse.ok) { - const errorText = await retryResponse.text(); - throw new Error(`Server returned ${retryResponse.status}${errorText ? (': ' + errorText) : ''}`); - } - - const retryData: StatusData = await retryResponse.json(); // Add type - - if (retryData.prg_file) { - const newPrgFile = retryData.prg_file; - - // Clean up the old entry from UI, memory, cache, and server (PRG file) - // logElement and retryBtn are part of the old entry's DOM structure and will be removed. - await this.cleanupEntry(queueId); - - // Add the new download entry. This will create a new element, start monitoring, etc. - this.addDownload(originalItem, apiTypeForNewEntry, newPrgFile, requestUrlForNewEntry, true); - - // The old setTimeout block for deleting oldPrgFile is no longer needed as cleanupEntry handles it. - } else { - if (errorMessageDiv) errorMessageDiv.textContent = 'Retry failed: invalid response from server.'; - const currentEntry = this.queueEntries[queueId]; // Check if old entry still exists - if (currentEntry) { - currentEntry.isRetrying = false; - } - if (retryBtn) { - retryBtn.disabled = false; - retryBtn.innerHTML = 'Retry'; - } - } - } catch (error) { - console.error('Retry error:', error); - // The old entry might still be in the DOM if cleanupEntry wasn't called or failed. - const stillExistingEntry = this.queueEntries[queueId]; - if (stillExistingEntry && stillExistingEntry.element) { - // logElement might be stale if the element was re-rendered, so query again if possible. - const currentLogOnFailedEntry = stillExistingEntry.element.querySelector('.log') as HTMLElement | null; - const errorDivOnFailedEntry = currentLogOnFailedEntry?.querySelector('.error-message') as HTMLElement | null || errorMessageDiv; - const retryButtonOnFailedEntry = currentLogOnFailedEntry?.querySelector('.retry-btn') as HTMLButtonElement | null || retryBtn; - - if (errorDivOnFailedEntry) errorDivOnFailedEntry.textContent = 'Retry failed: ' + (error as Error).message; // Cast error to Error - stillExistingEntry.isRetrying = false; - if (retryButtonOnFailedEntry) { - retryButtonOnFailedEntry.disabled = false; - retryButtonOnFailedEntry.innerHTML = 'Retry'; - } - } else if (errorMessageDiv) { - // Fallback if entry is gone from queue but original logElement's parts are somehow still accessible - errorMessageDiv.textContent = 'Retry failed: ' + (error as Error).message; - if (retryBtn) { - retryBtn.disabled = false; - retryBtn.innerHTML = 'Retry'; - } - } - } - } - - /** - * Start monitoring for all active entries in the queue that are visible - */ - startMonitoringActiveEntries() { - for (const queueId in this.queueEntries) { - const entry = this.queueEntries[queueId]; - // Only start monitoring if the entry is not in a terminal state and is visible - if (!entry.hasEnded && this.isEntryVisible(queueId) && !this.pollingIntervals[queueId]) { - this.setupPollingInterval(queueId); - } - } - } - - /** - * Centralized download method for all content types. - * This method replaces the individual startTrackDownload, startAlbumDownload, etc. methods. - * It will be called by all the other JS files. - */ - async download(itemId: string, type: string, item: QueueItem, albumType: string | null = null): Promise { // Add types and return type - if (!itemId) { - throw new Error('Missing ID for download'); - } - - await this.loadConfig(); - - // Construct the API URL in the new format /api/{type}/download/{itemId} - let apiUrl = `/api/${type}/download/${itemId}`; - - // Prepare query parameters - const queryParams = new URLSearchParams(); - // item.name and item.artist are no longer sent as query parameters - // if (item.name && item.name.trim() !== '') queryParams.append('name', item.name); - // if (item.artist && item.artist.trim() !== '') queryParams.append('artist', item.artist); - - // For artist downloads, include album_type as it may still be needed - if (type === 'artist' && albumType) { - queryParams.append('album_type', albumType); - } - - const queryString = queryParams.toString(); - if (queryString) { - apiUrl += `?${queryString}`; - } - - console.log(`Constructed API URL for download: ${apiUrl}`); // Log the constructed URL - - try { - // Show a loading indicator - const queueIcon = document.getElementById('queueIcon'); // No direct classList manipulation - if (queueIcon) { - queueIcon.classList.add('queue-icon-active'); - } - - const response = await fetch(apiUrl); - if (!response.ok) { - throw new Error(`Server returned ${response.status}`); - } - - const data: StatusData | { task_ids?: string[], album_prg_files?: string[] } = await response.json(); // Add type for data - - // Handle artist downloads which return multiple album tasks - if (type === 'artist') { - // Check for new API response format - if ('task_ids' in data && data.task_ids && Array.isArray(data.task_ids)) { // Type guard - console.log(`Queued artist discography with ${data.task_ids.length} albums`); - - // Make queue visible to show progress - this.toggleVisibility(true); - - // Create entries directly from task IDs and start monitoring them - const queueIds: string[] = []; // Add type - for (const taskId of data.task_ids) { - console.log(`Adding album task with ID: ${taskId}`); - // Create an album item with better display information - const albumItem: QueueItem = { // Add type - name: `${item.name || 'Artist'} - Album (loading...)`, - artist: item.name || 'Unknown artist', - type: 'album' - }; - // Use improved addDownload with forced monitoring - const queueId = this.addDownload(albumItem, 'album', taskId, apiUrl, true); - queueIds.push(queueId); - } - - return queueIds; - } - // Check for older API response format - else if ('album_prg_files' in data && data.album_prg_files && Array.isArray(data.album_prg_files)) { // Type guard - console.log(`Queued artist discography with ${data.album_prg_files.length} albums (old format)`); - - // Make queue visible to show progress - this.toggleVisibility(true); - - // Add each album to the download queue separately with forced monitoring - const queueIds: string[] = []; // Add type - data.album_prg_files.forEach(prgFile => { - console.log(`Adding album with PRG file: ${prgFile}`); - // Create an album item with better display information - const albumItem: QueueItem = { // Add type - name: `${item.name || 'Artist'} - Album (loading...)`, - artist: item.name || 'Unknown artist', - type: 'album' - }; - // Use improved addDownload with forced monitoring - const queueId = this.addDownload(albumItem, 'album', prgFile, apiUrl, true); - queueIds.push(queueId); - }); - - return queueIds; - } - // Handle any other response format for artist downloads - else { - console.log(`Queued artist discography with unknown format:`, data); - - // Make queue visible - this.toggleVisibility(true); - - // Just load existing PRG files as a fallback - await this.loadExistingPrgFiles(); - - // Force start monitoring for all loaded entries - for (const queueId in this.queueEntries) { - const entry = this.queueEntries[queueId]; - if (!entry.hasEnded) { - this.startDownloadStatusMonitoring(queueId); - } - } - - return data; - } - } - - // Handle single-file downloads (tracks, albums, playlists) - if ('prg_file' in data && data.prg_file) { // Type guard - console.log(`Adding ${type} PRG file: ${data.prg_file}`); - - // Store the initial metadata in the cache so it's available - // even before the first status update - this.queueCache[data.prg_file] = { - type, - status: 'initializing', - name: item.name || 'Unknown', - title: item.name || 'Unknown', - artist: item.artist || (item.artists && item.artists.length > 0 ? item.artists[0].name : ''), - owner: typeof item.owner === 'string' ? item.owner : item.owner?.display_name || '', - total_tracks: item.total_tracks || 0 - }; - - // Use direct monitoring for all downloads for consistency - const queueId = this.addDownload(item, type, data.prg_file, apiUrl, true); - - // Make queue visible to show progress if not already visible - if (this.config && !this.config.downloadQueueVisible) { // Add null check for config - this.toggleVisibility(true); - } - - return queueId; - } else { - throw new Error('Invalid response format from server'); - } - } catch (error) { - this.dispatchEvent('downloadError', { error, item }); - throw error; - } - } - - /** - * Loads existing PRG files from the /api/prgs/list endpoint and adds them as queue entries. - */ - async loadExistingPrgFiles() { - try { - // Clear existing queue entries first to avoid duplicates when refreshing - for (const queueId in this.queueEntries) { - const entry = this.queueEntries[queueId]; - this.clearPollingInterval(queueId); - delete this.queueEntries[queueId]; - } - - // Fetch detailed task list from the new endpoint - const response = await fetch('/api/prgs/list'); - if (!response.ok) { - console.error("Failed to load existing tasks:", response.status, await response.text()); - return; - } - const existingTasks: any[] = await response.json(); // We expect an array of detailed task objects - - const terminalStates = ['complete', 'done', 'cancelled', 'ERROR_AUTO_CLEANED', 'ERROR_RETRIED', 'cancel', 'interrupted', 'error']; - - for (const taskData of existingTasks) { - const prgFile = taskData.task_id; // Use task_id as prgFile identifier - const lastStatus = taskData.last_status_obj; - const originalRequest = taskData.original_request || {}; - - // Skip adding to UI if the task is already in a terminal state - if (lastStatus && terminalStates.includes(lastStatus.status)) { - console.log(`Skipping UI addition for terminal task ${prgFile}, status: ${lastStatus.status}`); - // Also ensure it's cleaned from local cache if it was there - if (this.queueCache[prgFile]) { - delete this.queueCache[prgFile]; - } - continue; - } - - let itemType = taskData.type || originalRequest.type || 'unknown'; - let dummyItem: QueueItem = { - name: taskData.name || originalRequest.name || prgFile, - artist: taskData.artist || originalRequest.artist || '', - type: itemType, - url: originalRequest.url || lastStatus?.url || '', - endpoint: originalRequest.endpoint || '', - download_type: taskData.download_type || originalRequest.download_type || '', - total_tracks: lastStatus?.total_tracks || originalRequest.total_tracks, - current_track: lastStatus?.current_track, - }; - - // If this is a track with a parent from the last_status, adjust item and type - if (lastStatus && lastStatus.type === 'track' && lastStatus.parent) { - const parent = lastStatus.parent; - if (parent.type === 'album') { - itemType = 'album'; - dummyItem = { - name: parent.title || 'Unknown Album', - artist: parent.artist || 'Unknown Artist', - type: 'album', - url: parent.url || '', - total_tracks: parent.total_tracks || lastStatus.total_tracks, - parent: parent - }; - } else if (parent.type === 'playlist') { - itemType = 'playlist'; - dummyItem = { - name: parent.name || 'Unknown Playlist', - owner: parent.owner || 'Unknown Creator', - type: 'playlist', - url: parent.url || '', - total_tracks: parent.total_tracks || lastStatus.total_tracks, - parent: parent - }; - } - } - - let retryCount = 0; - if (lastStatus && lastStatus.retry_count) { - retryCount = lastStatus.retry_count; - } else if (prgFile.includes('_retry')) { - const retryMatch = prgFile.match(/_retry(\d+)/); - if (retryMatch && retryMatch[1]) { - retryCount = parseInt(retryMatch[1], 10); - } - } - - const requestUrl = originalRequest.url ? `/api/${itemType}/download/${originalRequest.url.split('/').pop()}?name=${encodeURIComponent(dummyItem.name || '')}&artist=${encodeURIComponent(dummyItem.artist || '')}` : null; - - const queueId = this.generateQueueId(); - const entry = this.createQueueEntry(dummyItem, itemType, prgFile, queueId, requestUrl); - entry.retryCount = retryCount; - - if (lastStatus) { - entry.lastStatus = lastStatus; - if (lastStatus.parent) { - entry.parentInfo = lastStatus.parent; - } - this.queueCache[prgFile] = lastStatus; // Cache the last known status - this.applyStatusClasses(entry, lastStatus); - - const logElement = entry.element.querySelector('.log') as HTMLElement | null; - if (logElement) { - logElement.textContent = this.getStatusMessage(lastStatus); - } - } - this.queueEntries[queueId] = entry; - } - - localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); - this.updateQueueOrder(); - this.startMonitoringActiveEntries(); - } catch (error) { - console.error("Error loading existing PRG files:", error); - } - } - - async loadConfig() { - try { - const response = await fetch('/api/config'); - if (!response.ok) throw new Error('Failed to fetch config'); - this.config = await response.json(); - - // Update our retry constants from the server config - if (this.config.maxRetries !== undefined) { - this.MAX_RETRIES = this.config.maxRetries; - } - if (this.config.retryDelaySeconds !== undefined) { - this.RETRY_DELAY = this.config.retryDelaySeconds; - } - if (this.config.retry_delay_increase !== undefined) { - this.RETRY_DELAY_INCREASE = this.config.retry_delay_increase; - } - - console.log(`Loaded retry settings from config: max=${this.MAX_RETRIES}, delay=${this.RETRY_DELAY}, increase=${this.RETRY_DELAY_INCREASE}`); - } catch (error) { - console.error('Error loading config:', error); - this.config = { // Initialize with a default structure on error - downloadQueueVisible: false, - maxRetries: 3, - retryDelaySeconds: 5, - retry_delay_increase: 5, - explicitFilter: false - }; - } - } - - async saveConfig(updatedConfig: AppConfig) { // Add type - try { - const response = await fetch('/api/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updatedConfig) - }); - if (!response.ok) throw new Error('Failed to save config'); - this.config = await response.json(); - } catch (error) { - console.error('Error saving config:', error); - throw error; - } - } - - // Add a method to check if explicit filter is enabled - isExplicitFilterEnabled(): boolean { // Add return type - return !!this.config.explicitFilter; - } - - /* Sets up a polling interval for real-time status updates */ - setupPollingInterval(queueId: string) { // Add type - console.log(`Setting up polling for ${queueId}`); - const entry = this.queueEntries[queueId]; - if (!entry || !entry.prgFile) { - console.warn(`No entry or prgFile for ${queueId}`); - return; - } - - // Close any existing connection - this.clearPollingInterval(queueId); - - try { - // Immediately fetch initial data - this.fetchDownloadStatus(queueId); - - // Create a polling interval of 500ms for more responsive UI updates - const intervalId = setInterval(() => { - this.fetchDownloadStatus(queueId); - }, 500); - - // Store the interval ID for later cleanup - this.pollingIntervals[queueId] = intervalId as unknown as number; // Cast to number via unknown - } catch (error) { - console.error(`Error creating polling for ${queueId}:`, error); - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; - if (logElement) { - logElement.textContent = `Error with download: ${(error as Error).message}`; // Cast to Error - entry.element.classList.add('error'); - } - } - } - - async fetchDownloadStatus(queueId: string) { // Add type - const entry = this.queueEntries[queueId]; - if (!entry || !entry.prgFile) { - console.warn(`No entry or prgFile for ${queueId}`); - return; - } - - try { - const response = await fetch(`/api/prgs/${entry.prgFile}`); - if (!response.ok) { - throw new Error(`HTTP error: ${response.status}`); - } - - const data: StatusData = await response.json(); // Add type - - // If the last_line doesn't have name/artist/title info, add it from our stored item data - if (data.last_line && entry.item) { - if (!data.last_line.name && entry.item.name) { - data.last_line.name = entry.item.name; - } - if (!data.last_line.title && entry.item.name) { - data.last_line.title = entry.item.name; - } - if (!data.last_line.artist && entry.item.artist) { - data.last_line.artist = entry.item.artist; - } else if (!data.last_line.artist && entry.item.artists && entry.item.artists.length > 0) { - data.last_line.artist = entry.item.artists[0].name; - } - if (!data.last_line.owner && entry.item.owner) { - data.last_line.owner = typeof entry.item.owner === 'string' ? entry.item.owner : entry.item.owner?.display_name ; - } - if (!data.last_line.total_tracks && entry.item.total_tracks) { - data.last_line.total_tracks = entry.item.total_tracks; - } - } - - // Initialize the download type if needed - if (data.type && !entry.type) { - console.log(`Setting entry type to: ${data.type}`); - entry.type = data.type; - - // Update type display if element exists - const typeElement = entry.element.querySelector('.type') as HTMLElement | null; - if (typeElement) { - typeElement.textContent = data.type.charAt(0).toUpperCase() + data.type.slice(1); - // Update type class without triggering animation - typeElement.className = `type ${data.type}`; - } - } - - // Special handling for track updates that are part of an album/playlist - // Don't filter these out as they contain important track progress info - if (data.last_line && data.last_line.type === 'track' && data.last_line.parent) { - // This is a track update that's part of our album/playlist - keep it - if ((entry.type === 'album' && data.last_line.parent.type === 'album') || - (entry.type === 'playlist' && data.last_line.parent.type === 'playlist')) { - console.log(`Processing track update for ${entry.type} download: ${data.last_line.song}`); - // Continue processing - don't return - } - } - // Only filter out updates that don't match entry type AND don't have a relevant parent - else if (data.last_line && data.last_line.type && entry.type && - data.last_line.type !== entry.type && - (!data.last_line.parent || data.last_line.parent.type !== entry.type)) { - console.log(`Skipping status update with type '${data.last_line.type}' for entry with type '${entry.type}'`); - return; - } - - // Process the update - this.handleStatusUpdate(queueId, data); - - // Handle terminal states - if (data.last_line && ['complete', 'error', 'cancelled', 'done'].includes(data.last_line.status || '')) { // Add null check - console.log(`Terminal state detected: ${data.last_line.status} for ${queueId}`); - entry.hasEnded = true; - - // For cancelled downloads, clean up immediately - if (data.last_line.status === 'cancelled' || data.last_line.status === 'cancel') { - console.log('Cleaning up cancelled download immediately'); - this.clearPollingInterval(queueId); - this.cleanupEntry(queueId); - return; // No need to process further - } - - // Only set up cleanup if this is not an error that we're in the process of retrying - // If status is 'error' but the status message contains 'Retrying', don't clean up - const isRetrying = entry.isRetrying || - (data.last_line.status === 'error' && - entry.element.querySelector('.log')?.textContent?.includes('Retry')); - - if (!isRetrying) { - setTimeout(() => { - // Double-check the entry still exists and has not been retried before cleaning up - const currentEntry = this.queueEntries[queueId]; // Get current entry - if (currentEntry && // Check if currentEntry exists - !currentEntry.isRetrying && - currentEntry.hasEnded) { - this.clearPollingInterval(queueId); - this.cleanupEntry(queueId); - } - }, data.last_line.status === 'complete' || data.last_line.status === 'done' ? 3000 : 5000); // 3s for complete/done, 5s for others - } - } - - } catch (error) { - console.error(`Error fetching status for ${queueId}:`, error); - - // Show error in log - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; - if (logElement) { - logElement.textContent = `Error updating status: ${(error as Error).message}`; // Cast to Error - } - } - } - - clearPollingInterval(queueId: string) { // Add type - if (this.pollingIntervals[queueId]) { - console.log(`Stopping polling for ${queueId}`); - try { - clearInterval(this.pollingIntervals[queueId] as number); // Cast to number - } catch (error) { - console.error(`Error stopping polling for ${queueId}:`, error); - } - delete this.pollingIntervals[queueId]; - } - } - - /* Handle status updates from the progress API */ - handleStatusUpdate(queueId: string, data: StatusData) { // Add types - const entry = this.queueEntries[queueId]; - if (!entry) { - console.warn(`No entry for ${queueId}`); - return; - } - - // Extract the actual status data from the API response - const statusData: StatusData = data.last_line || {}; // Add type - - // Special handling for track status updates that are part of an album/playlist - // We want to keep these for showing the track-by-track progress - if (statusData.type === 'track' && statusData.parent) { - // If this is a track that's part of our album/playlist, keep it - if ((entry.type === 'album' && statusData.parent.type === 'album') || - (entry.type === 'playlist' && statusData.parent.type === 'playlist')) { - console.log(`Processing track status update for ${entry.type}: ${statusData.song}`); - } - } - // Only skip updates where type doesn't match AND there's no relevant parent relationship - else if (statusData.type && entry.type && statusData.type !== entry.type && - (!statusData.parent || statusData.parent.type !== entry.type)) { - console.log(`Skipping mismatched type: update=${statusData.type}, entry=${entry.type}`); - return; - } - - // Get primary status - let status = statusData.status || data.event || 'unknown'; // Define status *before* potential modification - - // Stall detection for 'real_time' status - if (status === 'real_time') { - entry.realTimeStallDetector = entry.realTimeStallDetector || { count: 0, lastStatusJson: '' }; - const detector = entry.realTimeStallDetector; - - const currentMetrics = { - progress: statusData.progress, - time_elapsed: statusData.time_elapsed, - // For multi-track items, current_track is a key indicator of activity - current_track: (entry.type === 'album' || entry.type === 'playlist') ? statusData.current_track : undefined, - // Include other relevant fields if they signify activity, e.g., speed, eta - // For example, if statusData.song changes for an album, that's progress. - song: statusData.song - }; - const currentMetricsJson = JSON.stringify(currentMetrics); - - // Check if significant metrics are present and static - if (detector.lastStatusJson === currentMetricsJson && - (currentMetrics.progress !== undefined || currentMetrics.time_elapsed !== undefined || currentMetrics.current_track !== undefined || currentMetrics.song !== undefined)) { - // Metrics are present and haven't changed - detector.count++; - } else { - // Metrics changed, or this is the first time seeing them, or no metrics to compare (e.g. empty object from server) - detector.count = 0; - // Only update lastStatusJson if currentMetricsJson represents actual data, not an empty object if that's possible - if (currentMetricsJson !== '{}' || detector.lastStatusJson === '') { // Avoid replacing actual old data with '{}' if new data is sparse - detector.lastStatusJson = currentMetricsJson; - } - } - - const STALL_THRESHOLD = 600; // Approx 5 minutes (600 polls * 0.5s/poll) - if (detector.count >= STALL_THRESHOLD) { - console.warn(`Download ${queueId} (${entry.prgFile}) appears stalled in real_time state. Metrics: ${detector.lastStatusJson}. Stall count: ${detector.count}. Forcing error.`); - statusData.status = 'error'; - statusData.error = 'Download stalled (no progress updates for 5 minutes)'; - statusData.can_retry = true; // Allow manual retry for stalled items - status = 'error'; // Update local status variable for current execution scope - - // Reset detector for this entry in case of retry - detector.count = 0; - detector.lastStatusJson = ''; - } - } - - // Store the status data for potential retries - entry.lastStatus = statusData; // This now stores the potentially modified statusData (e.g., status changed to 'error') - entry.lastUpdated = Date.now(); - - // Update type if needed - could be more specific now (e.g., from 'album' to 'compilation') - if (statusData.type && statusData.type !== entry.type) { - entry.type = statusData.type; - const typeEl = entry.element.querySelector('.type') as HTMLElement | null; - if (typeEl) { - const displayType = entry.type.charAt(0).toUpperCase() + entry.type.slice(1); - typeEl.textContent = displayType; - typeEl.className = `type ${entry.type}`; - } - } - - // Update the title and artist with better information if available - this.updateItemMetadata(entry, statusData, data); - - // Generate appropriate user-friendly message - const message = this.getStatusMessage(statusData); - - // Update log message - but only if we're not handling a track update for an album/playlist - // That case is handled separately in updateItemMetadata to ensure we show the right track info - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; - if (logElement && status !== 'error' && !(statusData.type === 'track' && statusData.parent && - (entry.type === 'album' || entry.type === 'playlist'))) { - logElement.textContent = message; - } - - // Handle real-time progress data for single track downloads - if (status === 'real-time') { - this.updateRealTimeProgress(entry, statusData); - } - - // Handle overall progress for albums and playlists - const isMultiTrack = entry.type === 'album' || entry.type === 'playlist'; - if (isMultiTrack) { - this.updateMultiTrackProgress(entry, statusData); - } else { - // For single tracks, update the track progress - this.updateSingleTrackProgress(entry, statusData); - } - - // Apply appropriate status classes - this.applyStatusClasses(entry, statusData); // Pass statusData instead of status string - - // Special handling for error status based on new API response format - if (status === 'error') { - entry.hasEnded = true; - // Hide cancel button - const cancelBtn = entry.element.querySelector('.cancel-btn') as HTMLButtonElement | null; - if (cancelBtn) cancelBtn.style.display = 'none'; - - // Hide progress bars for errored items - const trackProgressContainer = entry.element.querySelector(`#track-progress-container-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; - if (trackProgressContainer) trackProgressContainer.style.display = 'none'; - const overallProgressContainer = entry.element.querySelector('.overall-progress-container') as HTMLElement | null; - if (overallProgressContainer) overallProgressContainer.style.display = 'none'; - // Hide time elapsed for errored items - const timeElapsedContainer = entry.element.querySelector(`#time-elapsed-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; - if (timeElapsedContainer) timeElapsedContainer.style.display = 'none'; - - // Extract error details - const errMsg = statusData.error || 'An unknown error occurred.'; // Ensure errMsg is a string - // const canRetry = Boolean(statusData.can_retry) && statusData.retry_count < statusData.max_retries; // This logic is implicitly handled by retry button availability - const retryUrl = data.original_url || data.original_request?.url || entry.requestUrl || null; - if (retryUrl) { - entry.requestUrl = retryUrl; // Store for retry logic - } - - console.log(`Error for ${entry.type} download. Can retry: ${!!entry.requestUrl}. Retry URL: ${entry.requestUrl}`); - - const errorLogElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; // Use a different variable name - if (errorLogElement) { // Check errorLogElement - let errorMessageElement = errorLogElement.querySelector('.error-message') as HTMLElement | null; - - if (!errorMessageElement) { // If error UI (message and buttons) is not built yet - // Build error UI with manual retry always available - errorLogElement.innerHTML = ` -
${errMsg}
-
- - -
- `; - errorMessageElement = errorLogElement.querySelector('.error-message') as HTMLElement | null; // Re-select after innerHTML change - - // Attach listeners ONLY when creating the buttons - const closeErrorBtn = errorLogElement.querySelector('.close-error-btn') as HTMLButtonElement | null; - if (closeErrorBtn) { - closeErrorBtn.addEventListener('click', () => { - this.cleanupEntry(queueId); - }); - } - - const retryBtnElem = errorLogElement.querySelector('.retry-btn') as HTMLButtonElement | null; - if (retryBtnElem) { - retryBtnElem.addEventListener('click', (e: MouseEvent) => { // Add type for e - e.preventDefault(); - e.stopPropagation(); - if (retryBtnElem) { // Check if retryBtnElem is not null - retryBtnElem.disabled = true; - retryBtnElem.innerHTML = ' Retrying...'; - } - this.retryDownload(queueId, errorLogElement); // Pass errorLogElement - }); - } - - // Auto cleanup after 15s - only set this timeout once when error UI is first built - setTimeout(() => { - const currentEntryForCleanup = this.queueEntries[queueId]; - if (currentEntryForCleanup && - currentEntryForCleanup.hasEnded && - currentEntryForCleanup.lastStatus?.status === 'error' && - !currentEntryForCleanup.isRetrying) { - this.cleanupEntry(queueId); - } - }, 20000); // Changed from 15000 to 20000 - - } else { // Error UI already exists, just update the message text if it's different - if (errorMessageElement.textContent !== errMsg) { - errorMessageElement.textContent = errMsg; - } - } - } - } - - // Handle terminal states for non-error cases - if (['complete', 'cancel', 'cancelled', 'done', 'skipped'].includes(status)) { - entry.hasEnded = true; - this.handleDownloadCompletion(entry, queueId, statusData); - } - - // Cache the status for potential page reloads - this.queueCache[entry.prgFile] = statusData; - localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); - } - - // Update item metadata (title, artist, etc.) - updateItemMetadata(entry: QueueEntry, statusData: StatusData, data: StatusData) { // Add types - const titleEl = entry.element.querySelector('.title') as HTMLElement | null; - const artistEl = entry.element.querySelector('.artist') as HTMLElement | null; - - if (titleEl) { - // Check various data sources for a better title - let betterTitle: string | null | undefined = null; - - // First check the statusData - if (statusData.song) { - betterTitle = statusData.song; - } else if (statusData.album) { - betterTitle = statusData.album; - } else if (statusData.name) { - betterTitle = statusData.name; - } - // Then check if data has original_request with name - else if (data.original_request && data.original_request.name) { - betterTitle = data.original_request.name; - } - // Then check display_title from various sources - else if (statusData.display_title) { - betterTitle = statusData.display_title; - } else if (data.display_title) { - betterTitle = data.display_title; - } - - // Update title if we found a better one - if (betterTitle && betterTitle !== titleEl.textContent) { - titleEl.textContent = betterTitle; - // Also update the item's name for future reference - entry.item.name = betterTitle; - } - } - - // Update artist if available - if (artistEl) { - let artist = statusData.artist || data.display_artist || ''; - if (artist && (!artistEl.textContent || artistEl.textContent !== artist)) { - artistEl.textContent = artist; - // Update item data - entry.item.artist = artist; - } - } - } - - // Update real-time progress for track downloads - updateRealTimeProgress(entry: QueueEntry, statusData: StatusData) { // Add types - // Get track progress bar - const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; - const timeElapsedEl = entry.element.querySelector('#time-elapsed-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; - - if (trackProgressBar && statusData.progress !== undefined) { - // Update track progress bar - const progress = parseFloat(statusData.progress as string); // Cast to string - trackProgressBar.style.width = `${progress}%`; - trackProgressBar.setAttribute('aria-valuenow', progress.toString()); // Use string - - // Add success class when complete - if (progress >= 100) { - trackProgressBar.classList.add('complete'); - } else { - trackProgressBar.classList.remove('complete'); - } - } - - // Display time elapsed if available - if (timeElapsedEl && statusData.time_elapsed !== undefined) { - const seconds = Math.floor(statusData.time_elapsed / 1000); - const formattedTime = seconds < 60 - ? `${seconds}s` - : `${Math.floor(seconds / 60)}m ${seconds % 60}s`; - timeElapsedEl.textContent = formattedTime; - } - } - - // Update progress for single track downloads - updateSingleTrackProgress(entry: QueueEntry, statusData: StatusData) { // Add types - // Get track progress bar and other UI elements - const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; - const titleElement = entry.element.querySelector('.title') as HTMLElement | null; - const artistElement = entry.element.querySelector('.artist') as HTMLElement | null; - let progress = 0; // Declare progress here - - // If this track has a parent, this is actually part of an album/playlist - // We should update the entry type and handle it as a multi-track download - if (statusData.parent && (statusData.parent.type === 'album' || statusData.parent.type === 'playlist')) { - // Store parent info - entry.parentInfo = statusData.parent; - - // Update entry type to match parent type - entry.type = statusData.parent.type; - - // Update UI to reflect the parent type - const typeEl = entry.element.querySelector('.type') as HTMLElement | null; - if (typeEl) { - const displayType = entry.type.charAt(0).toUpperCase() + entry.type.slice(1); - typeEl.textContent = displayType; - // Update type class without triggering animation - typeEl.className = `type ${entry.type}`; - } - - // Update title and subtitle based on parent type - if (statusData.parent.type === 'album') { - if (titleElement) titleElement.textContent = statusData.parent.title || 'Unknown album'; - if (artistElement) artistElement.textContent = statusData.parent.artist || 'Unknown artist'; - } else if (statusData.parent.type === 'playlist') { - if (titleElement) titleElement.textContent = statusData.parent.name || 'Unknown playlist'; - if (artistElement) artistElement.textContent = statusData.parent.owner || 'Unknown creator'; - } - - // Now delegate to the multi-track progress updater - this.updateMultiTrackProgress(entry, statusData); - return; - } - - // For standalone tracks (without parent), update title and subtitle - if (!statusData.parent && statusData.song && titleElement) { - titleElement.textContent = statusData.song; - } - - if (!statusData.parent && statusData.artist && artistElement) { - artistElement.textContent = statusData.artist; - } - - // For individual track downloads, show the parent context if available - if (!['done', 'complete', 'error', 'skipped'].includes(statusData.status || '')) { // Add null check - // First check if we have parent data in the current status update - if (statusData.parent && logElement) { - // Store parent info in the entry for persistence across refreshes - entry.parentInfo = statusData.parent; - - let infoText = ''; - if (statusData.parent.type === 'album') { - infoText = `From album: "${statusData.parent.title}"`; - } else if (statusData.parent.type === 'playlist') { - infoText = `From playlist: "${statusData.parent.name}" by ${statusData.parent.owner}`; - } - - if (infoText) { - logElement.textContent = infoText; - } - } - // If no parent in current update, use stored parent info if available - else if (entry.parentInfo && logElement) { - let infoText = ''; - if (entry.parentInfo.type === 'album') { - infoText = `From album: "${entry.parentInfo.title}"`; - } else if (entry.parentInfo.type === 'playlist') { - infoText = `From playlist: "${entry.parentInfo.name}" by ${entry.parentInfo.owner}`; - } - - if (infoText) { - logElement.textContent = infoText; - } - } - } - - // Calculate progress based on available data - progress = 0; - - // Real-time progress for direct track download - if (statusData.status === 'real-time' && statusData.progress !== undefined) { - progress = parseFloat(statusData.progress as string); // Cast to string - } else if (statusData.percent !== undefined) { - progress = parseFloat(statusData.percent as string) * 100; // Cast to string - } else if (statusData.percentage !== undefined) { - progress = parseFloat(statusData.percentage as string) * 100; // Cast to string - } else if (statusData.status === 'done' || statusData.status === 'complete') { - progress = 100; - } else if (statusData.current_track && statusData.total_tracks) { - // If we don't have real-time progress but do have track position - progress = (parseInt(statusData.current_track as string, 10) / parseInt(statusData.total_tracks as string, 10)) * 100; // Cast to string - } - - // Update track progress bar if available - if (trackProgressBar) { - // Ensure numeric progress and prevent NaN - const safeProgress = isNaN(progress) ? 0 : Math.max(0, Math.min(100, progress)); - - trackProgressBar.style.width = `${safeProgress}%`; - trackProgressBar.setAttribute('aria-valuenow', safeProgress.toString()); // Use string - - // Make sure progress bar is visible - const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; - if (trackProgressContainer) { - trackProgressContainer.style.display = 'block'; - } - - // Add success class when complete - if (safeProgress >= 100) { - trackProgressBar.classList.add('complete'); - } else { - trackProgressBar.classList.remove('complete'); - } - } - } - - // Update progress for multi-track downloads (albums and playlists) - updateMultiTrackProgress(entry: QueueEntry, statusData: StatusData) { // Add types - // Get progress elements - const progressCounter = document.getElementById(`progress-count-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; - const overallProgressBar = document.getElementById(`overall-bar-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; - const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; - const titleElement = entry.element.querySelector('.title') as HTMLElement | null; - const artistElement = entry.element.querySelector('.artist') as HTMLElement | null; - let progress = 0; // Declare progress here for this function's scope - - // Initialize track progress variables - let currentTrack = 0; - let totalTracks = 0; - let trackProgress = 0; - - // Handle track-level updates for album/playlist downloads - if (statusData.type === 'track' && statusData.parent && - (entry.type === 'album' || entry.type === 'playlist')) { - console.log('Processing track update for multi-track download:', statusData); - - // Update parent title/artist for album - if (entry.type === 'album' && statusData.parent.type === 'album') { - if (titleElement && statusData.parent.title) { - titleElement.textContent = statusData.parent.title; - } - if (artistElement && statusData.parent.artist) { - artistElement.textContent = statusData.parent.artist; - } - } - // Update parent title/owner for playlist - else if (entry.type === 'playlist' && statusData.parent.type === 'playlist') { - if (titleElement && statusData.parent.name) { - titleElement.textContent = statusData.parent.name; - } - if (artistElement && statusData.parent.owner) { - artistElement.textContent = statusData.parent.owner; - } - } - - // Get current track and total tracks from the status data - if (statusData.current_track !== undefined) { - currentTrack = parseInt(String(statusData.current_track), 10); - - // Get total tracks - try from statusData first, then from parent - if (statusData.total_tracks !== undefined) { - totalTracks = parseInt(String(statusData.total_tracks), 10); - } else if (statusData.parent && statusData.parent.total_tracks !== undefined) { - totalTracks = parseInt(String(statusData.parent.total_tracks), 10); - } - - console.log(`Track info: ${currentTrack}/${totalTracks}`); - } - - // Get track progress for real-time updates - if (statusData.status === 'real-time' && statusData.progress !== undefined) { - trackProgress = parseFloat(statusData.progress as string); // Cast to string - } - - // Update the track progress counter display - if (progressCounter && totalTracks > 0) { - progressCounter.textContent = `${currentTrack}/${totalTracks}`; - } - - // Update the status message to show current track - if (logElement && statusData.song && statusData.artist) { - let progressInfo = ''; - if (statusData.status === 'real-time' && trackProgress > 0) { - progressInfo = ` - ${trackProgress.toFixed(1)}%`; - } - logElement.textContent = `Currently downloading: ${statusData.song} by ${statusData.artist} (${currentTrack}/${totalTracks}${progressInfo})`; - } - - // Calculate and update the overall progress bar - if (totalTracks > 0) { - let overallProgress = 0; - // Always compute overall based on trackProgress if available, using album/playlist real-time formula - if (trackProgress !== undefined) { - const completedTracksProgress = (currentTrack - 1) / totalTracks; - const currentTrackContribution = (1 / totalTracks) * (trackProgress / 100); - overallProgress = (completedTracksProgress + currentTrackContribution) * 100; - console.log(`Overall progress: ${overallProgress.toFixed(2)}% (Track ${currentTrack}/${totalTracks}, Progress: ${trackProgress}%)`); - } else { - overallProgress = (currentTrack / totalTracks) * 100; - console.log(`Overall progress (non-real-time): ${overallProgress.toFixed(2)}% (Track ${currentTrack}/${totalTracks})`); - } - - // Update the progress bar - if (overallProgressBar) { - const safeProgress = Math.max(0, Math.min(100, overallProgress)); - overallProgressBar.style.width = `${safeProgress}%`; - overallProgressBar.setAttribute('aria-valuenow', safeProgress.toString()); // Use string - - if (safeProgress >= 100) { - overallProgressBar.classList.add('complete'); - } else { - overallProgressBar.classList.remove('complete'); - } - } - - // Update the track-level progress bar - if (trackProgressBar) { - // Make sure progress bar container is visible - const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; - if (trackProgressContainer) { - trackProgressContainer.style.display = 'block'; - } - - if (statusData.status === 'real-time') { - // Real-time progress for the current track - const safeTrackProgress = Math.max(0, Math.min(100, trackProgress)); - trackProgressBar.style.width = `${safeTrackProgress}%`; - trackProgressBar.setAttribute('aria-valuenow', safeTrackProgress.toString()); // Use string - trackProgressBar.classList.add('real-time'); - - if (safeTrackProgress >= 100) { - trackProgressBar.classList.add('complete'); - } else { - trackProgressBar.classList.remove('complete'); - } - } else { - // Indeterminate progress animation for non-real-time updates - trackProgressBar.classList.add('progress-pulse'); - trackProgressBar.style.width = '100%'; - trackProgressBar.setAttribute('aria-valuenow', "50"); // Use string - } - } - - // Store progress for potential later use - entry.progress = overallProgress; - } - - return; // Skip the standard handling below - } - - // Standard handling for album/playlist direct updates (not track-level): - // Update title and subtitle based on item type - if (entry.type === 'album') { - if (statusData.title && titleElement) { - titleElement.textContent = statusData.title; - } - if (statusData.artist && artistElement) { - artistElement.textContent = statusData.artist; - } - } else if (entry.type === 'playlist') { - if (statusData.name && titleElement) { - titleElement.textContent = statusData.name; - } - if (statusData.owner && artistElement) { - artistElement.textContent = statusData.owner; - } - } - - // Extract track counting data from status data - if (statusData.current_track && statusData.total_tracks) { - currentTrack = parseInt(statusData.current_track as string, 10); // Cast to string - totalTracks = parseInt(statusData.total_tracks as string, 10); // Cast to string - } else if (statusData.parsed_current_track && statusData.parsed_total_tracks) { - currentTrack = parseInt(statusData.parsed_current_track as string, 10); // Cast to string - totalTracks = parseInt(statusData.parsed_total_tracks as string, 10); // Cast to string - } else if (statusData.current_track && typeof statusData.current_track === 'string' && /^\d+\/\d+$/.test(statusData.current_track)) { // Add type check - // Parse formats like "1/12" - const parts = statusData.current_track.split('/'); - currentTrack = parseInt(parts[0], 10); - totalTracks = parseInt(parts[1], 10); - } - - // Get track progress for real-time downloads - if (statusData.status === 'real-time' && statusData.progress !== undefined) { - // For real-time downloads, progress comes as a percentage value (0-100) - trackProgress = parseFloat(statusData.progress as string); // Cast to string - } else if (statusData.percent !== undefined) { - // Handle percent values (0-1) - trackProgress = parseFloat(statusData.percent as string) * 100; // Cast to string - } else if (statusData.percentage !== undefined) { - // Handle percentage values (0-1) - trackProgress = parseFloat(statusData.percentage as string) * 100; // Cast to string - } else if (statusData.status === 'done' || statusData.status === 'complete') { - progress = 100; - } else if (statusData.current_track && statusData.total_tracks) { - // If we don't have real-time progress but do have track position - progress = (parseInt(statusData.current_track as string, 10) / parseInt(statusData.total_tracks as string, 10)) * 100; // Cast to string - } - - // Update progress counter if available - if (progressCounter && totalTracks > 0) { - progressCounter.textContent = `${currentTrack}/${totalTracks}`; - } - - // Calculate overall progress - let overallProgress = 0; - if (totalTracks > 0) { - // Use explicit overall_progress if provided - if (statusData.overall_progress !== undefined) { - overallProgress = statusData.overall_progress; // overall_progress is number - } else if (trackProgress !== undefined) { - // For both real-time and standard multi-track downloads, use same formula - const completedTracksProgress = (currentTrack - 1) / totalTracks; - const currentTrackContribution = (1 / totalTracks) * (trackProgress / 100); - overallProgress = (completedTracksProgress + currentTrackContribution) * 100; - console.log(`Progress: Track ${currentTrack}/${totalTracks}, Track progress: ${trackProgress}%, Overall: ${overallProgress.toFixed(2)}%`); - } else { - overallProgress = 0; - } - - // Update overall progress bar - if (overallProgressBar) { - // Ensure progress is between 0-100 - const safeProgress = Math.max(0, Math.min(100, overallProgress)); - overallProgressBar.style.width = `${safeProgress}%`; - overallProgressBar.setAttribute('aria-valuenow', String(safeProgress)); - - // Add success class when complete - if (safeProgress >= 100) { - overallProgressBar.classList.add('complete'); - } else { - overallProgressBar.classList.remove('complete'); - } - } - - // Update track progress bar for current track in multi-track items - if (trackProgressBar) { - // Make sure progress bar container is visible - const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null; - if (trackProgressContainer) { - trackProgressContainer.style.display = 'block'; - } - - if (statusData.status === 'real-time' || statusData.status === 'real_time') { - // For real-time updates, use the track progress for the small green progress bar - // This shows download progress for the current track only - const safeProgress = isNaN(trackProgress) ? 0 : Math.max(0, Math.min(100, trackProgress)); - trackProgressBar.style.width = `${safeProgress}%`; - trackProgressBar.setAttribute('aria-valuenow', String(safeProgress)); - trackProgressBar.classList.add('real-time'); - - if (safeProgress >= 100) { - trackProgressBar.classList.add('complete'); - } else { - trackProgressBar.classList.remove('complete'); - } - } else if (['progress', 'processing'].includes(statusData.status || '')) { - // For non-real-time progress updates, show an indeterminate-style progress - // by using a pulsing animation via CSS - trackProgressBar.classList.add('progress-pulse'); - trackProgressBar.style.width = '100%'; - trackProgressBar.setAttribute('aria-valuenow', String(50)); // indicate in-progress - } else { - // For other status updates, use current track position - trackProgressBar.classList.remove('progress-pulse'); - const trackPositionPercent = currentTrack > 0 ? 100 : 0; - trackProgressBar.style.width = `${trackPositionPercent}%`; - trackProgressBar.setAttribute('aria-valuenow', String(trackPositionPercent)); - } - } - - // Store the progress in the entry for potential later use - entry.progress = overallProgress; - } - } - - /* Close all active polling intervals */ - clearAllPollingIntervals() { - for (const queueId in this.pollingIntervals) { - this.clearPollingInterval(queueId); - } - } - - /* New method for periodic server sync */ - async periodicSyncWithServer() { - console.log("Performing periodic sync with server..."); - try { - const response = await fetch('/api/prgs/list'); - if (!response.ok) { - console.error("Periodic sync: Failed to fetch task list from server", response.status); - return; - } - const serverTasks: any[] = await response.json(); - - const localTaskPrgFiles = new Set(Object.values(this.queueEntries).map(entry => entry.prgFile)); - const serverTaskPrgFiles = new Set(serverTasks.map(task => task.task_id)); - - const terminalStates = ['complete', 'done', 'cancelled', 'ERROR_AUTO_CLEANED', 'ERROR_RETRIED', 'cancel', 'interrupted', 'error']; - - // 1. Add new tasks from server not known locally or update existing ones - for (const serverTask of serverTasks) { - const taskId = serverTask.task_id; // This is the prgFile - const lastStatus = serverTask.last_status_obj; - const originalRequest = serverTask.original_request || {}; - - if (terminalStates.includes(lastStatus?.status)) { - // If server says it's terminal, and we have it locally, ensure it's cleaned up - const localEntry = Object.values(this.queueEntries).find(e => e.prgFile === taskId); - if (localEntry && !localEntry.hasEnded) { - console.log(`Periodic sync: Server task ${taskId} is terminal (${lastStatus.status}), cleaning up local entry.`); - // Use a status object for handleDownloadCompletion - this.handleDownloadCompletion(localEntry, localEntry.uniqueId, lastStatus); - } - continue; // Skip adding terminal tasks to UI if not already there - } - - if (!localTaskPrgFiles.has(taskId)) { - console.log(`Periodic sync: Found new non-terminal task ${taskId} on server. Adding to queue.`); - let itemType = serverTask.type || originalRequest.type || 'unknown'; - let dummyItem: QueueItem = { - name: serverTask.name || originalRequest.name || taskId, - artist: serverTask.artist || originalRequest.artist || '', - type: itemType, - url: originalRequest.url || lastStatus?.url || '', - endpoint: originalRequest.endpoint || '', - download_type: serverTask.download_type || originalRequest.download_type || '', - total_tracks: lastStatus?.total_tracks || originalRequest.total_tracks, - current_track: lastStatus?.current_track, - }; - - if (lastStatus && lastStatus.type === 'track' && lastStatus.parent) { - const parent = lastStatus.parent; - if (parent.type === 'album') { - itemType = 'album'; - dummyItem = { - name: parent.title || 'Unknown Album', - artist: parent.artist || 'Unknown Artist', - type: 'album', url: parent.url || '', - total_tracks: parent.total_tracks || lastStatus.total_tracks, - parent: parent }; - } else if (parent.type === 'playlist') { - itemType = 'playlist'; - dummyItem = { - name: parent.name || 'Unknown Playlist', - owner: parent.owner || 'Unknown Creator', - type: 'playlist', url: parent.url || '', - total_tracks: parent.total_tracks || lastStatus.total_tracks, - parent: parent }; - } - } - const requestUrl = originalRequest.url ? `/api/${itemType}/download/${originalRequest.url.split('/').pop()}?name=${encodeURIComponent(dummyItem.name || '')}&artist=${encodeURIComponent(dummyItem.artist || '')}` : null; - // Add with startMonitoring = true - const queueId = this.addDownload(dummyItem, itemType, taskId, requestUrl, true); - const newEntry = this.queueEntries[queueId]; - if (newEntry && lastStatus) { - // Manually set lastStatus and update UI as addDownload might not have full server info yet - newEntry.lastStatus = lastStatus; - if(lastStatus.parent) newEntry.parentInfo = lastStatus.parent; - this.applyStatusClasses(newEntry, lastStatus); - const logEl = newEntry.element.querySelector('.log') as HTMLElement | null; - if(logEl) logEl.textContent = this.getStatusMessage(lastStatus); - // Ensure polling is active for this newly added item - this.setupPollingInterval(newEntry.uniqueId); - } - } else { - // Task exists locally, check if status needs update from server list - const localEntry = Object.values(this.queueEntries).find(e => e.prgFile === taskId); - if (localEntry && lastStatus && JSON.stringify(localEntry.lastStatus) !== JSON.stringify(lastStatus)) { - if (!localEntry.hasEnded) { - console.log(`Periodic sync: Updating status for existing task ${taskId} from ${localEntry.lastStatus?.status} to ${lastStatus.status}`); - // Create a data object that handleStatusUpdate expects - const updateData: StatusData = { ...serverTask, last_line: lastStatus }; - this.handleStatusUpdate(localEntry.uniqueId, updateData); - } - } - } - } - - // 2. Remove local tasks that are no longer on the server or are now terminal on server - for (const localEntry of Object.values(this.queueEntries)) { - if (!serverTaskPrgFiles.has(localEntry.prgFile)) { - if (!localEntry.hasEnded) { - console.log(`Periodic sync: Local task ${localEntry.prgFile} not found on server. Assuming completed/cleaned. Removing.`); - this.cleanupEntry(localEntry.uniqueId); - } - } else { - const serverEquivalent = serverTasks.find(st => st.task_id === localEntry.prgFile); - if (serverEquivalent && serverEquivalent.last_status_obj && terminalStates.includes(serverEquivalent.last_status_obj.status)) { - if (!localEntry.hasEnded) { - console.log(`Periodic sync: Local task ${localEntry.prgFile} is now terminal on server (${serverEquivalent.last_status_obj.status}). Cleaning up.`); - this.handleDownloadCompletion(localEntry, localEntry.uniqueId, serverEquivalent.last_status_obj); - } - } - } - } - - this.updateQueueOrder(); - - } catch (error) { - console.error("Error during periodic sync with server:", error); - } - } -} - -// Singleton instance -export const downloadQueue = new DownloadQueue(); \ No newline at end of file diff --git a/src/js/track.ts b/src/js/track.ts deleted file mode 100644 index 08b3a7c..0000000 --- a/src/js/track.ts +++ /dev/null @@ -1,258 +0,0 @@ -// Import the downloadQueue singleton from your working queue.js implementation. -import { downloadQueue } from './queue.js'; - -document.addEventListener('DOMContentLoaded', () => { - // Parse track ID from URL. Expecting URL in the form /track/{id} - const pathSegments = window.location.pathname.split('/'); - const trackId = pathSegments[pathSegments.indexOf('track') + 1]; - - if (!trackId) { - showError('No track ID provided.'); - return; - } - - // Fetch track info directly - fetch(`/api/track/info?id=${encodeURIComponent(trackId)}`) - .then(response => { - if (!response.ok) throw new Error('Network response was not ok'); - return response.json(); - }) - .then(data => renderTrack(data)) - .catch(error => { - console.error('Error:', error); - showError('Error loading track'); - }); - - // Attach event listener to the queue icon to toggle the download queue - const queueIcon = document.getElementById('queueIcon'); - if (queueIcon) { - queueIcon.addEventListener('click', () => { - downloadQueue.toggleVisibility(); - }); - } - - // Attempt to set initial watchlist button visibility from cache - const watchlistButton = document.getElementById('watchlistButton') as HTMLAnchorElement | null; - if (watchlistButton) { - const cachedWatchEnabled = localStorage.getItem('spotizerr_watch_enabled_cached'); - if (cachedWatchEnabled === 'true') { - watchlistButton.classList.remove('hidden'); - } - } - - // Fetch watch config to determine if watchlist button should be visible - async function updateWatchlistButtonVisibility() { - if (watchlistButton) { - try { - const response = await fetch('/api/config/watch'); - if (response.ok) { - const watchConfig = await response.json(); - localStorage.setItem('spotizerr_watch_enabled_cached', watchConfig.enabled ? 'true' : 'false'); - if (watchConfig && watchConfig.enabled === false) { - watchlistButton.classList.add('hidden'); - } else { - watchlistButton.classList.remove('hidden'); // Ensure it's shown if enabled - } - } else { - console.error('Failed to fetch watch config for track page, defaulting to hidden'); - // Don't update cache on error - watchlistButton.classList.add('hidden'); // Hide if config fetch fails - } - } catch (error) { - console.error('Error fetching watch config for track page:', error); - // Don't update cache on error - watchlistButton.classList.add('hidden'); // Hide on error - } - } - } - updateWatchlistButtonVisibility(); -}); - -/** - * Renders the track header information. - */ -function renderTrack(track: any) { - // Hide the loading and error messages. - const loadingEl = document.getElementById('loading'); - if (loadingEl) loadingEl.classList.add('hidden'); - const errorEl = document.getElementById('error'); - if (errorEl) errorEl.classList.add('hidden'); - - // Check if track is explicit and if explicit filter is enabled - if (track.explicit && downloadQueue.isExplicitFilterEnabled()) { - // Show placeholder for explicit content - const loadingElExplicit = document.getElementById('loading'); - if (loadingElExplicit) loadingElExplicit.classList.add('hidden'); - - const placeholderContent = ` -
-

Explicit Content Filtered

-

This track contains explicit content and has been filtered based on your settings.

-

The explicit content filter is controlled by environment variables.

-
- `; - - const contentContainer = document.getElementById('track-header'); - if (contentContainer) { - contentContainer.innerHTML = placeholderContent; - contentContainer.classList.remove('hidden'); - } - - return; // Stop rendering the actual track content - } - - // Update track information fields. - const trackNameEl = document.getElementById('track-name'); - if (trackNameEl) { - trackNameEl.innerHTML = - `${track.name || 'Unknown Track'}`; - } - - const trackArtistEl = document.getElementById('track-artist'); - if (trackArtistEl) { - trackArtistEl.innerHTML = - `By ${track.artists?.map((a: any) => - `${a?.name || 'Unknown Artist'}` - ).join(', ') || 'Unknown Artist'}`; - } - - const trackAlbumEl = document.getElementById('track-album'); - if (trackAlbumEl) { - trackAlbumEl.innerHTML = - `Album: ${track.album?.name || 'Unknown Album'} (${track.album?.album_type || 'album'})`; - } - - const trackDurationEl = document.getElementById('track-duration'); - if (trackDurationEl) { - trackDurationEl.textContent = - `Duration: ${msToTime(track.duration_ms || 0)}`; - } - - const trackExplicitEl = document.getElementById('track-explicit'); - if (trackExplicitEl) { - trackExplicitEl.textContent = - track.explicit ? 'Explicit' : 'Clean'; - } - - const imageUrl = (track.album?.images && track.album.images[0]) - ? track.album.images[0].url - : '/static/images/placeholder.jpg'; - const trackAlbumImageEl = document.getElementById('track-album-image') as HTMLImageElement; - if (trackAlbumImageEl) trackAlbumImageEl.src = imageUrl; - - // --- Insert Home Button (if not already present) --- - let homeButton = document.getElementById('homeButton') as HTMLButtonElement; - if (!homeButton) { - homeButton = document.createElement('button'); - homeButton.id = 'homeButton'; - homeButton.className = 'home-btn'; - homeButton.innerHTML = `Home`; - // Prepend the home button into the header. - const trackHeader = document.getElementById('track-header'); - if (trackHeader) { - trackHeader.insertBefore(homeButton, trackHeader.firstChild); - } - } - homeButton.addEventListener('click', () => { - window.location.href = window.location.origin; - }); - - // --- Move the Download Button from #actions into #track-header --- - let downloadBtn = document.getElementById('downloadTrackBtn') as HTMLButtonElement; - if (downloadBtn) { - // Remove the parent container (#actions) if needed. - const actionsContainer = document.getElementById('actions'); - if (actionsContainer) { - actionsContainer.parentNode?.removeChild(actionsContainer); - } - // Set the inner HTML to use the download.svg icon. - downloadBtn.innerHTML = `Download`; - // Append the download button to the track header so it appears at the right. - const trackHeader = document.getElementById('track-header'); - if (trackHeader) { - trackHeader.appendChild(downloadBtn); - } - } - - if (downloadBtn) { - downloadBtn.addEventListener('click', () => { - downloadBtn.disabled = true; - downloadBtn.innerHTML = `Queueing...`; - - const trackUrl = track.external_urls?.spotify || ''; - if (!trackUrl) { - showError('Missing track URL'); - downloadBtn.disabled = false; - downloadBtn.innerHTML = `Download`; - return; - } - const trackIdToDownload = track.id || ''; - if (!trackIdToDownload) { - showError('Missing track ID for download'); - downloadBtn.disabled = false; - downloadBtn.innerHTML = `Download`; - return; - } - - // Use the centralized downloadQueue.download method - downloadQueue.download(trackIdToDownload, 'track', { name: track.name || 'Unknown Track', artist: track.artists?.[0]?.name }) - .then(() => { - downloadBtn.innerHTML = `Queued!`; - // Make the queue visible to show the download - downloadQueue.toggleVisibility(true); - }) - .catch((err: any) => { - showError('Failed to queue track download: ' + (err?.message || 'Unknown error')); - downloadBtn.disabled = false; - downloadBtn.innerHTML = `Download`; - }); - }); - } - - // Reveal the header now that track info is loaded. - const trackHeaderEl = document.getElementById('track-header'); - if (trackHeaderEl) trackHeaderEl.classList.remove('hidden'); -} - -/** - * Converts milliseconds to minutes:seconds. - */ -function msToTime(duration: number) { - if (!duration || isNaN(duration)) return '0:00'; - - const minutes = Math.floor(duration / 60000); - const seconds = Math.floor((duration % 60000) / 1000); - return `${minutes}:${seconds.toString().padStart(2, '0')}`; -} - -/** - * Displays an error message in the UI. - */ -function showError(message: string) { - const errorEl = document.getElementById('error'); - if (errorEl) { - errorEl.textContent = message || 'An error occurred'; - errorEl.classList.remove('hidden'); - } -} - -/** - * Starts the download process by calling the centralized downloadQueue method - */ -async function startDownload(itemId: string, type: string, item: any) { - if (!itemId || !type) { - showError('Missing ID or type for download'); - return; - } - - try { - // Use the centralized downloadQueue.download method - await downloadQueue.download(itemId, type, item); - - // Make the queue visible after queueing - downloadQueue.toggleVisibility(true); - } catch (error: any) { - showError('Download failed: ' + (error?.message || 'Unknown error')); - throw error; - } -} diff --git a/src/js/watch.ts b/src/js/watch.ts deleted file mode 100644 index 0dfabfb..0000000 --- a/src/js/watch.ts +++ /dev/null @@ -1,688 +0,0 @@ -import { downloadQueue } from './queue.js'; // Assuming queue.js is in the same directory - -// Interfaces for API data -interface Image { - url: string; - height?: number; - width?: number; -} - -// --- Items from the initial /watch/list API calls --- -interface ArtistFromWatchList { - spotify_id: string; // Changed from id to spotify_id - name: string; - images?: Image[]; - total_albums?: number; // Already provided by /api/artist/watch/list -} - -// New interface for artists after initial processing (spotify_id mapped to id) -interface ProcessedArtistFromWatchList extends ArtistFromWatchList { - id: string; // This is the mapped spotify_id -} - -interface WatchedPlaylistOwner { // Kept as is, used by PlaylistFromWatchList - display_name?: string; - id?: string; -} - -interface PlaylistFromWatchList { - spotify_id: string; // Changed from id to spotify_id - name: string; - owner?: WatchedPlaylistOwner; - images?: Image[]; // Ensure images can be part of this initial fetch - total_tracks?: number; -} - -// New interface for playlists after initial processing (spotify_id mapped to id) -interface ProcessedPlaylistFromWatchList extends PlaylistFromWatchList { - id: string; // This is the mapped spotify_id -} -// --- End of /watch/list items --- - - -// --- Responses from /api/{artist|playlist}/info endpoints --- -interface AlbumWithImages { // For items in ArtistInfoResponse.items - images?: Image[]; - // Other album properties like name, id etc., are not strictly needed for this specific change -} - -interface ArtistInfoResponse { - artist_id: string; // Matches key from artist.py - artist_name: string; // Matches key from artist.py - artist_image_url?: string; // Matches key from artist.py - total: number; // This is total_albums, matches key from artist.py - artist_external_url?: string; // Matches key from artist.py - items?: AlbumWithImages[]; // Add album items to get the first album's image -} - -// PlaylistInfoResponse is effectively the Playlist interface from playlist.ts -// For clarity, defining it here based on what's needed for the card. -interface PlaylistInfoResponse { - id: string; - name: string; - description: string | null; - owner: { display_name?: string; id?: string; }; // Matches Playlist.owner - images: Image[]; // Matches Playlist.images - tracks: { total: number; /* items: PlaylistItem[] - not needed for card */ }; // Matches Playlist.tracks - followers?: { total: number; }; // Matches Playlist.followers - external_urls?: { spotify?: string }; // Matches Playlist.external_urls -} -// --- End of /info endpoint responses --- - - -// --- Final combined data structure for rendering cards --- -interface FinalArtistCardItem { - itemType: 'artist'; - id: string; // Spotify ID - name: string; // Best available name (from /info or fallback) - imageUrl?: string; // Best available image URL (from /info or fallback) - total_albums: number;// From /info or fallback - external_urls?: { spotify?: string }; // From /info -} - -interface FinalPlaylistCardItem { - itemType: 'playlist'; - id: string; // Spotify ID - name: string; // Best available name (from /info or fallback) - imageUrl?: string; // Best available image URL (from /info or fallback) - owner_name?: string; // From /info or fallback - total_tracks: number;// From /info or fallback - followers_count?: number; // From /info - description?: string | null; // From /info, for potential use (e.g., tooltip) - external_urls?: { spotify?: string }; // From /info -} - -type FinalCardItem = FinalArtistCardItem | FinalPlaylistCardItem; -// --- End of final card data structure --- - -// The type for items initially fetched from /watch/list, before detailed processing -// Updated to use ProcessedArtistFromWatchList for artists and ProcessedPlaylistFromWatchList for playlists -type InitialWatchedItem = - (ProcessedArtistFromWatchList & { itemType: 'artist' }) | - (ProcessedPlaylistFromWatchList & { itemType: 'playlist' }); - -// Interface for a settled promise (fulfilled) -interface CustomPromiseFulfilledResult { - status: 'fulfilled'; - value: T; -} - -// Interface for a settled promise (rejected) -interface CustomPromiseRejectedResult { - status: 'rejected'; - reason: any; -} - -type CustomSettledPromiseResult = CustomPromiseFulfilledResult | CustomPromiseRejectedResult; - -// Original WatchedItem type, which will be replaced by FinalCardItem for rendering -interface WatchedArtistOriginal { - id: string; - name: string; - images?: Image[]; - total_albums?: number; -} - -interface WatchedPlaylistOriginal { - id: string; - name: string; - owner?: WatchedPlaylistOwner; - images?: Image[]; - total_tracks?: number; -} - -type WatchedItem = (WatchedArtistOriginal & { itemType: 'artist' }) | (WatchedPlaylistOriginal & { itemType: 'playlist' }); - -// Added: Interface for global watch config -interface GlobalWatchConfig { - enabled: boolean; - [key: string]: any; // Allow other properties -} - -// Added: Helper function to fetch global watch config -async function getGlobalWatchConfig(): Promise { - try { - const response = await fetch('/api/config/watch'); - if (!response.ok) { - console.error('Failed to fetch global watch config, assuming disabled.'); - return { enabled: false }; // Default to disabled on error - } - return await response.json() as GlobalWatchConfig; - } catch (error) { - console.error('Error fetching global watch config:', error); - return { enabled: false }; // Default to disabled on error - } -} - -document.addEventListener('DOMContentLoaded', async function() { - const watchedItemsContainer = document.getElementById('watchedItemsContainer'); - const loadingIndicator = document.getElementById('loadingWatchedItems'); - const emptyStateIndicator = document.getElementById('emptyWatchedItems'); - const queueIcon = document.getElementById('queueIcon'); - const checkAllWatchedBtn = document.getElementById('checkAllWatchedBtn') as HTMLButtonElement | null; - - // Fetch global watch config first - const globalWatchConfig = await getGlobalWatchConfig(); - - if (queueIcon) { - queueIcon.addEventListener('click', () => { - downloadQueue.toggleVisibility(); - }); - } - - if (checkAllWatchedBtn) { - checkAllWatchedBtn.addEventListener('click', async () => { - checkAllWatchedBtn.disabled = true; - const originalText = checkAllWatchedBtn.innerHTML; - checkAllWatchedBtn.innerHTML = 'Refreshing... Checking...'; - - try { - const artistCheckPromise = fetch('/api/artist/watch/trigger_check', { method: 'POST' }); - const playlistCheckPromise = fetch('/api/playlist/watch/trigger_check', { method: 'POST' }); - - // Use Promise.allSettled-like behavior to handle both responses - const results = await Promise.all([ - artistCheckPromise.then(async res => ({ - ok: res.ok, - data: await res.json().catch(() => ({ error: 'Invalid JSON response' })), - type: 'artist' - })).catch(e => ({ ok: false, data: { error: e.message || 'Request failed' }, type: 'artist' })), - playlistCheckPromise.then(async res => ({ - ok: res.ok, - data: await res.json().catch(() => ({ error: 'Invalid JSON response' })), - type: 'playlist' - })).catch(e => ({ ok: false, data: { error: e.message || 'Request failed' }, type: 'playlist' })) - ]); - - const artistResult = results.find(r => r.type === 'artist'); - const playlistResult = results.find(r => r.type === 'playlist'); - - let successMessages: string[] = []; - let errorMessages: string[] = []; - - if (artistResult) { - if (artistResult.ok) { - successMessages.push(artistResult.data.message || 'Artist check triggered.'); - } else { - errorMessages.push(`Artist check failed: ${artistResult.data.error || 'Unknown error'}`); - } - } - - if (playlistResult) { - if (playlistResult.ok) { - successMessages.push(playlistResult.data.message || 'Playlist check triggered.'); - } else { - errorMessages.push(`Playlist check failed: ${playlistResult.data.error || 'Unknown error'}`); - } - } - - if (errorMessages.length > 0) { - showNotification(errorMessages.join(' '), true); - if (successMessages.length > 0) { // If some succeeded and some failed - // Delay the success message slightly so it doesn't overlap or get missed - setTimeout(() => showNotification(successMessages.join(' ')), 1000); - } - } else if (successMessages.length > 0) { - showNotification(successMessages.join(' ')); - } else { - showNotification('Could not determine check status for artists or playlists.', true); - } - - } catch (error: any) { // Catch for unexpected issues with Promise.all or setup - console.error('Error in checkAllWatchedBtn handler:', error); - showNotification(`An unexpected error occurred: ${error.message}`, true); - } finally { - checkAllWatchedBtn.disabled = false; - checkAllWatchedBtn.innerHTML = originalText; - } - }); - } - - // Initial load is now conditional - if (globalWatchConfig.enabled) { - if (checkAllWatchedBtn) checkAllWatchedBtn.classList.remove('hidden'); - loadWatchedItems(); - } else { - // Watch feature is disabled globally - showLoading(false); - showEmptyState(false); - if (checkAllWatchedBtn) checkAllWatchedBtn.classList.add('hidden'); // Hide the button - - if (watchedItemsContainer) { - watchedItemsContainer.innerHTML = ` -
- Watch Disabled -

The Watchlist feature is currently disabled in the application settings.

-

Please enable it in Settings to use this page.

-
- `; - } - // Ensure the main loading indicator is also hidden if it was shown by default - if (loadingIndicator) loadingIndicator.classList.add('hidden'); - } -}); - -const MAX_NOTIFICATIONS = 3; - -async function loadWatchedItems() { - const watchedItemsContainer = document.getElementById('watchedItemsContainer'); - const loadingIndicator = document.getElementById('loadingWatchedItems'); - const emptyStateIndicator = document.getElementById('emptyWatchedItems'); - - showLoading(true); - showEmptyState(false); - if (watchedItemsContainer) watchedItemsContainer.innerHTML = ''; - - try { - const [artistsResponse, playlistsResponse] = await Promise.all([ - fetch('/api/artist/watch/list'), - fetch('/api/playlist/watch/list') - ]); - - if (!artistsResponse.ok || !playlistsResponse.ok) { - throw new Error('Failed to load initial watched items list'); - } - - const artists: ArtistFromWatchList[] = await artistsResponse.json(); - const playlists: PlaylistFromWatchList[] = await playlistsResponse.json(); - - const initialItems: InitialWatchedItem[] = [ - ...artists.map(artist => ({ - ...artist, - id: artist.spotify_id, // Map spotify_id to id for artists - itemType: 'artist' as const - })), - ...playlists.map(playlist => ({ - ...playlist, - id: playlist.spotify_id, // Map spotify_id to id for playlists - itemType: 'playlist' as const - })) - ]; - - if (initialItems.length === 0) { - showLoading(false); - showEmptyState(true); - return; - } - - // Fetch detailed info for each item - const detailedItemPromises = initialItems.map(async (initialItem) => { - try { - if (initialItem.itemType === 'artist') { - const infoResponse = await fetch(`/api/artist/info?id=${initialItem.id}`); - if (!infoResponse.ok) { - console.warn(`Failed to fetch artist info for ${initialItem.name} (ID: ${initialItem.id}): ${infoResponse.status}`); - // Fallback to initial data if info fetch fails - return { - itemType: 'artist', - id: initialItem.id, - name: initialItem.name, - imageUrl: (initialItem as ArtistFromWatchList).images?.[0]?.url, // Cast to access images - total_albums: (initialItem as ArtistFromWatchList).total_albums || 0, // Cast to access total_albums - } as FinalArtistCardItem; - } - const info: ArtistInfoResponse = await infoResponse.json(); - return { - itemType: 'artist', - id: initialItem.id, // Use the ID from the watch list, as /info might have 'artist_id' - name: info.artist_name || initialItem.name, // Prefer info, fallback to initial - imageUrl: info.items?.[0]?.images?.[0]?.url || info.artist_image_url || (initialItem as ProcessedArtistFromWatchList).images?.[0]?.url, // Prioritize first album image from items - total_albums: info.total, // 'total' from ArtistInfoResponse is total_albums - external_urls: { spotify: info.artist_external_url } - } as FinalArtistCardItem; - } else { // Playlist - const infoResponse = await fetch(`/api/playlist/info?id=${initialItem.id}`); - if (!infoResponse.ok) { - console.warn(`Failed to fetch playlist info for ${initialItem.name} (ID: ${initialItem.id}): ${infoResponse.status}`); - // Fallback to initial data if info fetch fails - return { - itemType: 'playlist', - id: initialItem.id, - name: initialItem.name, - imageUrl: (initialItem as ProcessedPlaylistFromWatchList).images?.[0]?.url, // Cast to access images - owner_name: (initialItem as ProcessedPlaylistFromWatchList).owner?.display_name, // Cast to access owner - total_tracks: (initialItem as ProcessedPlaylistFromWatchList).total_tracks || 0, // Cast to access total_tracks - } as FinalPlaylistCardItem; - } - const info: PlaylistInfoResponse = await infoResponse.json(); - return { - itemType: 'playlist', - id: initialItem.id, // Use ID from watch list - name: info.name || initialItem.name, // Prefer info, fallback to initial - imageUrl: info.images?.[0]?.url || (initialItem as ProcessedPlaylistFromWatchList).images?.[0]?.url, // Prefer info, fallback to initial (ProcessedPlaylistFromWatchList) - owner_name: info.owner?.display_name || (initialItem as ProcessedPlaylistFromWatchList).owner?.display_name, // Prefer info, fallback to initial (ProcessedPlaylistFromWatchList) - total_tracks: info.tracks.total, // 'total' from PlaylistInfoResponse.tracks - followers_count: info.followers?.total, - description: info.description, - external_urls: info.external_urls - } as FinalPlaylistCardItem; - } - } catch (e: any) { - console.error(`Error processing item ${initialItem.name} (ID: ${initialItem.id}):`, e); - // Return a fallback structure if processing fails catastrophically - return { - itemType: initialItem.itemType, - id: initialItem.id, - name: initialItem.name + " (Error loading details)", - imageUrl: initialItem.images?.[0]?.url, - // Add minimal common fields for artists and playlists for fallback - ...(initialItem.itemType === 'artist' ? { total_albums: (initialItem as ProcessedArtistFromWatchList).total_albums || 0 } : {}), - ...(initialItem.itemType === 'playlist' ? { total_tracks: (initialItem as ProcessedPlaylistFromWatchList).total_tracks || 0 } : {}), - } as FinalCardItem; // Cast to avoid TS errors, knowing one of the spreads will match - } - }); - - // Simulating Promise.allSettled behavior for compatibility - const settledResults: CustomSettledPromiseResult[] = await Promise.all( - detailedItemPromises.map(p => - p.then(value => ({ status: 'fulfilled', value } as CustomPromiseFulfilledResult)) - .catch(reason => ({ status: 'rejected', reason } as CustomPromiseRejectedResult)) - ) - ); - - const finalItems: FinalCardItem[] = settledResults - .filter((result): result is CustomPromiseFulfilledResult => result.status === 'fulfilled') - .map(result => result.value) - .filter(item => item !== null) as FinalCardItem[]; // Ensure no nulls from catastrophic failures - - showLoading(false); - - if (finalItems.length === 0) { - showEmptyState(true); - // Potentially show a different message if initialItems existed but all failed to load details - if (initialItems.length > 0 && watchedItemsContainer) { - watchedItemsContainer.innerHTML = `

Could not load details for any watched items. Please check the console for errors.

`; - } - return; - } - - if (watchedItemsContainer) { - // Clear previous content - watchedItemsContainer.innerHTML = ''; - - if (finalItems.length > 8) { - const playlistItems = finalItems.filter(item => item.itemType === 'playlist') as FinalPlaylistCardItem[]; - const artistItems = finalItems.filter(item => item.itemType === 'artist') as FinalArtistCardItem[]; - - // Create and append Playlist section - if (playlistItems.length > 0) { - const playlistSection = document.createElement('div'); - playlistSection.className = 'watched-items-group'; - const playlistHeader = document.createElement('h2'); - playlistHeader.className = 'watched-group-header'; - playlistHeader.textContent = 'Watched Playlists'; - playlistSection.appendChild(playlistHeader); - const playlistGrid = document.createElement('div'); - playlistGrid.className = 'results-grid'; // Use existing grid style - playlistItems.forEach(item => { - const cardElement = createWatchedItemCard(item); - playlistGrid.appendChild(cardElement); - }); - playlistSection.appendChild(playlistGrid); - watchedItemsContainer.appendChild(playlistSection); - } else { - const noPlaylistsMessage = document.createElement('p'); - noPlaylistsMessage.textContent = 'No watched playlists.'; - noPlaylistsMessage.className = 'empty-group-message'; - // Optionally add a header for consistency even if empty - const playlistHeader = document.createElement('h2'); - playlistHeader.className = 'watched-group-header'; - playlistHeader.textContent = 'Watched Playlists'; - watchedItemsContainer.appendChild(playlistHeader); - watchedItemsContainer.appendChild(noPlaylistsMessage); - } - - // Create and append Artist section - if (artistItems.length > 0) { - const artistSection = document.createElement('div'); - artistSection.className = 'watched-items-group'; - const artistHeader = document.createElement('h2'); - artistHeader.className = 'watched-group-header'; - artistHeader.textContent = 'Watched Artists'; - artistSection.appendChild(artistHeader); - const artistGrid = document.createElement('div'); - artistGrid.className = 'results-grid'; // Use existing grid style - artistItems.forEach(item => { - const cardElement = createWatchedItemCard(item); - artistGrid.appendChild(cardElement); - }); - artistSection.appendChild(artistGrid); - watchedItemsContainer.appendChild(artistSection); - } else { - const noArtistsMessage = document.createElement('p'); - noArtistsMessage.textContent = 'No watched artists.'; - noArtistsMessage.className = 'empty-group-message'; - // Optionally add a header for consistency even if empty - const artistHeader = document.createElement('h2'); - artistHeader.className = 'watched-group-header'; - artistHeader.textContent = 'Watched Artists'; - watchedItemsContainer.appendChild(artistHeader); - watchedItemsContainer.appendChild(noArtistsMessage); - } - - } else { // 8 or fewer items, render them directly - finalItems.forEach(item => { - const cardElement = createWatchedItemCard(item); - watchedItemsContainer.appendChild(cardElement); - }); - } - } - - } catch (error: any) { - console.error('Error loading watched items:', error); - showLoading(false); - if (watchedItemsContainer) { - watchedItemsContainer.innerHTML = `

Error loading watched items: ${error.message}

`; - } - } -} - -function createWatchedItemCard(item: FinalCardItem): HTMLDivElement { - const cardElement = document.createElement('div'); - cardElement.className = 'watched-item-card'; - cardElement.dataset.itemId = item.id; - cardElement.dataset.itemType = item.itemType; - - // Check Now button HTML is no longer generated separately here for absolute positioning - - let imageUrl = '/static/images/placeholder.jpg'; - if (item.imageUrl) { - imageUrl = item.imageUrl; - } - - let detailsHtml = ''; - let typeBadgeClass = ''; - let typeName = ''; - - if (item.itemType === 'artist') { - typeName = 'Artist'; - typeBadgeClass = 'artist'; - const artist = item as FinalArtistCardItem; - detailsHtml = artist.total_albums !== undefined ? `${artist.total_albums} albums` : ''; - } else if (item.itemType === 'playlist') { - typeName = 'Playlist'; - typeBadgeClass = 'playlist'; - const playlist = item as FinalPlaylistCardItem; - detailsHtml = playlist.owner_name ? `By: ${playlist.owner_name}` : ''; - detailsHtml += playlist.total_tracks !== undefined ? ` • ${playlist.total_tracks} tracks` : ''; - if (playlist.followers_count !== undefined) { - detailsHtml += ` • ${playlist.followers_count} followers`; - } - } - - cardElement.innerHTML = ` -
- ${item.name} -
-
${item.name}
-
${detailsHtml}
- ${typeName} -
- - -
- `; - - // Add click event to navigate to the item's detail page - cardElement.addEventListener('click', (e: MouseEvent) => { - const target = e.target as HTMLElement; - // Don't navigate if any button within the card was clicked - if (target.closest('button')) { - return; - } - window.location.href = `/${item.itemType}/${item.id}`; - }); - - // Add event listener for the "Check Now" button - const checkNowBtn = cardElement.querySelector('.check-item-now-btn') as HTMLButtonElement | null; - if (checkNowBtn) { - checkNowBtn.addEventListener('click', (e: MouseEvent) => { - e.stopPropagation(); - const itemId = checkNowBtn.dataset.id; - const itemType = checkNowBtn.dataset.type as 'artist' | 'playlist'; - if (itemId && itemType) { - triggerItemCheck(itemId, itemType, checkNowBtn); - } - }); - } - - // Add event listener for the "Unwatch" button - const unwatchBtn = cardElement.querySelector('.unwatch-item-btn') as HTMLButtonElement | null; - if (unwatchBtn) { - unwatchBtn.addEventListener('click', (e: MouseEvent) => { - e.stopPropagation(); - const itemId = unwatchBtn.dataset.id; - const itemType = unwatchBtn.dataset.type as 'artist' | 'playlist'; - if (itemId && itemType) { - unwatchItem(itemId, itemType, unwatchBtn, cardElement); - } - }); - } - - return cardElement; -} - -function showLoading(show: boolean) { - const loadingIndicator = document.getElementById('loadingWatchedItems'); - if (loadingIndicator) loadingIndicator.classList.toggle('hidden', !show); -} - -function showEmptyState(show: boolean) { - const emptyStateIndicator = document.getElementById('emptyWatchedItems'); - if (emptyStateIndicator) emptyStateIndicator.classList.toggle('hidden', !show); -} - -async function unwatchItem(itemId: string, itemType: 'artist' | 'playlist', buttonElement: HTMLButtonElement, cardElement: HTMLElement) { - const originalButtonContent = buttonElement.innerHTML; - buttonElement.disabled = true; - buttonElement.innerHTML = 'Unwatching...'; // Assuming a small loader icon - - const endpoint = `/api/${itemType}/watch/${itemId}`; - - try { - const response = await fetch(endpoint, { method: 'DELETE' }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || `Server error: ${response.status}`); - } - const result = await response.json(); - showNotification(result.message || `${itemType.charAt(0).toUpperCase() + itemType.slice(1)} unwatched successfully.`); - - cardElement.style.transition = 'opacity 0.5s ease, transform 0.5s ease'; - cardElement.style.opacity = '0'; - cardElement.style.transform = 'scale(0.9)'; - setTimeout(() => { - cardElement.remove(); - const watchedItemsContainer = document.getElementById('watchedItemsContainer'); - const playlistGroups = document.querySelectorAll('.watched-items-group .results-grid'); - let totalItemsLeft = 0; - - if (playlistGroups.length > 0) { // Grouped view - playlistGroups.forEach(group => { - totalItemsLeft += group.childElementCount; - }); - // If a group becomes empty, we might want to remove the group header or show an empty message for that group. - // This can be added here if desired. - } else if (watchedItemsContainer) { // Non-grouped view - totalItemsLeft = watchedItemsContainer.childElementCount; - } - - if (totalItemsLeft === 0) { - // If all items are gone (either from groups or directly), reload to show empty state. - // This also correctly handles the case where the initial list had <= 8 items. - loadWatchedItems(); - } - - }, 500); - - } catch (error: any) { - console.error(`Error unwatching ${itemType}:`, error); - showNotification(`Failed to unwatch: ${error.message}`, true); - buttonElement.disabled = false; - buttonElement.innerHTML = originalButtonContent; - } -} - -async function triggerItemCheck(itemId: string, itemType: 'artist' | 'playlist', buttonElement: HTMLButtonElement) { - const originalButtonContent = buttonElement.innerHTML; // Will just be the img - buttonElement.disabled = true; - // Keep the icon, but we can add a class for spinning or use the same icon. - // For simplicity, just using the same icon. Text "Checking..." is removed. - buttonElement.innerHTML = 'Checking...'; - - const endpoint = `/api/${itemType}/watch/trigger_check/${itemId}`; - - try { - const response = await fetch(endpoint, { method: 'POST' }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); // Handle non-JSON error responses - throw new Error(errorData.error || `Server error: ${response.status}`); - } - const result = await response.json(); - showNotification(result.message || `Successfully triggered check for ${itemType}.`); - } catch (error: any) { - console.error(`Error triggering ${itemType} check:`, error); - showNotification(`Failed to trigger check: ${error.message}`, true); - } finally { - buttonElement.disabled = false; - buttonElement.innerHTML = originalButtonContent; - } -} - -// Helper function to show notifications (can be moved to a shared utility file if used elsewhere) -function showNotification(message: string, isError: boolean = false) { - const notificationArea = document.getElementById('notificationArea') || createNotificationArea(); - - // Limit the number of visible notifications - while (notificationArea.childElementCount >= MAX_NOTIFICATIONS) { - const oldestNotification = notificationArea.firstChild; // In column-reverse, firstChild is visually the bottom one - if (oldestNotification) { - oldestNotification.remove(); - } else { - break; // Should not happen if childElementCount > 0 - } - } - - const notification = document.createElement('div'); - notification.className = `notification-toast ${isError ? 'error' : 'success'}`; - notification.textContent = message; - - notificationArea.appendChild(notification); - - // Auto-remove after 5 seconds - setTimeout(() => { - notification.classList.add('hide'); - setTimeout(() => notification.remove(), 500); // Remove from DOM after fade out - }, 5000); -} - -function createNotificationArea(): HTMLElement { - const area = document.createElement('div'); - area.id = 'notificationArea'; - document.body.appendChild(area); - return area; -} \ No newline at end of file diff --git a/static/css/album/album.css b/static/css/album/album.css deleted file mode 100644 index 3f71721..0000000 --- a/static/css/album/album.css +++ /dev/null @@ -1,406 +0,0 @@ -/* Base Styles */ -* { - margin: 0; - padding: 0; - box-sizing: border-box; - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; -} - -body { - background: linear-gradient(135deg, #121212, #1e1e1e); - color: #ffffff; - min-height: 100vh; - line-height: 1.4; -} - -/* Main App Container */ -#app { - max-width: 1200px; - margin: 0 auto; - padding: 20px; - position: relative; - z-index: 1; -} - -/* Album Header */ -#album-header { - display: flex; - gap: 20px; - margin-bottom: 2rem; - align-items: center; - padding-bottom: 1.5rem; - border-bottom: 1px solid #2a2a2a; - flex-wrap: wrap; - transition: all 0.3s ease; -} - -#album-image { - width: 200px; - height: 200px; - object-fit: cover; - border-radius: 8px; - flex-shrink: 0; - box-shadow: 0 4px 8px rgba(0,0,0,0.3); - transition: transform 0.3s ease; -} -#album-image:hover { - transform: scale(1.02); -} - -#album-info { - flex: 1; - min-width: 0; -} - -#album-name { - font-size: 2.5rem; - margin-bottom: 0.5rem; - background: linear-gradient(90deg, #1db954, #17a44b); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; -} - -#album-artist, -#album-stats { - font-size: 1.1rem; - color: #b3b3b3; - margin-bottom: 0.5rem; -} - -#album-copyright { - font-size: 0.9rem; - color: #b3b3b3; - opacity: 0.8; - margin-bottom: 0.5rem; -} - -/* Tracks Container */ -#tracks-container { - margin-top: 2rem; -} - -#tracks-container h2 { - font-size: 1.75rem; - margin-bottom: 1rem; - border-bottom: 1px solid #2a2a2a; - padding-bottom: 0.5rem; -} - -/* Tracks List */ -#tracks-list { - display: flex; - flex-direction: column; - gap: 1rem; -} - -/* Individual Track Styling */ -.track { - display: grid; - grid-template-columns: 40px 1fr auto auto; - align-items: center; - padding: 0.75rem 1rem; - border-radius: var(--radius-sm); - background-color: var(--color-surface); - margin-bottom: 0.5rem; - transition: background-color 0.2s ease; -} - -.track:hover { - background-color: var(--color-surface-hover); -} - -.track-number { - display: flex; - align-items: center; - justify-content: center; - font-size: 0.9rem; - color: var(--color-text-secondary); - width: 24px; -} - -.track-info { - padding: 0 1rem; - flex: 1; - min-width: 0; -} - -.track-name { - font-weight: 500; - margin-bottom: 0.25rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.track-artist { - font-size: 0.9rem; - color: var(--color-text-secondary); -} - -.track-duration { - color: var(--color-text-tertiary); - font-size: 0.9rem; - margin-right: 1rem; -} - -/* Loading and Error States */ -.loading, -.error { - width: 100%; - text-align: center; - font-size: 1rem; - padding: 1rem; -} - -.error { - color: #c0392b; -} - -/* Utility Classes */ -.hidden { - display: none !important; -} - -/* Unified Download Button Base Style */ -.download-btn { - background-color: #1db954; - color: #fff; - border: none; - border-radius: 4px; - padding: 0.6rem 1.2rem; - font-size: 1rem; - cursor: pointer; - transition: background-color 0.2s ease, transform 0.2s ease; - display: inline-flex; - align-items: center; - justify-content: center; - margin: 0.5rem; -} - -.download-btn:hover { - background-color: #17a44b; -} - -.download-btn:active { - transform: scale(0.98); -} - -/* Circular Variant for Compact Areas */ -.download-btn--circle { - width: 32px; - height: 32px; - padding: 0; - border-radius: 50%; - font-size: 0; /* Hide any text */ - background-color: #1db954; - border: none; - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: background-color 0.2s ease, transform 0.2s ease; - margin: 0.5rem; -} - -.download-btn--circle img { - width: 20px; - height: 20px; - filter: brightness(0) invert(1); - display: block; -} - -.download-btn--circle:hover { - background-color: #17a44b; - transform: scale(1.05); -} - -.download-btn--circle:active { - transform: scale(0.98); -} - -/* Home Button Styling */ -.home-btn { - background-color: transparent; - border: none; - cursor: pointer; - margin-right: 1rem; - padding: 0; -} - -.home-btn img { - width: 32px; - height: 32px; - filter: invert(1); - transition: transform 0.2s ease; -} - -.home-btn:hover img { - transform: scale(1.05); -} - -.home-btn:active img { - transform: scale(0.98); -} - -/* Queue Toggle Button */ -.queue-toggle { - background: #1db954; - color: #fff; - border: none; - border-radius: 50%; - width: 56px; - height: 56px; - cursor: pointer; - font-size: 1rem; - font-weight: bold; - box-shadow: 0 2px 8px rgba(0,0,0,0.3); - transition: background-color 0.3s ease, transform 0.2s ease; - z-index: 1002; - /* Remove any fixed positioning by default for mobile; fixed positioning remains for larger screens */ -} - -/* Actions Container for Small Screens */ -#album-actions { - display: flex; - width: 100%; - justify-content: space-between; - align-items: center; - margin-top: 1rem; -} - -/* Responsive Styles */ - -/* Medium Devices (Tablets) */ -@media (max-width: 768px) { - #album-header { - flex-direction: column; - align-items: center; - text-align: center; - } - - #album-image { - width: 180px; - height: 180px; - margin-bottom: 1rem; - } - - #album-name { - font-size: 2rem; - } - - #album-artist, - #album-stats { - font-size: 1rem; - } - - .track { - grid-template-columns: 30px 1fr auto auto; - padding: 0.6rem 0.8rem; - } - - .track-duration { - margin-right: 0.5rem; - } -} - -/* Small Devices (Mobile Phones) */ -@media (max-width: 480px) { - #app { - padding: 10px; - } - - #album-header { - flex-direction: column; - align-items: center; - text-align: center; - } - - /* Center the album cover */ - #album-image { - margin: 0 auto; - } - - #album-name { - font-size: 1.75rem; - } - - #album-artist, - #album-stats, - #album-copyright { - font-size: 0.9rem; - } - - .track { - grid-template-columns: 30px 1fr auto; - } - - .track-info { - padding: 0 0.5rem; - } - - .track-name, .track-artist { - max-width: 200px; - } - - .section-title { - font-size: 1.25rem; - } - - /* Ensure the actions container lays out buttons properly */ - #album-actions { - flex-direction: row; - justify-content: space-between; - } - - /* Remove extra margins from the queue toggle */ - .queue-toggle { - position: static; - margin: 0; - } -} - -/* Prevent anchor links from appearing all blue */ -a { - color: inherit; - text-decoration: none; - transition: color 0.2s ease; -} - -a:hover, -a:focus { - color: #1db954; - text-decoration: underline; -} - -/* (Optional) Override for circular download button pseudo-element */ -.download-btn--circle::before { - content: none; -} - -/* Album page specific styles */ - -/* Add some context styles for the album copyright */ -.album-copyright { - font-size: 0.85rem; - color: var(--color-text-tertiary); - margin-top: 0.5rem; - font-style: italic; -} - -/* Section title styling */ -.section-title { - font-size: 1.5rem; - margin-bottom: 1rem; - position: relative; - padding-bottom: 0.5rem; -} - -.section-title:after { - content: ''; - position: absolute; - left: 0; - bottom: 0; - width: 50px; - height: 2px; - background-color: var(--color-primary); -} diff --git a/static/css/artist/artist.css b/static/css/artist/artist.css deleted file mode 100644 index 3c0a75e..0000000 --- a/static/css/artist/artist.css +++ /dev/null @@ -1,637 +0,0 @@ -/* Base Styles */ -* { - margin: 0; - padding: 0; - box-sizing: border-box; - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; -} - -body { - background-color: #121212; - color: #ffffff; - min-height: 100vh; - line-height: 1.4; -} - -/* Main App Container */ -#app { - max-width: 1200px; - margin: 0 auto; - padding: 20px; - position: relative; - z-index: 1; -} - -/* Artist Header */ -#artist-header { - display: flex; - gap: 20px; - margin-bottom: 2rem; - align-items: center; - padding: 20px; - border-bottom: 1px solid #2a2a2a; - flex-wrap: wrap; - border-radius: 8px; - background: linear-gradient(135deg, rgba(0,0,0,0.5), transparent); -} - -#artist-image { - width: 200px; - height: 200px; - object-fit: cover; - border-radius: 8px; - flex-shrink: 0; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); -} - -#artist-info { - flex: 1; - min-width: 0; -} - -#artist-name { - font-size: 2rem; - margin-bottom: 0.5rem; -} - -#artist-stats { - font-size: 1rem; - color: #b3b3b3; - margin-bottom: 0.5rem; -} - -/* Albums Container */ -#albums-container { - margin-top: 2rem; -} - -#albums-container h2 { - font-size: 1.5rem; - margin-bottom: 1rem; - border-bottom: 1px solid #2a2a2a; - padding-bottom: 0.5rem; -} - -/* Album groups layout */ -.album-groups { - display: flex; - flex-direction: column; - gap: 2rem; -} - -/* Album group section */ -.album-group { - margin-bottom: 1.5rem; -} - -.album-group-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 1rem; -} - -.album-group-header h3 { - font-size: 1.3rem; - position: relative; - padding-bottom: 0.5rem; -} - -.album-group-header h3::after { - content: ''; - position: absolute; - left: 0; - bottom: 0; - width: 40px; - height: 2px; - background-color: var(--color-primary); -} - -.group-download-btn { - padding: 0.4rem 0.8rem; - font-size: 0.9rem; -} - -/* Albums grid layout */ -.albums-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 1.5rem; - margin-top: 1rem; -} - -/* Album card styling */ -.album-card { - background-color: var(--color-surface); - border-radius: var(--radius-md); - overflow: hidden; - transition: transform 0.2s ease, box-shadow 0.2s ease; - position: relative; - box-shadow: var(--shadow-sm); -} - -.album-card:hover { - transform: translateY(-5px); - box-shadow: var(--shadow-md); -} - -.album-cover { - width: 100%; - aspect-ratio: 1/1; - object-fit: cover; - transition: opacity 0.2s ease; -} - -.album-info { - padding: 0.75rem; -} - -.album-title { - font-weight: 600; - margin-bottom: 0.25rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.album-artist { - font-size: 0.9rem; - color: var(--color-text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Track Card (for Albums or Songs) */ -.track { - display: flex; - align-items: center; - padding: 1rem; - background: #181818; - border-radius: 8px; - transition: background 0.3s ease; - flex-wrap: wrap; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); -} - -.track:hover { - background: #2a2a2a; -} - -.track-number { - width: 30px; - font-size: 1rem; - font-weight: 500; - text-align: center; - margin-right: 1rem; - flex-shrink: 0; -} - -.track-info { - flex: 1; - display: flex; - flex-direction: column; - min-width: 0; - align-items: flex-start; -} - -.track-name { - font-size: 1rem; - font-weight: bold; - word-wrap: break-word; -} - -.track-artist { - font-size: 0.9rem; - color: #b3b3b3; -} - -.track-album { - max-width: 200px; - font-size: 0.9rem; - color: #b3b3b3; - margin-left: 1rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - text-align: right; -} - -.track-duration { - width: 60px; - text-align: right; - font-size: 0.9rem; - color: #b3b3b3; - margin-left: 1rem; - flex-shrink: 0; -} - -/* Loading and Error States */ -.loading, -.error { - width: 100%; - text-align: center; - font-size: 1rem; - padding: 1rem; -} - -.error { - color: #c0392b; -} - -/* Utility Classes */ -.hidden { - display: none !important; -} - -/* Unified Download Button Base Style */ -.download-btn { - background-color: #1db954; - color: #fff; - border: none; - border-radius: 4px; - padding: 0.5rem 1rem; - font-size: 0.95rem; - cursor: pointer; - transition: background-color 0.2s ease, transform 0.2s ease; - display: inline-flex; - align-items: center; - justify-content: center; - margin: 0.5rem; -} - -.download-btn:hover { - background-color: #17a44b; -} - -.download-btn:active { - transform: scale(0.98); -} - -/* Circular Variant for Compact Areas (e.g. album download buttons) */ -.download-btn--circle { - width: 32px; - height: 32px; - padding: 0; - border-radius: 50%; - font-size: 0; /* Hide any text */ - background-color: #1db954; - border: none; - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: background-color 0.2s ease, transform 0.2s ease; - margin: 0.5rem; -} - -.download-btn--circle img { - width: 20px; - height: 20px; - filter: brightness(0) invert(1); - display: block; -} - -.download-btn--circle:hover { - background-color: #17a44b; - transform: scale(1.05); -} - -.download-btn--circle:active { - transform: scale(0.98); -} - -/* Home Button Styling */ -.home-btn { - background-color: transparent; - border: none; - cursor: pointer; - margin-right: 1rem; - padding: 0; -} - -.home-btn img { - width: 32px; - height: 32px; - filter: invert(1); - transition: transform 0.2s ease; -} - -.home-btn:hover img { - transform: scale(1.05); -} - -.home-btn:active img { - transform: scale(0.98); -} - -/* Watch Artist Button Styling */ -.watch-btn { - background-color: transparent; - color: #ffffff; - border: 1px solid #ffffff; - border-radius: 4px; - padding: 0.5rem 1rem; - font-size: 0.95rem; - cursor: pointer; - transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, transform 0.2s ease; - display: inline-flex; - align-items: center; - justify-content: center; - margin: 0.5rem; -} - -.watch-btn:hover { - background-color: #ffffff; - color: #121212; - border-color: #ffffff; -} - -.watch-btn.watching { - background-color: #1db954; /* Spotify green for "watching" state */ - color: #ffffff; - border-color: #1db954; -} - -.watch-btn.watching:hover { - background-color: #17a44b; /* Darker green on hover */ - border-color: #17a44b; - color: #ffffff; -} - -.watch-btn:active { - transform: scale(0.98); -} - -/* Styling for icons within watch and sync buttons */ -.watch-btn img, -.sync-btn img { - width: 16px; /* Adjust size as needed */ - height: 16px; /* Adjust size as needed */ - margin-right: 8px; /* Space between icon and text */ - filter: brightness(0) invert(1); /* Make icons white */ -} - -/* Responsive Styles */ - -/* Medium Devices (Tablets) */ -@media (max-width: 768px) { - #artist-header { - flex-direction: column; - align-items: center; - text-align: center; - } - - #artist-image { - width: 180px; - height: 180px; - margin-bottom: 1rem; - } - - .track { - flex-direction: column; - align-items: center; - } - - .track-album, - .track-duration { - margin-left: 0; - margin-top: 0.5rem; - width: 100%; - text-align: center; - } - - .albums-list { - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); - gap: 1rem; - } - - .album-group-header { - flex-direction: column; - align-items: flex-start; - } - - .group-download-btn { - margin-top: 0.5rem; - } -} - -/* Small Devices (Mobile Phones) */ -@media (max-width: 480px) { - #app { - padding: 10px; - } - - #artist-name { - font-size: 1.75rem; - } - - .track { - padding: 0.8rem; - flex-direction: column; - align-items: center; - text-align: center; - } - - .track-number { - font-size: 0.9rem; - margin-right: 0; - margin-bottom: 0.5rem; - } - - .track-info { - align-items: center; - } - - .track-album, - .track-duration { - margin-left: 0; - margin-top: 0.5rem; - width: 100%; - text-align: center; - } - - .albums-list { - grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); - gap: 0.75rem; - } - - .album-info { - padding: 0.5rem; - } - - .album-title { - font-size: 0.9rem; - } - - .album-artist { - font-size: 0.8rem; - } -} - -/* Prevent anchor links from appearing blue */ -a { - color: inherit; - text-decoration: none; - transition: color 0.2s ease; -} - -a:hover, -a:focus { - color: #1db954; - text-decoration: underline; -} - -/* Toggle Known Status Button for Tracks/Albums */ -.toggle-known-status-btn { - background-color: transparent; - border: 1px solid var(--color-text-secondary); - color: var(--color-text-secondary); - padding: 5px; - border-radius: 50%; /* Make it circular */ - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - width: 30px; /* Fixed size */ - height: 30px; /* Fixed size */ - transition: background-color 0.2s, border-color 0.2s, color 0.2s, opacity 0.2s; /* Added opacity */ - /* opacity: 0; Initially hidden, JS will make it visible if artist is watched via persistent-album-action-btn */ -} - -.toggle-known-status-btn img { - width: 16px; /* Adjust icon size */ - height: 16px; - filter: brightness(0) invert(1); /* Make icon white consistently */ - margin: 0; /* Ensure no accidental margin for centering */ -} - -.toggle-known-status-btn:hover { - border-color: var(--color-primary); - background-color: rgba(var(--color-primary-rgb), 0.1); -} - -.toggle-known-status-btn[data-status="known"] { - /* Optional: specific styles if it's already known, e.g., a slightly different border */ - border-color: var(--color-success); /* Green border for known items */ -} - -.toggle-known-status-btn[data-status="missing"] { - /* Optional: specific styles if it's missing, e.g., a warning color */ - border-color: var(--color-warning); /* Orange border for missing items */ -} - -.toggle-known-status-btn:active { - transform: scale(0.95); -} - -/* Ensure album download button also fits well within actions container */ -.album-actions-container .album-download-btn { - width: 30px; - height: 30px; - padding: 5px; /* Ensure padding doesn't make it too big */ -} - -.album-actions-container .album-download-btn img { - width: 16px; - height: 16px; -} - -/* Album actions container */ -.album-actions-container { - /* position: absolute; */ /* No longer needed if buttons are positioned individually */ - /* bottom: 8px; */ - /* right: 8px; */ - /* display: flex; */ - /* gap: 8px; */ - /* background-color: rgba(0, 0, 0, 0.6); */ - /* padding: 5px; */ - /* border-radius: var(--radius-sm); */ - /* opacity: 0; */ /* Ensure it doesn't hide buttons if it still wraps them elsewhere */ - /* transition: opacity 0.2s ease-in-out; */ - display: none; /* Hide this container if it solely relied on hover and now buttons are persistent */ -} - -/* .album-card:hover .album-actions-container { */ - /* opacity: 1; */ /* Remove this hover effect */ -/* } */ - -/* Album card actions container - for persistent buttons at the bottom */ -.album-card-actions { - display: flex; - justify-content: space-between; /* Pushes children to ends */ - align-items: center; - padding: 8px; /* Spacing around the buttons */ - border-top: 1px solid var(--color-surface-darker, #2a2a2a); /* Separator line */ - /* Ensure it takes up full width of the card if not already */ - width: 100%; -} - -/* Persistent action button (e.g., toggle known/missing) on album card - BOTTOM-LEFT */ -.persistent-album-action-btn { - /* position: absolute; */ /* No longer absolute */ - /* bottom: 8px; */ - /* left: 8px; */ - /* z-index: 2; */ - opacity: 1; /* Ensure it is visible */ - /* Specific margin if needed, but flexbox space-between should handle it */ - margin: 0; /* Reset any previous margins */ -} - -/* Persistent download button on album card - BOTTOM-RIGHT */ -.persistent-download-btn { - /* position: absolute; */ /* No longer absolute */ - /* bottom: 8px; */ - /* right: 8px; */ - /* z-index: 2; */ - opacity: 1; /* Ensure it is visible */ - /* Specific margin if needed, but flexbox space-between should handle it */ - margin: 0; /* Reset any previous margins */ -} - -.album-cover.album-missing-in-db { - border: 3px dashed var(--color-warning); /* Example: orange dashed border */ - opacity: 0.7; -} - -/* NEW STYLES FOR BUTTON STATES */ -.persistent-album-action-btn.status-missing { - background-color: #d9534f; /* Bootstrap's btn-danger red */ - border-color: #d43f3a; -} - -.persistent-album-action-btn.status-missing:hover { - background-color: #c9302c; - border-color: #ac2925; -} - -/* Ensure icon is white on colored background */ -.persistent-album-action-btn.status-missing img { - filter: brightness(0) invert(1); -} - -.persistent-album-action-btn.status-known { - background-color: #5cb85c; /* Bootstrap's btn-success green */ - border-color: #4cae4c; -} - -.persistent-album-action-btn.status-known:hover { - background-color: #449d44; - border-color: #398439; -} - -/* Ensure icon is white on colored background */ -.persistent-album-action-btn.status-known img { - filter: brightness(0) invert(1); -} -/* END OF NEW STYLES */ - -/* Spinning Icon Animation */ -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(-360deg); } -} - -.icon-spin { - animation: spin 1s linear infinite; -} diff --git a/static/css/config/config.css b/static/css/config/config.css deleted file mode 100644 index 927c32c..0000000 --- a/static/css/config/config.css +++ /dev/null @@ -1,1014 +0,0 @@ -/* CONFIGURATION PAGE STYLES */ -/* Base styles */ -* { - margin: 0; - padding: 0; - box-sizing: border-box; - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; -} - -body { - /* Modern dark gradient background */ - background: linear-gradient(135deg, #121212, #1e1e1e); - color: #ffffff; - min-height: 100vh; -} - -/* Config Container */ -.config-container { - max-width: 800px; - margin: 0 auto; - padding: 2rem 1.5rem; -} - -/* Header */ -.config-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 2rem; - padding-bottom: 1.5rem; - border-bottom: 1px solid #2a2a2a; - transition: all 0.3s ease; -} - -/* Environment controlled setting styles */ -.env-controlled-setting { - display: flex; - align-items: center; - background-color: #2a2a2a; - border-radius: 8px; - padding: 0.8rem 1rem; - margin-top: 0.5rem; -} - -.env-controlled-value { - flex: 1; - font-weight: 500; -} - -.env-controlled-value.enabled { - color: #1db954; -} - -.env-controlled-value.disabled { - color: #ff5555; -} - -.env-controlled-badge { - background-color: #555; - color: white; - font-size: 0.7rem; - padding: 0.2rem 0.5rem; - border-radius: 4px; - margin-left: 0.5rem; - font-weight: bold; -} - -/* Queue Sidebar for Config Page */ -#downloadQueue { - position: fixed; - top: 0; - right: -350px; - width: 350px; - height: 100vh; - background: #181818; - padding: 20px; - transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1); - z-index: 1001; - box-shadow: -20px 0 30px rgba(0, 0, 0, 0.4); - display: flex; - flex-direction: column; -} - -#downloadQueue.active { - right: 0; -} - -/* Header inside the queue sidebar */ -.sidebar-header { - display: flex; - justify-content: space-between; - align-items: center; - padding-bottom: 15px; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - margin-bottom: 20px; -} - -.sidebar-header h2 { - font-size: 1.25rem; - font-weight: 600; - color: #fff; - margin: 0; -} - -.header-actions { - display: flex; - gap: 10px; - align-items: center; -} - -/* Cancel all button styling */ -#cancelAllBtn { - background: #8b0000; - border: none; - color: #fff; - padding: 8px 12px; - border-radius: 4px; - cursor: pointer; - transition: background 0.3s ease, transform 0.2s ease; - font-size: 14px; - font-weight: 600; - display: flex; - align-items: center; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); -} - -#cancelAllBtn:hover { - background: #a30000; - transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); -} - -#cancelAllBtn:active { - transform: scale(0.98); -} - -#cancelAllBtn .skull-icon { - width: 16px; - height: 16px; - margin-right: 8px; - vertical-align: middle; - filter: brightness(0) invert(1); - transition: transform 0.3s ease; -} - -#cancelAllBtn:hover .skull-icon { - transform: rotate(-10deg) scale(1.2); - animation: skullShake 0.5s infinite alternate; -} - -@keyframes skullShake { - 0% { transform: rotate(-5deg); } - 100% { transform: rotate(5deg); } -} - -/* Empty queue state */ -.queue-empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 200px; - color: #b3b3b3; - text-align: center; - padding: 20px; -} - -.queue-empty img { - width: 60px; - height: 60px; - margin-bottom: 15px; - opacity: 0.6; -} - -.queue-empty p { - font-size: 14px; - line-height: 1.5; -} - -/* Mobile responsiveness for queue in Config page */ -@media (max-width: 600px) { - #downloadQueue { - width: 100%; - right: -100%; - padding: 15px; - } - - #downloadQueue.active { - right: 0; - } - - .sidebar-header { - padding-bottom: 12px; - margin-bottom: 15px; - } - - .sidebar-header h2 { - font-size: 1.1rem; - } - - #cancelAllBtn { - padding: 6px 10px; - font-size: 12px; - } -} - -/* Account Configuration Section */ -.account-config { - background: #181818; - padding: 1.5rem; - border-radius: 12px; - margin-bottom: 2rem; - box-shadow: 0 4px 8px rgba(0,0,0,0.15); - transition: transform 0.3s ease; -} - -/* Service selection highlight */ -#defaultServiceSelect { - border-left: 3px solid #1db954; - box-shadow: 0 0 8px rgba(29, 185, 84, 0.1); - transition: all 0.3s ease; -} - -#defaultServiceSelect:focus { - border-color: #1db954; - box-shadow: 0 0 12px rgba(29, 185, 84, 0.2); -} - -/* Highlighted service-specific options */ -.config-item.highlighted-option { - background-color: rgba(29, 185, 84, 0.05); - border-radius: 8px; - padding: 10px; - margin-left: -10px; - margin-right: -10px; - position: relative; - overflow: hidden; - transition: all 0.3s ease; -} - -.config-item.highlighted-option::before { - content: ''; - position: absolute; - left: 0; - top: 0; - height: 100%; - width: 3px; - background-color: #1db954; -} - -.config-item.highlighted-option label { - color: #ffffff; - font-weight: 500; -} - -/* Add subtle animation on hover */ -.config-item:hover #defaultServiceSelect { - box-shadow: 0 0 12px rgba(29, 185, 84, 0.15); -} - -.account-config:hover { - transform: translateY(-2px); -} - -/* Master Accounts Configuration Section (Global API Keys + Per-Account Lists) */ -.master-accounts-config-section { - background: #181818; /* Consistent with other sections */ - padding: 1.5rem; - border-radius: 12px; - margin-bottom: 2rem; - box-shadow: 0 4px 8px rgba(0,0,0,0.15); - transition: transform 0.3s ease; /* Optional, for consistency */ -} - -.master-accounts-config-section:hover { - transform: translateY(-2px); /* Optional, for consistency */ -} - -/* Section for Global Spotify API Key Configuration */ -.global-api-keys-config { - background: #2a2a2a; /* Slightly different background to stand out or match input groups */ - padding: 1.5rem; - border-radius: 8px; - margin-bottom: 1.5rem; /* Space before the per-account sections start */ - border: 1px solid #404040; /* Subtle border */ -} - -/* Section Titles */ -.section-title { - font-size: 1.5rem; - margin-bottom: 1.5rem; - position: relative; - padding-bottom: 0.75rem; - color: var(--color-text-primary); -} - -.section-title::after { - content: ''; - position: absolute; - bottom: 0; - left: 0; - width: 50px; - height: 2px; - background-color: var(--color-primary); -} - -.config-item { - margin-bottom: 1.5rem; - position: relative; -} - -.config-item label { - display: block; - margin-bottom: 0.5rem; - color: #b3b3b3; - font-size: 0.95rem; -} - -/* Enhanced Dropdown Styling */ -.form-select { - background: #2a2a2a; - color: #ffffff; - border: 1px solid #404040; - border-radius: 8px; - padding: 0.8rem 2.5rem 0.8rem 1rem; - width: 100%; - font-size: 0.95rem; - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3e%3cpath d='M7 10l5 5 5-5z'/%3e%3c/svg%3e"); - background-repeat: no-repeat; - background-position: right 1rem center; - background-size: 12px; - transition: all 0.3s ease; -} - -.form-select:focus { - outline: none; - border-color: #1db954; - box-shadow: 0 0 0 2px rgba(29, 185, 84, 0.2); -} - -.form-select option { - background: #181818; - color: #ffffff; - padding: 0.8rem; -} - -.form-select option:hover { - background: #1db954; -} - -/* New Input Styling for Custom Format Fields */ -.form-input { - width: 100%; - padding: 0.8rem; - background: #2a2a2a; - border: 1px solid #404040; - border-radius: 8px; - color: #ffffff; - font-size: 0.95rem; - transition: border-color 0.3s ease; -} - -.form-input:focus { - outline: none; - border-color: #1db954; - box-shadow: 0 0 0 2px rgba(29, 185, 84, 0.2); -} - -/* Improved Toggle Switches */ -.switch { - position: relative; - display: inline-block; - width: 48px; - height: 24px; - margin-top: 0.5rem; - vertical-align: middle; - overflow: hidden; -} - -.switch input { - opacity: 0; - width: 0; - height: 0; -} - -.slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: #666; - transition: 0.4s; - border-radius: 24px; -} - -.slider:before { - position: absolute; - content: ""; - height: 18px; - width: 18px; - left: 3px; - bottom: 3px; - background-color: #ffffff; - transition: 0.4s; - border-radius: 50%; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); -} - -input:checked + .slider { - background-color: #1db954; -} - -input:checked + .slider:before { - transform: translateX(24px); -} - -/* Setting description */ -.setting-description { - margin-top: 0.4rem; - font-size: 0.8rem; - color: #b3b3b3; - line-height: 1.4; -} - -/* Accounts section layout */ -.accounts-section { - margin-top: 2rem; -} - -/* Service Tabs */ -.service-tabs { - display: flex; - gap: 0.5rem; - margin-bottom: 1.5rem; -} - -.tab-button { - padding: 0.8rem 1.5rem; - border: none; - border-radius: 25px; - background: #2a2a2a; - color: #ffffff; - cursor: pointer; - font-size: 0.95rem; - transition: background 0.3s ease, transform 0.2s ease; - flex: 1; - text-align: center; -} - -.tab-button.active { - background: #1db954; - transform: translateY(-2px); -} - -.tab-button:hover:not(.active) { - background: #333333; -} - -/* No Credentials Message */ -.no-credentials { - padding: 1.5rem; - background: #2a2a2a; - border-radius: 8px; - margin-bottom: 1.5rem; - text-align: center; - color: #b3b3b3; -} - -/* Credentials List */ -.credentials-list { - margin-bottom: 2rem; - padding: 1.5rem; - background: #181818; - border-radius: 12px; - box-shadow: 0 4px 8px rgba(0,0,0,0.15); -} - -.credential-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 1.25rem; - background: #2a2a2a; - border-radius: 8px; - margin-bottom: 0.75rem; - transition: all 0.3s ease; - border-left: 3px solid var(--color-primary); -} - -.credential-item:hover { - background: #333333; - transform: translateY(-2px); - box-shadow: var(--shadow-md); -} - -.credential-item:last-child { - margin-bottom: 0; -} - -/* New styling for credential info and actions */ -.credential-info { - display: flex; - flex-direction: column; - gap: 0.25rem; - flex: 1; - padding-right: 1rem; -} - -.credential-name { - font-weight: 600; - font-size: 1.1rem; - color: var(--color-text-primary); - margin-bottom: 0.5rem; -} - -.credential-type { - display: inline-block; - padding: 0.25rem 0.5rem; - background-color: rgba(29, 185, 84, 0.1); - border-radius: 8px; - font-size: 0.8rem; - color: var(--color-primary); - margin-bottom: 0.5rem; -} - -.credential-details { - font-size: 0.9rem; - color: var(--color-text-secondary); - margin-top: 0.25rem; -} - -.search-credentials-status { - font-size: 0.85rem; - padding: 0.25rem 0.5rem; - border-radius: 12px; - display: inline-block; - width: fit-content; - margin-top: 0.5rem; -} - -.search-credentials-status.has-api { - background: rgba(29, 185, 84, 0.2); - color: #1db954; -} - -.search-credentials-status.no-api { - background: rgba(255, 85, 85, 0.2); - color: #ff5555; -} - -.credential-actions { - display: flex; - gap: 0.75rem; -} - -.credential-actions button { - background-color: #222222; - border: none; - cursor: pointer; - padding: 0.6rem; - border-radius: 8px; - transition: background-color 0.2s ease, transform 0.2s ease; - display: flex; - align-items: center; - justify-content: center; - font-size: 0.9rem; - font-weight: 500; - color: white; -} - -.credential-actions button img { - width: 20px; - height: 20px; - filter: brightness(0) invert(1); -} - -.credential-actions button:hover { - transform: translateY(-2px); -} - -.credential-actions button.delete-btn { - color: #ff5555; -} - -.credential-actions button.delete-btn:hover { - background-color: rgba(192, 57, 43, 0.2); -} - -.credential-actions button.edit-btn:hover { - background-color: rgba(52, 152, 219, 0.2); -} - -/* Credentials Form */ -.credentials-form { - background: #181818; - padding: 1.5rem; - border-radius: 12px; - box-shadow: 0 4px 8px rgba(0,0,0,0.15); - transition: transform 0.3s ease; -} - -.credentials-form:hover { - transform: translateY(-2px); -} - -#serviceFields, #searchFields { - margin: 1.5rem 0; -} - -.form-group { - margin-bottom: 1.2rem; -} - -.form-group label { - display: block; - margin-bottom: 0.5rem; - color: #b3b3b3; -} - -.form-group input { - width: 100%; - padding: 0.8rem; - background: #2a2a2a; - border: 1px solid #404040; - border-radius: 8px; - color: #ffffff; - transition: border-color 0.3s ease; -} - -.form-group input:focus { - outline: none; - border-color: #1db954; -} - -.save-btn { - background: #1db954; - color: #ffffff; - padding: 0.8rem 1.5rem; - border: none; - border-radius: 25px; - cursor: pointer; - font-weight: 500; - transition: background 0.3s ease, transform 0.2s ease; - margin-top: 1.5rem; -} - -.save-btn:hover { - background: #1ed760; - transform: translateY(-2px); -} - -/* Error Messages - Hidden by default */ -#configError { - background-color: rgba(192, 57, 43, 0.1); - color: #ff5555; - margin-top: 1.5rem; - padding: 1rem; - border-radius: 8px; - border-left: 3px solid #ff5555; - font-size: 0.9rem; - display: none; -} - -/* Show the messages when they have content */ -#configError:not(:empty) { - display: block; -} - -/* Success Messages - Hidden by default */ -#configSuccess { - background-color: rgba(46, 204, 113, 0.1); - color: #1db954; - margin-top: 1.5rem; - padding: 1rem; - border-radius: 8px; - border-left: 3px solid #1db954; - font-size: 0.9rem; - font-weight: 500; - display: none; -} - -/* Show the messages when they have content */ -#configSuccess:not(:empty) { - display: block; -} - -/* MOBILE RESPONSIVENESS */ -@media (max-width: 768px) { - .config-container { - padding: 1.5rem 1rem; - } - - .config-header { - flex-direction: column; - gap: 1rem; - align-items: flex-start; - } - - /* Increase touch target sizes for buttons and selects */ - .form-select { - padding: 0.8rem 2rem 0.8rem 1rem; - font-size: 0.9rem; - } - - .service-tabs { - flex-wrap: wrap; - } - - .tab-button { - flex: 1 1 auto; - text-align: center; - margin-bottom: 0.5rem; - } - - .credential-item { - flex-direction: column; - align-items: flex-start; - gap: 0.75rem; - } - - .credential-info { - width: 100%; - margin-bottom: 1rem; - } - - .credential-actions { - width: 100%; - display: flex; - justify-content: flex-end; - flex-wrap: wrap; - gap: 0.5rem; - } - - /* Adjust toggle switch size for better touch support */ - .switch { - width: 52px; - height: 26px; - } - - .slider:before { - height: 20px; - width: 20px; - } -} - -@media (max-width: 480px) { - .config-container { - padding: 1rem; - } - - .account-config, - .credentials-list, - .credentials-form { - padding: 1rem; - border-radius: 8px; - } - - .section-title { - font-size: 1.3rem; - } - - .config-item label { - font-size: 0.95rem; - } - - .form-select, - .form-input { - padding: 0.7rem 1.8rem 0.7rem 0.8rem; - font-size: 0.9rem; - } - - .save-btn { - width: 100%; - padding: 0.7rem; - font-size: 0.9rem; - } - - /* Position floating icons a bit closer to the edges on small screens */ - .back-button.floating-icon { - width: 60px; - height: 60px; - left: 16px; - bottom: 16px; - } - - .back-button.floating-icon img { - width: 28px; - height: 28px; - } -} - -/* Format help styles */ -.format-help { - display: flex; - margin-top: 8px; - align-items: center; -} - -.format-selector { - width: 100%; - padding: 6px; - border-radius: 4px; - border: 1px solid #404040; - background-color: #2a2a2a; - color: #ffffff; -} - -.format-selector:focus { - outline: none; - border-color: #1db954; - box-shadow: 0 0 0 2px rgba(29, 185, 84, 0.2); -} - -.setting-description { - margin-top: 8px; - font-size: 0.9em; - color: #b3b3b3; -} - -/* Copy notification styles */ -#copyNotificationContainer { - position: fixed; - bottom: 20px; - left: 0; - right: 0; - display: flex; - flex-direction: column; - align-items: center; - z-index: 1000; - pointer-events: none; -} - -.copy-notification { - background-color: rgba(0, 0, 0, 0.8); - color: white; - padding: 10px 20px; - border-radius: 4px; - margin-top: 5px; - opacity: 0; - transform: translateY(20px); - transition: opacity 0.3s, transform 0.3s; - max-width: 90%; - text-align: center; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); -} - -.copy-notification.show { - opacity: 1; - transform: translateY(0); -} - -/* Credentials List Wrapper */ -.credentials-list-wrapper { - background: #181818; /* Same as original .credentials-list.card */ - border-radius: 12px; - box-shadow: 0 4px 8px rgba(0,0,0,0.15); - padding: 1.5rem; /* Add padding here if you want it around the whole block */ - margin-bottom: 2rem; -} - -/* Styling for the Add New Account button to make it look like a list item */ -.add-account-item { - margin-top: 0.75rem; /* Space above the add button if there are items */ -} - -.btn-add-account-styled { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - padding: 1.25rem; - background-color: #1db954; /* Green background */ - color: #ffffff; - border: none; - border-radius: 8px; /* Same as credential-item */ - font-size: 1.1rem; /* Similar to credential-name */ - font-weight: 600; - cursor: pointer; - transition: background-color 0.3s ease, transform 0.2s ease; - text-align: center; - opacity: 1; /* Ensure it's not transparent by default */ -} - -.btn-add-account-styled img { - width: 20px; /* Adjust as needed */ - height: 20px; /* Adjust as needed */ - margin-right: 10px; - filter: brightness(0) invert(1); /* Make icon white if it's not already */ -} - -.btn-add-account-styled:hover { - background-color: #1aa34a; /* Darker green on hover */ - transform: translateY(-1px); -} - -/* New styles for the icon-based cancel button */ -.btn-cancel-icon { - background-color: #c0392b !important; /* Red background */ - padding: 0.6rem !important; /* Adjust padding for icon */ - width: auto; /* Allow button to size to icon */ - min-width: 40px; /* Ensure a minimum touch target size */ - height: 40px; /* Ensure a minimum touch target size */ - align-items: center; - justify-content: center; - border-radius: 50% !important; /* Make it circular */ - opacity: 1 !important; /* Ensure it's always visible when its container is */ - visibility: visible !important; /* Ensure it's not hidden by visibility property */ -} - -.btn-cancel-icon img { - width: 16px; /* Adjust icon size as needed */ - height: 16px; - filter: brightness(0) invert(1); /* Make icon white */ -} - -.btn-cancel-icon:hover { - background-color: #e74c3c !important; /* Lighter red on hover */ - transform: translateY(-2px) scale(1.05); -} - -/* Watch Options Config Section */ -.watch-options-config { - background: #181818; - padding: 1.5rem; - border-radius: 12px; - margin-bottom: 2rem; - box-shadow: 0 4px 8px rgba(0,0,0,0.15); - transition: transform 0.3s ease; -} - -.watch-options-config:hover { - transform: translateY(-2px); -} - -/* New Checklist Styles */ -.checklist-container { - background: #2a2a2a; - border: 1px solid #404040; - border-radius: 8px; - padding: 0.8rem; - margin-top: 0.5rem; -} - -.checklist-item { - display: flex; - align-items: center; - margin-bottom: 0.5rem; - padding: 0.3rem 0; -} - -.checklist-item:last-child { - margin-bottom: 0; -} - -.checklist-item input[type="checkbox"] { - margin-right: 0.8rem; - width: 18px; /* Custom size */ - height: 18px; /* Custom size */ - cursor: pointer; - accent-color: #1db954; /* Modern way to color checkboxes */ -} - -.checklist-item label { - color: #ffffff; /* Ensure label text is white */ - font-size: 0.95rem; - cursor: pointer; - /* Reset some global label styles if they interfere */ - display: inline; - margin-bottom: 0; -} - -/* Urgent Warning Message Style */ -.urgent-warning-message { - background-color: rgba(255, 165, 0, 0.1); /* Orange/Amber background */ - border: 1px solid #FFA500; /* Orange/Amber border */ - color: #FFA500; /* Orange/Amber text */ - padding: 1rem; - border-radius: 8px; - display: flex; /* Use flex to align icon and text */ - align-items: center; /* Vertically align icon and text */ - margin-top: 1rem; - margin-bottom: 1rem; -} - -.urgent-warning-message .warning-icon { - margin-right: 0.75rem; /* Space between icon and text */ - min-width: 24px; /* Ensure icon doesn't shrink too much */ - color: #FFA500; /* Match icon color to text/border */ -} - -/* Existing info-message style - ensure it doesn't conflict or adjust if needed */ -.info-message { - background-color: rgba(0, 123, 255, 0.1); - border: 1px solid #007bff; - color: #007bff; - padding: 1rem; - border-radius: 8px; - margin-top: 1rem; - margin-bottom: 1rem; -} - -/* Version text styling */ -.version-text { - font-size: 0.9rem; - color: #888; /* Light grey color */ - margin-left: auto; /* Push it to the right */ - padding-top: 0.5rem; /* Align with title better */ -} \ No newline at end of file diff --git a/static/css/history/history.css b/static/css/history/history.css deleted file mode 100644 index 5267b84..0000000 --- a/static/css/history/history.css +++ /dev/null @@ -1,121 +0,0 @@ -body { - font-family: sans-serif; - margin: 0; - background-color: #121212; - color: #e0e0e0; -} - -.container { - padding: 20px; - max-width: 1200px; - margin: auto; -} - -h1 { - color: #1DB954; /* Spotify Green */ - text-align: center; -} - -table { - width: 100%; - border-collapse: collapse; - margin-top: 20px; - background-color: #1e1e1e; -} - -th, td { - border: 1px solid #333; - padding: 10px 12px; - text-align: left; -} - -th { - background-color: #282828; - cursor: pointer; -} - -tr:nth-child(even) { - background-color: #222; -} - -.pagination { - margin-top: 20px; - text-align: center; -} - -.pagination button, .pagination select { - padding: 8px 12px; - margin: 0 5px; - background-color: #1DB954; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; -} - -.pagination button:disabled { - background-color: #555; - cursor: not-allowed; -} - -.filters { - margin-bottom: 20px; - display: flex; - gap: 15px; - align-items: center; -} - -.filters label, .filters select, .filters input { - margin-right: 5px; -} - -.filters select, .filters input { - padding: 8px; - background-color: #282828; - color: #e0e0e0; - border: 1px solid #333; - border-radius: 4px; -} - -.status-COMPLETED { color: #1DB954; font-weight: bold; } -.status-ERROR { color: #FF4136; font-weight: bold; } -.status-CANCELLED { color: #AAAAAA; } - -.error-message-toggle { - cursor: pointer; - color: #FF4136; /* Red for error indicator */ - text-decoration: underline; -} - -.error-details { - display: none; /* Hidden by default */ - white-space: pre-wrap; /* Preserve formatting */ - background-color: #303030; - padding: 5px; - margin-top: 5px; - border-radius: 3px; - font-size: 0.9em; -} - -/* Styling for the Details icon button in the table */ -.details-btn { - background-color: transparent; /* Or a subtle color like #282828 */ - border: none; - border-radius: 50%; /* Make it circular */ - padding: 5px; /* Adjust padding to control size */ - cursor: pointer; - display: inline-flex; /* Important for aligning the image */ - align-items: center; - justify-content: center; - transition: background-color 0.2s ease; -} - -.details-btn img { - width: 16px; /* Icon size */ - height: 16px; - filter: invert(1); /* Make icon white if it's dark, adjust if needed */ -} - -.details-btn:hover { - background-color: #333; /* Darker on hover */ -} \ No newline at end of file diff --git a/static/css/main/base.css b/static/css/main/base.css deleted file mode 100644 index 89cab1e..0000000 --- a/static/css/main/base.css +++ /dev/null @@ -1,530 +0,0 @@ -/* Spotizerr Base Styles - Provides consistent styling across all pages */ - -/* Reset and base styles */ -* { - margin: 0; - padding: 0; - box-sizing: border-box; - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; -} - -:root { - /* Main colors */ - --color-background: #121212; - --color-background-gradient: linear-gradient(135deg, #121212, #1e1e1e); - --color-surface: #1c1c1c; - --color-surface-hover: #2a2a2a; - --color-border: #2a2a2a; - - /* Text colors */ - --color-text-primary: #ffffff; - --color-text-secondary: #b3b3b3; - --color-text-tertiary: #757575; - - /* Brand colors */ - --color-primary: #1db954; - --color-primary-hover: #17a44b; - --color-error: #c0392b; - --color-success: #2ecc71; - /* Adding accent green if not present, or ensuring it is */ - --color-accent-green: #22c55e; /* Example: A Tailwind-like green */ - --color-accent-green-dark: #16a34a; /* Darker shade for hover */ - - /* Spacing */ - --space-xs: 0.25rem; - --space-sm: 0.5rem; - --space-md: 1rem; - --space-lg: 1.5rem; - --space-xl: 2rem; - - /* Shadow */ - --shadow-sm: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); - --shadow-md: 0 4px 6px rgba(0,0,0,0.1); - --shadow-lg: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23); - - /* Border radius */ - --radius-sm: 4px; - --radius-md: 8px; - --radius-lg: 12px; - --radius-round: 50%; -} - -body { - background: var(--color-background-gradient); - color: var(--color-text-primary); - min-height: 100vh; - line-height: 1.4; -} - -a { - color: inherit; - text-decoration: none; - transition: color 0.2s ease; -} - -a:hover, a:focus { - color: var(--color-primary); - text-decoration: underline; -} - -/* Container for main content */ -.app-container { - max-width: 1200px; - margin: 0 auto; - padding: var(--space-lg); - position: relative; - z-index: 1; -} - -/* Card component */ -.card { - background: var(--color-surface); - border-radius: var(--radius-md); - padding: var(--space-md); - box-shadow: var(--shadow-sm); - transition: transform 0.2s ease, box-shadow 0.2s ease; -} - -.card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-md); -} - -/* Button variants */ -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - padding: var(--space-sm) var(--space-md); - border: none; - border-radius: var(--radius-sm); - cursor: pointer; - font-weight: 500; - transition: background-color 0.2s ease, transform 0.2s ease; - background-color: var(--color-surface-hover); - color: var(--color-text-primary); -} - -.btn:hover { - transform: translateY(-1px); -} - -.btn:active { - transform: scale(0.98); -} - -.btn-primary { - background-color: var(--color-primary); - color: white; -} - -.btn-primary:hover { - background-color: var(--color-primary-hover); -} - -/* Icon button */ -.btn-icon { - width: 40px; - height: 40px; - border-radius: var(--radius-round); - padding: 0; - display: inline-flex; - align-items: center; - justify-content: center; - background: transparent; - border: none; - cursor: pointer; - transition: background-color 0.2s ease; -} - -.btn-icon:hover { - background-color: var(--color-surface-hover); -} - -.btn-icon img { - width: 20px; - height: 20px; - filter: brightness(0) invert(1); -} - -/* Queue icon styling */ -.queue-icon { - background-color: transparent; - transition: background-color 0.2s ease, transform 0.2s ease; -} - -.queue-icon:hover { - background-color: var(--color-surface-hover); -} - -/* General styles for floating action buttons (FABs) */ -.floating-icon { - position: fixed; - z-index: 1000; /* Base z-index, can be overridden */ - border-radius: 50%; - box-shadow: 0 2px 5px rgba(0,0,0,0.2); - display: flex; - align-items: center; - justify-content: center; - width: 48px; /* Standard size */ - height: 48px; /* Standard size */ - background-color: #282828; /* Dark background */ - transition: background-color 0.3s ease, transform 0.2s ease; - text-decoration: none !important; /* Ensure no underline for tags */ -} - -.floating-icon:hover { - background-color: #333; /* Slightly lighter on hover */ - transform: scale(1.05); -} - -.floating-icon:active { - transform: scale(0.98); -} - -.floating-icon img { - width: 24px; - height: 24px; - filter: invert(1); /* White icon */ - margin: 0; /* Reset any margin if inherited */ -} - -/* Home button */ -.home-btn { - background-color: transparent; - border: none; - cursor: pointer; - padding: 0; -} - -.home-btn img { - width: 32px; - height: 32px; - filter: invert(1); - transition: transform 0.2s ease; -} - -.home-btn:hover img { - transform: scale(1.05); -} - -.home-btn:active img { - transform: scale(0.98); -} - -/* Styles for buttons that are specifically floating icons (like home button when it's a FAB) */ -/* This ensures that if a .home-btn also has .floating-icon, it gets the correct FAB styling. */ -.home-btn.floating-icon, -.settings-icon.floating-icon, /* If settings button is an or - - - - - - - -
-
Loading...
-
- - - - -
- History - - - - - - - - - - \ No newline at end of file diff --git a/static/html/artist.html b/static/html/artist.html deleted file mode 100644 index b732907..0000000 --- a/static/html/artist.html +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - Artist Viewer - Spotizerr - - - - - - - -
- - - - - - - -
-
Loading...
-
- -
- - - - History - - - - - - - - - - diff --git a/static/html/config.html b/static/html/config.html deleted file mode 100644 index aef6cbf..0000000 --- a/static/html/config.html +++ /dev/null @@ -1,412 +0,0 @@ - - - - - - Configuration - Spotizerr - - - - - - - -
-
-
-

Configuration

- Set on build -
- - - -
-

Watch Options

-
- - -
- Enable or disable the entire watch feature (monitoring playlists and artists for new content). -
-
- -
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- Select which album groups to monitor on watched artist pages. -
-
-
- - -
- How often to check watched items for updates (e.g., new playlist tracks, new artist albums). -
-
-
- -
-

Accounts configuration

- - -
-

Global Spotify API Credentials

-
- - -
-
- - -
-
- -
-
-
- - -
-
- - -
- - -
-
- - -
- -
- -
-

Add New Spotify Account

-
-
- - - - - - - -
-
-
-
-
-
-
-
- - - - History - - - Back - - - - - - - - - - diff --git a/static/html/history.html b/static/html/history.html deleted file mode 100644 index 044ae57..0000000 --- a/static/html/history.html +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - Download History - - - - - - - - - -
-

Download History

- -
- - - - - -
- - - - - - - - - - - - - - - - - - -
NameArtistTypeServiceQualityStatusDate AddedDate Completed/EndedDetails
- -
- - - - Home - - - - - - - - - \ No newline at end of file diff --git a/static/html/main.html b/static/html/main.html deleted file mode 100755 index 61fba44..0000000 --- a/static/html/main.html +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - Spotizerr - - - - - - - - -
-
-
- - -
- - -
- - -
- - -
-
- Music -

Search for music

-

Find and download your favorite tracks, albums, playlists or artists

-
-
- - - -
- - - - History - - - Settings - - - - - - - - - diff --git a/static/html/playlist.html b/static/html/playlist.html deleted file mode 100644 index b4ea267..0000000 --- a/static/html/playlist.html +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - Playlist Viewer - Spotizerr - - - - - - - -
- - - - - -
-
Loading...
-
- -
- - - - History - - - - - - - - - - diff --git a/static/html/track.html b/static/html/track.html deleted file mode 100644 index 5ec8347..0000000 --- a/static/html/track.html +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - Track Viewer - Spotizerr - - - - - - - -
- - - -
-
Loading...
-
- -
- - - - History - - - - - - - - - - - diff --git a/static/html/watch.html b/static/html/watch.html deleted file mode 100644 index 687b571..0000000 --- a/static/html/watch.html +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - Watched Items - Spotizerr - - - - - - - - -
-
-

Watched Artists & Playlists

- -
- -
- -
- - - - -
- - - - History - - - Home - - - - - - - - - \ No newline at end of file