diff --git a/README_images/details.png b/README_images/details.png index 74fa27a9..61e2ebaa 100644 Binary files a/README_images/details.png and b/README_images/details.png differ diff --git a/README_images/search.png b/README_images/search.png index e320c9ca..291dfd77 100644 Binary files a/README_images/search.png and b/README_images/search.png differ diff --git a/app.py b/app.py index 41154e78..f154a1c8 100644 --- a/app.py +++ b/app.py @@ -3,8 +3,10 @@ import logging import io, re, os import sqlite3 +import time +from datetime import datetime, timedelta from functools import wraps -from flask import Flask, request, jsonify, send_file, send_from_directory +from flask import Flask, request, jsonify, send_file, send_from_directory, session from flask_cors import CORS from flask_socketio import SocketIO, emit from werkzeug.middleware.proxy_fix import ProxyFix @@ -58,6 +60,63 @@ socketio = SocketIO( ws_manager.init_app(app, socketio) logger.info(f"Flask-SocketIO initialized with async_mode='{async_mode}'") +# Rate limiting for login attempts +# Structure: {username: {'count': int, 'lockout_until': datetime}} +failed_login_attempts: typing.Dict[str, typing.Dict[str, typing.Any]] = {} +MAX_LOGIN_ATTEMPTS = 10 +LOCKOUT_DURATION_MINUTES = 30 + +def cleanup_old_lockouts() -> None: + """Remove expired lockout entries to prevent memory buildup.""" + current_time = datetime.now() + expired_users = [ + username for username, data in failed_login_attempts.items() + if 'lockout_until' in data and data['lockout_until'] < current_time + ] + for username in expired_users: + logger.info(f"Lockout expired for user: {username}") + del failed_login_attempts[username] + +def is_account_locked(username: str) -> bool: + """Check if an account is currently locked due to failed login attempts.""" + cleanup_old_lockouts() + + if username not in failed_login_attempts: + return False + + lockout_until = failed_login_attempts[username].get('lockout_until') + if lockout_until and datetime.now() < lockout_until: + return True + + return False + +def record_failed_login(username: str, ip_address: str) -> bool: + """ + Record a failed login attempt and lock account if threshold is reached. + Returns True if account is now locked, False otherwise. + """ + if username not in failed_login_attempts: + failed_login_attempts[username] = {'count': 0} + + failed_login_attempts[username]['count'] += 1 + count = failed_login_attempts[username]['count'] + + logger.warning(f"Failed login attempt {count}/{MAX_LOGIN_ATTEMPTS} for user '{username}' from IP {ip_address}") + + if count >= MAX_LOGIN_ATTEMPTS: + lockout_until = datetime.now() + timedelta(minutes=LOCKOUT_DURATION_MINUTES) + failed_login_attempts[username]['lockout_until'] = lockout_until + logger.warning(f"Account locked for user '{username}' until {lockout_until.strftime('%Y-%m-%d %H:%M:%S')} due to {count} failed login attempts") + return True + + return False + +def clear_failed_logins(username: str) -> None: + """Clear failed login attempts for a user after successful login.""" + if username in failed_login_attempts: + del failed_login_attempts[username] + logger.debug(f"Cleared failed login attempts for user: {username}") + # Enable CORS in development mode for local frontend development if DEBUG: CORS(app, resources={ @@ -69,6 +128,17 @@ if DEBUG: } }) +# Custom log filter to exclude routine status endpoint polling +class StatusEndpointFilter(logging.Filter): + """Filter out routine status endpoint requests to reduce log noise.""" + def filter(self, record): + # Exclude GET /api/status and GET /request/api/status requests + if hasattr(record, 'getMessage'): + message = record.getMessage() + if 'GET /api/status' in message or 'GET /request/api/status' in message: + return False + return True + # Flask logger app.logger.handlers = logger.handlers app.logger.setLevel(logger.level) @@ -76,14 +146,35 @@ app.logger.setLevel(logger.level) werkzeug_logger = logging.getLogger('werkzeug') werkzeug_logger.handlers = logger.handlers werkzeug_logger.setLevel(logger.level) +# Add filter to suppress routine status endpoint polling logs +werkzeug_logger.addFilter(StatusEndpointFilter()) # Set up authentication defaults # The secret key will reset every time we restart, which will # require users to authenticate again + +# Auto-detect HTTPS for secure cookies +# Can be overridden with SESSION_COOKIE_SECURE environment variable +session_cookie_secure_env = os.getenv('SESSION_COOKIE_SECURE', 'auto').lower() +if session_cookie_secure_env == 'auto': + # Auto-detect: check if we're behind a reverse proxy with HTTPS + # This will be determined per-request, but default to False for local HTTP + SESSION_COOKIE_SECURE = False +elif session_cookie_secure_env in ['true', 'yes', '1']: + SESSION_COOKIE_SECURE = True +else: + SESSION_COOKIE_SECURE = False + app.config.update( - SECRET_KEY = os.urandom(64) + SECRET_KEY = os.urandom(64), + SESSION_COOKIE_HTTPONLY = True, + SESSION_COOKIE_SAMESITE = 'Lax', + SESSION_COOKIE_SECURE = SESSION_COOKIE_SECURE, + PERMANENT_SESSION_LIFETIME = 604800 # 7 days in seconds ) +logger.info(f"Session cookie secure setting: {SESSION_COOKIE_SECURE} (from env: {session_cookie_secure_env})") + def login_required(f): @wraps(f) def decorated_function(*args, **kwargs): @@ -91,15 +182,16 @@ def login_required(f): # path, return a server error if CWA_DB_PATH is not None and not os.path.isfile(CWA_DB_PATH): logger.error(f"CWA_DB_PATH is set to {CWA_DB_PATH} but this is not a valid path") - return Response("Internal Server Error", 500) - if not authenticate(): - return Response( - response="Unauthorized", - status=401, - headers={ - "WWW-Authenticate": 'Basic realm="Calibre-Web-Automated-Book-Downloader"', - }, - ) + return jsonify({"error": "Internal Server Error"}), 500 + + # If no database is configured, allow access + if not CWA_DB_PATH: + return f(*args, **kwargs) + + # Check if user has a valid session + if 'user_id' not in session: + return jsonify({"error": "Unauthorized"}), 401 + return f(*args, **kwargs) return decorated_function @@ -151,10 +243,10 @@ def serve_frontend_assets(filename: str) -> Response: return send_from_directory(os.path.join(app.root_path, 'frontend-dist', 'assets'), filename) @app.route('/') -@login_required def index() -> Response: """ Serve the React frontend application. + Authentication is handled by the React app itself. """ return send_from_directory(os.path.join(app.root_path, 'frontend-dist'), 'index.html') @@ -566,58 +658,165 @@ def internal_error(error: Exception) -> Union[Response, Tuple[Response, int]]: logger.error_trace(f"500 error: {error}") return jsonify({"error": "Internal server error"}), 500 -def authenticate() -> bool: +@app.route('/api/auth/login', methods=['POST']) +def api_login() -> Union[Response, Tuple[Response, int]]: """ - Helper function that validates Basic credentials - against a Calibre-Web app.db SQLite database - - Database structure: - - Table 'user' with columns: 'name' (username), 'password' + Login endpoint that validates credentials and creates a session. + Includes rate limiting: 10 failed attempts = 30 minute lockout. + + Request Body: + username (str): Username + password (str): Password + remember_me (bool): Whether to extend session duration + + Returns: + flask.Response: JSON with success status or error message. """ - - # If the database doesn't exist, the user is always authenticated - if not CWA_DB_PATH: - return True - - # If no authorization object exists, return false to prompt - # a request to the user - if not request.authorization: - return False - - username = request.authorization.get("username") - password = request.authorization.get("password") - - # Validate credentials against database try: - # Open database in true read-only mode to avoid journal/WAL writes on RO mounts - db_path = os.fspath(CWA_DB_PATH) - db_uri = f"file:{db_path}?mode=ro&immutable=1" - conn = sqlite3.connect(db_uri, uri=True) - cur = conn.cursor() - cur.execute("SELECT password FROM user WHERE name = ?", (username,)) - row = cur.fetchone() - conn.close() - - # Check if user exists and password is correct - if not row or not row[0] or not check_password_hash(row[0], password): - logger.error("User not found or password check failed") - return False - + # Get client IP address (handles reverse proxy forwarding) + ip_address = request.headers.get('X-Forwarded-For', request.remote_addr) + if ip_address and ',' in ip_address: + # X-Forwarded-For can contain multiple IPs, take the first one + ip_address = ip_address.split(',')[0].strip() + + data = request.get_json() + if not data: + return jsonify({"error": "No data provided"}), 400 + + username = data.get('username', '').strip() + password = data.get('password', '') + remember_me = data.get('remember_me', False) + + if not username or not password: + return jsonify({"error": "Username and password are required"}), 400 + + # Check if account is locked due to failed login attempts + if is_account_locked(username): + lockout_until = failed_login_attempts[username].get('lockout_until') + remaining_time = (lockout_until - datetime.now()).total_seconds() / 60 + logger.warning(f"Login attempt blocked for locked account '{username}' from IP {ip_address}") + return jsonify({ + "error": f"Account temporarily locked due to multiple failed login attempts. Try again in {int(remaining_time)} minutes." + }), 429 + + # If the database doesn't exist, authentication always succeeds + if not CWA_DB_PATH: + session['user_id'] = username + session.permanent = remember_me + clear_failed_logins(username) + logger.info(f"Login successful for user '{username}' from IP {ip_address} (no DB configured)") + return jsonify({"success": True}) + + # If the CWA_DB_PATH variable exists, but isn't a valid path, return error + if not os.path.isfile(CWA_DB_PATH): + logger.error(f"CWA_DB_PATH is set to {CWA_DB_PATH} but this is not a valid path") + return jsonify({"error": "Database configuration error"}), 500 + + # Validate credentials against database + try: + db_path = os.fspath(CWA_DB_PATH) + db_uri = f"file:{db_path}?mode=ro&immutable=1" + conn = sqlite3.connect(db_uri, uri=True) + cur = conn.cursor() + cur.execute("SELECT password FROM user WHERE name = ?", (username,)) + row = cur.fetchone() + conn.close() + + # Check if user exists and password is correct + if not row or not row[0] or not check_password_hash(row[0], password): + # Record failed login attempt + is_now_locked = record_failed_login(username, ip_address) + + if is_now_locked: + return jsonify({ + "error": f"Account locked due to {MAX_LOGIN_ATTEMPTS} failed login attempts. Try again in {LOCKOUT_DURATION_MINUTES} minutes." + }), 429 + else: + attempts_remaining = MAX_LOGIN_ATTEMPTS - failed_login_attempts[username]['count'] + # Only show attempts remaining when 5 or fewer attempts remain (after 6+ failed attempts) + if attempts_remaining <= 5: + return jsonify({ + "error": f"Invalid username or password. {attempts_remaining} attempts remaining." + }), 401 + else: + return jsonify({ + "error": "Invalid username or password." + }), 401 + + # Successful authentication - create session and clear failed attempts + session['user_id'] = username + session.permanent = remember_me + clear_failed_logins(username) + logger.info(f"Login successful for user '{username}' from IP {ip_address} (remember_me={remember_me})") + return jsonify({"success": True}) + + except Exception as e: + logger.error_trace(f"Database error during login: {e}") + return jsonify({"error": "Authentication system error"}), 500 + except Exception as e: - logger.error_trace(f"CWA DB or authentication send_from_directory: {e}") - return False + logger.error_trace(f"Login error: {e}") + return jsonify({"error": "Login failed"}), 500 - logger.info(f"Authentication successful for user {username}") - return True +@app.route('/api/auth/logout', methods=['POST']) +def api_logout() -> Union[Response, Tuple[Response, int]]: + """ + Logout endpoint that clears the session. + + Returns: + flask.Response: JSON with success status. + """ + try: + # Get client IP address (handles reverse proxy forwarding) + ip_address = request.headers.get('X-Forwarded-For', request.remote_addr) + if ip_address and ',' in ip_address: + ip_address = ip_address.split(',')[0].strip() + + username = session.get('user_id', 'unknown') + session.clear() + logger.info(f"Logout successful for user '{username}' from IP {ip_address}") + return jsonify({"success": True}) + except Exception as e: + logger.error_trace(f"Logout error: {e}") + return jsonify({"error": "Logout failed"}), 500 + +@app.route('/api/auth/check', methods=['GET']) +def api_auth_check() -> Union[Response, Tuple[Response, int]]: + """ + Check if user has a valid session. + + Returns: + flask.Response: JSON with authentication status and whether auth is required. + """ + try: + # If no database is configured, authentication is not required + if not CWA_DB_PATH: + return jsonify({ + "authenticated": True, + "auth_required": False + }) + + # Check if user has a valid session + is_authenticated = 'user_id' in session + return jsonify({ + "authenticated": is_authenticated, + "auth_required": True + }) + except Exception as e: + logger.error_trace(f"Auth check error: {e}") + return jsonify({ + "authenticated": False, + "auth_required": True + }) # Catch-all route for React Router (must be last) # This handles client-side routing by serving index.html for any unmatched routes @app.route('/') -@login_required def catch_all(path: str) -> Response: """ Serve the React app for any route not matched by API endpoints. This allows React Router to handle client-side routing. + Authentication is handled by the React app itself. """ # If the request is for an API endpoint or static file, let it 404 if path.startswith('api/') or path.startswith('assets/'): diff --git a/env.py b/env.py index e4fc6289..c67d1673 100644 --- a/env.py +++ b/env.py @@ -4,6 +4,13 @@ from pathlib import Path def string_to_bool(s: str) -> bool: return s.lower() in ["true", "yes", "1", "y"] +# Authentication and session settings +# SESSION_COOKIE_SECURE: Controls whether session cookies are marked as secure (HTTPS only) +# - 'auto' (default): Uses False for local development, can be overridden +# - 'true'/'yes'/'1': Always use secure cookies (recommended for production with HTTPS) +# - 'false'/'no'/'0': Never use secure cookies (only for local HTTP) +SESSION_COOKIE_SECURE_ENV = os.getenv("SESSION_COOKIE_SECURE", "auto") + CWA_DB = os.getenv("CWA_DB_PATH") CWA_DB_PATH = Path(CWA_DB) if CWA_DB else None LOG_ROOT = Path(os.getenv("LOG_ROOT", "/var/log/")) diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 3feedd6e..2841f89c 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", + "react-router-dom": "^6.30.2", "socket.io-client": "^4.7.5" }, "devDependencies": { @@ -68,6 +69,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -827,6 +829,15 @@ "node": ">=14" } }, + "node_modules/@remix-run/router": { + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz", + "integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1206,6 +1217,7 @@ "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1223,6 +1235,7 @@ "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -1424,6 +1437,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -1987,6 +2001,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -2307,6 +2322,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2476,6 +2492,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2488,6 +2505,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -2506,6 +2524,38 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz", + "integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz", + "integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.1", + "react-router": "6.30.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -3040,6 +3090,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/src/frontend/package.json b/src/frontend/package.json index 6cb866b0..d1309c74 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -12,6 +12,7 @@ "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", + "react-router-dom": "^6.30.2", "socket.io-client": "^4.7.5" }, "devDependencies": { diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 88d5bb0f..3b4c0a01 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,6 +1,14 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { Book, StatusData, ButtonStateInfo, AppConfig } from './types'; -import { searchBooks, getBookInfo, downloadBook, cancelDownload, clearCompleted, getConfig } from './services/api'; +import { useState, useEffect, useCallback, useRef, CSSProperties } from 'react'; +import { Navigate, Route, Routes, useNavigate } from 'react-router-dom'; +import { + Book, + StatusData, + ButtonStateInfo, + AppConfig, + LoginCredentials, + AdvancedFilterState, +} from './types'; +import { searchBooks, getBookInfo, downloadBook, cancelDownload, clearCompleted, getConfig, login, logout, checkAuth, AuthenticationError } from './services/api'; import { useToast } from './hooks/useToast'; import { useRealtimeStatus } from './hooks/useRealtimeStatus'; import { Header } from './components/Header'; @@ -11,10 +19,22 @@ import { DetailsModal } from './components/DetailsModal'; import { DownloadsSidebar } from './components/DownloadsSidebar'; import { ToastContainer } from './components/ToastContainer'; import { Footer } from './components/Footer'; +import { LoginPage } from './pages/LoginPage'; import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages'; +import { getLanguageFilterValues, LANGUAGE_OPTION_DEFAULT } from './utils/languageFilters'; import './styles.css'; +const DEFAULT_FORMAT_SELECTION = DEFAULT_SUPPORTED_FORMATS.filter(format => format !== 'pdf'); + function App() { + // Authentication state + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [authRequired, setAuthRequired] = useState(true); + const [authChecked, setAuthChecked] = useState(false); + const [loginError, setLoginError] = useState(null); + const [isLoggingIn, setIsLoggingIn] = useState(false); + const navigate = useNavigate(); + const [books, setBooks] = useState([]); const [selectedBook, setSelectedBook] = useState(null); const [isSearching, setIsSearching] = useState(false); @@ -22,16 +42,20 @@ function App() { const [searchInput, setSearchInput] = useState(''); const [showAdvanced, setShowAdvanced] = useState(false); const [downloadsSidebarOpen, setDownloadsSidebarOpen] = useState(false); - const [advancedFilters, setAdvancedFilters] = useState({ + const [lastSearchQuery, setLastSearchQuery] = useState(''); + const [advancedFilters, setAdvancedFilters] = useState({ isbn: '', author: '', title: '', - lang: 'all', + lang: [LANGUAGE_OPTION_DEFAULT], sort: '', content: '', - formats: [] as string[], + formats: DEFAULT_FORMAT_SELECTION, }); const { toasts, showToast } = useToast(); + const updateAdvancedFilters = useCallback((updates: Partial) => { + setAdvancedFilters(prev => ({ ...prev, ...updates })); + }, []); // Determine WebSocket URL based on current location // In production, use the same origin as the page; in dev, use localhost @@ -128,6 +152,68 @@ function App() { // Track previous status for change detection const prevStatusRef = useRef({}); + // Check authentication on mount + useEffect(() => { + const verifyAuth = async () => { + try { + const response = await checkAuth(); + const authenticated = response.authenticated || false; + const authIsRequired = response.auth_required !== false; // Default to true if undefined + + setAuthRequired(authIsRequired); + setIsAuthenticated(authenticated); + } catch (error) { + console.error('Auth check failed:', error); + // On error, assume auth is required and user is not authenticated + setAuthRequired(true); + setIsAuthenticated(false); + } finally { + setAuthChecked(true); + } + }; + verifyAuth(); + }, []); + + // Authentication handlers + const handleLogin = async (credentials: LoginCredentials) => { + setIsLoggingIn(true); + setLoginError(null); + try { + const response = await login(credentials); + if (response.success) { + setIsAuthenticated(true); + setLoginError(null); + navigate('/', { replace: true }); + } else { + setLoginError(response.error || 'Login failed'); + } + } catch (error) { + if (error instanceof Error) { + setLoginError(error.message || 'Login failed'); + } else { + setLoginError('Login failed'); + } + } finally { + setIsLoggingIn(false); + } + }; + + const handleLogout = async () => { + try { + await logout(); + setIsAuthenticated(false); + // Clear application state + setBooks([]); + setSelectedBook(null); + setSearchInput(''); + setLastSearchQuery(''); + navigate('/login', { replace: true }); + } catch (error) { + console.error('Logout failed:', error); + showToast('Logout failed', 'error'); + } + }; + // Detect status changes when currentStatus updates useEffect(() => { if (prevStatusRef.current && Object.keys(prevStatusRef.current).length > 0) { @@ -136,7 +222,7 @@ function App() { prevStatusRef.current = currentStatus; }, [currentStatus, detectChanges]); - // Fetch config on mount + // Fetch config on mount and when authentication changes useEffect(() => { const loadConfig = async () => { try { @@ -147,8 +233,11 @@ function App() { // Use defaults if config fails to load } }; - loadConfig(); - }, []); + // Only fetch config if authenticated (or auth is not required) + if (isAuthenticated) { + loadConfig(); + } + }, [isAuthenticated]); // Log WebSocket connection status changes useEffect(() => { @@ -168,15 +257,24 @@ function App() { const handleSearch = async (query: string) => { if (!query) { setBooks([]); + setLastSearchQuery(''); return; } setIsSearching(true); + setLastSearchQuery(query); try { const results = await searchBooks(query); setBooks(results); } catch (error) { - console.error('Search failed:', error); - setBooks([]); + if (error instanceof AuthenticationError) { + setIsAuthenticated(false); + if (authRequired) { + navigate('/login', { replace: true }); + } + } else { + console.error('Search failed:', error); + setBooks([]); + } } finally { setIsSearching(false); } @@ -230,17 +328,34 @@ function App() { setBooks([]); setSearchInput(''); setShowAdvanced(false); + setLastSearchQuery(''); setAdvancedFilters({ isbn: '', author: '', title: '', - lang: 'all', + lang: [LANGUAGE_OPTION_DEFAULT], sort: '', content: '', - formats: [], + formats: DEFAULT_FORMAT_SELECTION, }); }; + const handleSortChange = (value: string) => { + updateAdvancedFilters({ sort: value }); + if (!lastSearchQuery) return; + + const params = new URLSearchParams(lastSearchQuery); + if (value) { + params.set('sort', value); + } else { + params.delete('sort'); + } + + const nextQuery = params.toString(); + if (!nextQuery) return; + handleSearch(nextQuery); + }; + // Get button state for a book - memoized to ensure proper re-renders when status changes const getButtonState = useCallback((bookId: string): ButtonStateInfo => { // Check error first @@ -287,7 +402,14 @@ function App() { return { text: 'Download', state: 'download' }; }, [currentStatus]); - return ( + const bookLanguages = config?.book_languages || DEFAULT_LANGUAGES; + const supportedFormats = config?.supported_formats || DEFAULT_SUPPORTED_FORMATS; + const defaultLanguageCodes = + config?.default_language && config.default_language.length > 0 + ? config.default_language + : [bookLanguages[0]?.code || 'en']; + + const mainAppContent = ( <>
setDownloadsSidebarOpen(true)} statusCounts={statusCounts} onLogoClick={handleResetSearch} + authRequired={authRequired} + isAuthenticated={isAuthenticated} + onLogout={handleLogout} onSearch={() => { const q: string[] = []; const basic = searchInput.trim(); @@ -308,10 +433,18 @@ function App() { if (advancedFilters.isbn) q.push(`isbn=${encodeURIComponent(advancedFilters.isbn)}`); if (advancedFilters.author) q.push(`author=${encodeURIComponent(advancedFilters.author)}`); if (advancedFilters.title) q.push(`title=${encodeURIComponent(advancedFilters.title)}`); - if (advancedFilters.lang && advancedFilters.lang !== 'all') q.push(`lang=${encodeURIComponent(advancedFilters.lang)}`); - if (advancedFilters.sort) q.push(`sort=${encodeURIComponent(advancedFilters.sort)}`); if (advancedFilters.content) q.push(`content=${encodeURIComponent(advancedFilters.content)}`); advancedFilters.formats.forEach(f => q.push(`format=${encodeURIComponent(f)}`)); + const resolvedLangs = getLanguageFilterValues( + advancedFilters.lang, + bookLanguages, + defaultLanguageCodes, + ); + resolvedLangs?.forEach(code => q.push(`lang=${encodeURIComponent(code)}`)); + } + + if (advancedFilters.sort) { + q.push(`sort=${encodeURIComponent(advancedFilters.sort)}`); } handleSearch(q.join('&')); @@ -322,25 +455,28 @@ function App() { -
+
setShowAdvanced(!showAdvanced)} + advancedFilters={advancedFilters} + onAdvancedFiltersChange={updateAdvancedFilters} /> {selectedBook && ( @@ -360,12 +498,12 @@ function App() { /> )} -
+
@@ -379,9 +517,55 @@ function App() { onCancel={handleCancel} activeCount={activeCount} /> - ); + + const visuallyHiddenStyle: CSSProperties = { + position: 'absolute', + width: '1px', + height: '1px', + padding: 0, + margin: '-1px', + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + border: 0, + }; + + if (!authChecked) { + return ( +
+ Checking authentication… +
+ ); + } + + const shouldRedirectFromLogin = !authRequired || isAuthenticated; + const appElement = authRequired && !isAuthenticated ? ( + + ) : ( + mainAppContent + ); + + return ( + + + ) : ( + + ) + } + /> + + + ); } export default App; diff --git a/src/frontend/src/components/AdvancedFilters.tsx b/src/frontend/src/components/AdvancedFilters.tsx index 2fbadb8b..a2544774 100644 --- a/src/frontend/src/components/AdvancedFilters.tsx +++ b/src/frontend/src/components/AdvancedFilters.tsx @@ -1,20 +1,16 @@ -import { useState } from 'react'; -import { Language } from '../types'; +import { AdvancedFilterState, Language } from '../types'; +import { normalizeLanguageSelection } from '../utils/languageFilters'; +import { LanguageMultiSelect } from './LanguageMultiSelect'; +import { DropdownList } from './DropdownList'; +import { CONTENT_OPTIONS } from '../data/filterOptions'; interface AdvancedFiltersProps { visible: boolean; bookLanguages: Language[]; - defaultLanguage: string; + defaultLanguage: string[]; supportedFormats: string[]; - onFiltersChange: (filters: { - isbn: string; - author: string; - title: string; - lang: string; - sort: string; - content: string; - formats: string[]; - }) => void; + filters: AdvancedFilterState; + onFiltersChange: (updates: Partial) => void; } export const AdvancedFilters = ({ @@ -22,45 +18,26 @@ export const AdvancedFilters = ({ bookLanguages, defaultLanguage, supportedFormats, + filters, onFiltersChange, }: AdvancedFiltersProps) => { - const [isbn, setIsbn] = useState(''); - const [author, setAuthor] = useState(''); - const [title, setTitle] = useState(''); - const [lang, setLang] = useState(defaultLanguage || 'all'); - const [sort, setSort] = useState(''); - const [content, setContent] = useState(''); - const [formats, setFormats] = useState( - supportedFormats.filter(f => f !== 'pdf') - ); + const { isbn, author, title, lang, content, formats } = filters; - const notifyChange = (updates?: Partial<{ - isbn: string; - author: string; - title: string; - lang: string; - sort: string; - content: string; - formats: string[]; - }>) => { - onFiltersChange({ - isbn, - author, - title, - lang, - sort, - content, - formats, - ...updates, - }); + const handleLangChange = (next: string[]) => { + const normalized = normalizeLanguageSelection(next); + onFiltersChange({ lang: normalized }); + }; + + const handleContentChange = (next: string[] | string) => { + const value = Array.isArray(next) ? next[0] ?? '' : next; + onFiltersChange({ content: value }); }; const toggleFormat = (format: string) => { const newFormats = formats.includes(format) ? formats.filter(f => f !== format) : [...formats, format]; - setFormats(newFormats); - notifyChange({ formats: newFormats }); + onFiltersChange({ formats: newFormats }); }; if (!visible) return null; @@ -89,8 +66,7 @@ export const AdvancedFilters = ({ }} value={isbn} onChange={e => { - setIsbn(e.target.value); - notifyChange({ isbn: e.target.value }); + onFiltersChange({ isbn: e.target.value }); }} /> @@ -111,8 +87,7 @@ export const AdvancedFilters = ({ }} value={author} onChange={e => { - setAuthor(e.target.value); - notifyChange({ author: e.target.value }); + onFiltersChange({ author: e.target.value }); }} /> @@ -133,94 +108,24 @@ export const AdvancedFilters = ({ }} value={title} onChange={e => { - setTitle(e.target.value); - notifyChange({ title: e.target.value }); + onFiltersChange({ title: e.target.value }); }} /> -
- - -
-
- - -
-
- - -
+ +
diff --git a/src/frontend/src/components/BookCard.tsx b/src/frontend/src/components/BookCard.tsx deleted file mode 100644 index ed6e093e..00000000 --- a/src/frontend/src/components/BookCard.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Book, ButtonStateInfo } from '../types'; -import { CircularProgress } from './CircularProgress'; - -const SkeletonLoader = () => ( -
-); - -interface BookCardProps { - book: Book; - onDetails: (id: string) => Promise; - onDownload: (book: Book) => Promise; - buttonState: ButtonStateInfo; -} - -export const BookCard = ({ book, onDetails, onDownload, buttonState }: BookCardProps) => { - const [isQueuing, setIsQueuing] = useState(false); - const [isLoadingDetails, setIsLoadingDetails] = useState(false); - const [imageLoaded, setImageLoaded] = useState(false); - const [imageError, setImageError] = useState(false); - const [isHovered, setIsHovered] = useState(false); - - // Clear queuing state once button state changes from download - useEffect(() => { - if (isQueuing && buttonState.state !== 'download') { - setIsQueuing(false); - } - }, [buttonState.state, isQueuing]); - - const isCompleted = buttonState.state === 'completed'; - const hasError = buttonState.state === 'error'; - const isInProgress = ['queued', 'resolving', 'bypassing', 'downloading', 'verifying', 'ingesting'].includes(buttonState.state); - const isDisabled = buttonState.state !== 'download' || isQueuing || isCompleted; - const displayText = isQueuing ? 'Queuing...' : buttonState.text; - - // Show circular progress only for downloading state with progress data - const showCircularProgress = buttonState.state === 'downloading' && buttonState.progress !== undefined; - // Show spinner for other in-progress states or when queuing - const showSpinner = (isInProgress && !showCircularProgress) || isQueuing; - - const handleDetails = async (id: string) => { - setIsLoadingDetails(true); - try { - await onDetails(id); - } finally { - setIsLoadingDetails(false); - } - }; - - const handleDownload = async () => { - setIsQueuing(true); - try { - await onDownload(book); - } catch (error) { - setIsQueuing(false); - } - }; - - return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - {/* Book Cover Image - 2:3 aspect ratio on desktop, fixed width on mobile */} -
- {book.preview && !imageError ? ( - <> - {!imageLoaded && ( -
- -
- )} - {book.title setImageLoaded(true)} - onError={() => setImageError(true)} - /> - - ) : ( -
- No Cover -
- )} - - {/* Hover overlay with 2% white opacity */} -
- - {/* Info button - appears on hover, positioned bottom-right */} - -
- - {/* Book Details Section */} -
-
-

- {book.title || 'Untitled'} -

-

{book.author || 'Unknown author'}

-
- {book.year || '-'} - • - {book.language || '-'} - • - {book.format || '-'} - {book.size && ( - <> - • - {book.size} - - )} -
-
- - {/* Mobile: Details and Download buttons side by side */} -
- - -
-
- - {/* Desktop: Full-width Download button at bottom */} - -
- ); -}; diff --git a/src/frontend/src/components/BookDownloadButton.tsx b/src/frontend/src/components/BookDownloadButton.tsx new file mode 100644 index 00000000..6655f3aa --- /dev/null +++ b/src/frontend/src/components/BookDownloadButton.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState, CSSProperties } from 'react'; +import { ButtonStateInfo } from '../types'; +import { CircularProgress } from './CircularProgress'; + +type ButtonSize = 'sm' | 'md'; + +interface BookDownloadButtonProps { + buttonState: ButtonStateInfo; + onDownload: () => Promise; + size?: ButtonSize; + fullWidth?: boolean; + className?: string; + showIcon?: boolean; + style?: CSSProperties; +} + +const sizeClasses: Record = { + sm: 'px-2.5 py-1.5 text-xs', + md: 'px-4 py-2.5 text-sm', +}; + +const iconSizes: Record = { + sm: 'w-3.5 h-3.5', + md: 'w-4 h-4', +}; + +export const BookDownloadButton = ({ + buttonState, + onDownload, + size = 'md', + fullWidth = false, + className = '', + showIcon = false, + style, +}: BookDownloadButtonProps) => { + const [isQueuing, setIsQueuing] = useState(false); + + useEffect(() => { + if (isQueuing && buttonState.state !== 'download') { + setIsQueuing(false); + } + }, [buttonState.state, isQueuing]); + + const isCompleted = buttonState.state === 'completed'; + const hasError = buttonState.state === 'error'; + const isInProgress = ['queued', 'resolving', 'bypassing', 'downloading', 'verifying', 'ingesting'].includes( + buttonState.state, + ); + const isDisabled = buttonState.state !== 'download' || isQueuing || isCompleted; + const displayText = isQueuing ? 'Queuing...' : buttonState.text; + const showCircularProgress = buttonState.state === 'downloading' && buttonState.progress !== undefined; + const showSpinner = (isInProgress && !showCircularProgress) || isQueuing; + + const stateClasses = + isCompleted + ? 'bg-green-600 cursor-not-allowed' + : hasError + ? 'bg-red-600 cursor-not-allowed opacity-75' + : isInProgress + ? 'bg-gray-500 cursor-not-allowed opacity-75' + : 'bg-sky-700 hover:bg-sky-800'; + + const widthClasses = fullWidth ? 'w-full' : ''; + const baseClasses = + 'inline-flex items-center justify-center gap-1.5 rounded text-white transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500'; + + const handleDownload = async () => { + if (isDisabled) return; + setIsQueuing(true); + try { + await onDownload(); + } catch (error) { + setIsQueuing(false); + } + }; + + return ( + + ); +}; + diff --git a/src/frontend/src/components/CircularProgress.tsx b/src/frontend/src/components/CircularProgress.tsx index 3cf5803f..624a69e1 100644 --- a/src/frontend/src/components/CircularProgress.tsx +++ b/src/frontend/src/components/CircularProgress.tsx @@ -1,20 +1,22 @@ interface CircularProgressProps { progress?: number; size?: number; + className?: string; } /** * Circular progress indicator component * Displays a circular SVG progress ring that fills based on the progress percentage */ -export const CircularProgress = ({ progress, size = 16 }: CircularProgressProps) => { +export const CircularProgress = ({ progress, size = 16, className }: CircularProgressProps) => { const radius = (size - 2) / 2; const circumference = 2 * Math.PI * radius; const progressValue = progress ?? 0; const strokeDashoffset = circumference - (progressValue / 100) * circumference; + const svgClassName = className ? `transform -rotate-90 ${className}` : 'transform -rotate-90'; return ( - + {/* Background circle */} document.removeEventListener('keydown', handleEscape); }, [onClose]); + useEffect(() => { + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, []); + if (!book) return null; + const titleId = `book-details-title-${book.id}`; + const isCompleted = buttonState.state === 'completed'; const hasError = buttonState.state === 'error'; const isInProgress = ['queued', 'resolving', 'bypassing', 'downloading', 'verifying', 'ingesting'].includes(buttonState.state); @@ -59,101 +69,177 @@ export const DetailsModal = ({ book, onClose, onDownload, buttonState }: Details } }; + const publisherInfo = { label: 'Publisher', value: book.publisher || '-' }; + const metadata = [ + { label: 'Year', value: book.year || '-' }, + { label: 'Language', value: book.language || '-' }, + { label: 'Format', value: book.format || '-' }, + { label: 'Size', value: book.size || '-' }, + ]; + const artworkMaxHeight = 'calc(90vh - 220px)'; + const artworkMaxWidth = 'min(45vw, 520px, calc((90vh - 220px) / 1.6))'; + const additionalInfo = + book.info && Object.keys(book.info).length > 0 + ? Object.entries(book.info).filter(([key]) => { + const normalized = key.toLowerCase(); + return normalized !== 'language' && normalized !== 'year'; + }) + : []; + return (
{ if (e.target === e.currentTarget) onClose(); }} > -
-
-
-
- {book.preview && ( - Cover - )} +
+
+
+
+

Book

+

+ {book.title || 'Untitled'} +

+

+ {book.author || 'Unknown author'} +

-
-

{book.title || 'Untitled'}

-

{book.author || 'Unknown author'}

-
-

- Publisher: {book.publisher || '-'} -

-

- Year: {book.year || '-'} -

-

- Language: {book.language || '-'} -

-

- Format: {book.format || '-'} -

-

- Size: {book.size || '-'} -

+ +
+ +
+
+
+ {book.preview ? ( +
+ Book cover +
+ ) : ( +
+ No cover +
+ )} +
+ +
+
+
+

+ {publisherInfo.label} +

+

{publisherInfo.value}

+
+
+ {metadata.map(item => ( +
+

+ {item.label} +

+

{item.value}

+
+ ))} +
+
+ + {additionalInfo.length > 0 && ( +
+

Further Information

+
    + {additionalInfo.map(([key, value]) => ( +
  • + + {key} + + + {Array.isArray(value) ? value.join(', ') : value} + +
  • + ))} +
+
+ )}
- {book.info && Object.keys(book.info).length > 0 && ( -
-

Further Information

-
    - {Object.entries(book.info).map(([k, v]) => ( -
  • - {k}:{' '} - {Array.isArray(v) ? v.join(', ') : v} -
  • - ))} -
+ +
+
+
- )} -
- - -
+
diff --git a/src/frontend/src/components/DownloadsSidebar.tsx b/src/frontend/src/components/DownloadsSidebar.tsx index b947fcba..34319f93 100644 --- a/src/frontend/src/components/DownloadsSidebar.tsx +++ b/src/frontend/src/components/DownloadsSidebar.tsx @@ -218,6 +218,7 @@ export const DownloadsSidebar = ({ {/* Cancel Button for in-progress items */} {isInProgress && ( + )} + + {isOpen && ( +
+
+ {children({ close })} +
+
+ )} +
+ ); +}; + diff --git a/src/frontend/src/components/DropdownList.tsx b/src/frontend/src/components/DropdownList.tsx new file mode 100644 index 00000000..6cef5497 --- /dev/null +++ b/src/frontend/src/components/DropdownList.tsx @@ -0,0 +1,155 @@ +import { ReactNode } from 'react'; +import { Dropdown } from './Dropdown'; + +export interface DropdownListOption { + value: string; + label: string; + description?: string; + disabled?: boolean; + icon?: ReactNode; +} + +interface DropdownListProps { + label?: string; + placeholder?: string; + options: DropdownListOption[]; + multiple?: boolean; + showCheckboxes?: boolean; + value: string[] | string | null | undefined; + onChange: (value: string[] | string) => void; + align?: 'left' | 'right'; + widthClassName?: string; + buttonClassName?: string; + summaryFormatter?: (selected: DropdownListOption[], placeholder: string) => ReactNode; + keepOpenOnSelect?: boolean; +} + +export const DropdownList = ({ + label, + placeholder = 'Select an option', + options, + multiple = false, + showCheckboxes, + value, + onChange, + align, + widthClassName, + buttonClassName, + summaryFormatter, + keepOpenOnSelect, +}: DropdownListProps) => { + const selectedValues = normalizeValue(value, multiple); + const selectedOptions = options.filter(opt => selectedValues.includes(opt.value)); + const checkboxEnabled = showCheckboxes ?? multiple; + const stayOpenOnSelect = keepOpenOnSelect ?? multiple; + + const renderSummary = () => { + if (summaryFormatter) { + return summaryFormatter(selectedOptions, placeholder); + } + + if (selectedOptions.length === 0) { + return {placeholder}; + } + + if (!multiple) { + return selectedOptions[0]?.label ?? placeholder; + } + + if (selectedOptions.length === 1) { + return selectedOptions[0].label; + } + + const [first, second, ...rest] = selectedOptions.map(opt => opt.label); + const suffix = rest.length > 0 ? ` +${rest.length}` : ''; + return `${first}, ${second ?? ''}${suffix}`.trim(); + }; + + const handleOptionClick = (option: DropdownListOption, close: () => void) => { + if (option.disabled) return; + + if (multiple) { + const next = selectedValues.includes(option.value) + ? selectedValues.filter(v => v !== option.value) + : [...selectedValues, option.value]; + onChange(next); + if (!stayOpenOnSelect) { + close(); + } + return; + } + + if (selectedValues[0] === option.value) { + close(); + return; + } + + onChange(option.value); + close(); + }; + + return ( + + {({ close }) => ( +
+ {options.map(option => ( + + ))} +
+ )} +
+ ); +}; + +const normalizeValue = (value: string[] | string | null | undefined, multiple: boolean): string[] => { + if (multiple) { + if (Array.isArray(value)) { + return value; + } + if (typeof value === 'string') { + return [value]; + } + return []; + } + + if (Array.isArray(value)) { + return value.length ? [value[0]] : []; + } + + if (typeof value === 'string' && value) { + return [value]; + } + + return []; +}; + diff --git a/src/frontend/src/components/Footer.tsx b/src/frontend/src/components/Footer.tsx index 4025d436..2a22705f 100644 --- a/src/frontend/src/components/Footer.tsx +++ b/src/frontend/src/components/Footer.tsx @@ -6,7 +6,7 @@ interface FooterProps { export const Footer = ({ buildVersion, releaseVersion, appEnv }: FooterProps) => { return ( -
+

Calibre Web Book Downloader

@@ -15,19 +15,21 @@ export const Footer = ({ buildVersion, releaseVersion, appEnv }: FooterProps) => {appEnv || 'development'}

- - + - - - + + + + +
); diff --git a/src/frontend/src/components/Header.tsx b/src/frontend/src/components/Header.tsx index b77fc585..078d45c5 100644 --- a/src/frontend/src/components/Header.tsx +++ b/src/frontend/src/components/Header.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; interface StatusCounts { ongoing: number; @@ -19,6 +19,9 @@ interface HeaderProps { onDownloadsClick?: () => void; statusCounts?: StatusCounts; onLogoClick?: () => void; + authRequired?: boolean; + isAuthenticated?: boolean; + onLogout?: () => void; } export const Header = ({ @@ -34,8 +37,15 @@ export const Header = ({ onDownloadsClick, statusCounts = { ongoing: 0, completed: 0, errored: 0 }, onLogoClick, + authRequired = false, + isAuthenticated = false, + onLogout, }: HeaderProps) => { const [theme, setTheme] = useState('auto'); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [isClosing, setIsClosing] = useState(false); + const [shouldAnimateIn, setShouldAnimateIn] = useState(false); + const dropdownRef = useRef(null); useEffect(() => { const saved = localStorage.getItem('preferred-theme') || 'auto'; @@ -59,6 +69,40 @@ export const Header = ({ return () => mq.removeEventListener('change', handler); }, []); + // Helper function to close dropdown with animation + const closeDropdown = () => { + setIsClosing(true); + setTimeout(() => { + setIsDropdownOpen(false); + setIsClosing(false); + }, 150); // Match the animation duration + }; + + // Close dropdown when clicking outside or pressing ESC + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + closeDropdown(); + } + }; + + const handleEscapeKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeDropdown(); + } + }; + + if (isDropdownOpen && !isClosing) { + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscapeKey); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscapeKey); + }; + }, [isDropdownOpen, isClosing]); + const applyTheme = (pref: string) => { if (pref === 'auto') { const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; @@ -81,6 +125,22 @@ export const Header = ({ handleThemeChange(themeOrder[nextIndex]); }; + const handleLogout = () => { + closeDropdown(); + onLogout?.(); + }; + + const toggleDropdown = () => { + if (isDropdownOpen) { + closeDropdown(); + } else { + setShouldAnimateIn(true); + setIsDropdownOpen(true); + // Reset animation flag after animation completes + setTimeout(() => setShouldAnimateIn(false), 200); + } + }; + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && onSearch) { onSearch(); @@ -91,13 +151,76 @@ export const Header = ({ // Icon buttons component - reused for both states const IconButtons = () => (
+ {/* Calibre-Web Button */} + {calibreWebUrl && ( + + + + + Go To Library + + )} + {/* Downloads Button */} {onDownloadsClick && ( + )} + + {/* User Menu Dropdown */} +
+ - )} - {/* Theme Toggle Button */} - + {/* Dropdown Menu */} + {(isDropdownOpen || isClosing) && ( +
+
+ {/* Theme Button */} + - {/* Calibre-Web Button */} - {calibreWebUrl && ( - - - - - Go To Library - - )} + {/* Debug Buttons */} + {debug && ( + <> +
+ +
+
+ +
+ + )} - {/* Debug Buttons */} - {debug && ( - <> -
- -
-
- -
- - )} + {/* Logout Button */} + {authRequired && isAuthenticated && onLogout && ( + + )} +
+
+ )} +
); return ( -
+
diff --git a/src/frontend/src/components/LanguageMultiSelect.tsx b/src/frontend/src/components/LanguageMultiSelect.tsx new file mode 100644 index 00000000..2e044755 --- /dev/null +++ b/src/frontend/src/components/LanguageMultiSelect.tsx @@ -0,0 +1,117 @@ +import { Language } from '../types'; +import { + formatDefaultLanguageLabel, + LANGUAGE_OPTION_ALL, + LANGUAGE_OPTION_DEFAULT, + normalizeLanguageSelection, +} from '../utils/languageFilters'; +import { DropdownList, DropdownListOption } from './DropdownList'; + +interface LanguageMultiSelectProps { + options: Language[]; + value: string[]; + onChange: (value: string[]) => void; + defaultLanguageCodes: string[]; + label?: string; + placeholder?: string; +} + +export const LanguageMultiSelect = ({ + options, + value, + onChange, + defaultLanguageCodes, + label, + placeholder, +}: LanguageMultiSelectProps) => { + const defaultLabel = formatDefaultLanguageLabel(defaultLanguageCodes, options); + const defaultCodeSet = new Set(defaultLanguageCodes); + const nonDefaultLanguages = options.filter(lang => !defaultCodeSet.has(lang.code)); + const selectableValues = [LANGUAGE_OPTION_DEFAULT, ...nonDefaultLanguages.map(lang => lang.code)]; + + const optionList: DropdownListOption[] = [ + { + value: LANGUAGE_OPTION_ALL, + label: 'All languages', + }, + { + value: LANGUAGE_OPTION_DEFAULT, + label: defaultLabel, + }, + ...nonDefaultLanguages.map(lang => ({ + value: lang.code, + label: lang.language, + })), + ]; + + const includesAllSelection = value.includes(LANGUAGE_OPTION_ALL); + const effectiveValue = includesAllSelection ? selectableValues : value; + const selectedSet = new Set(effectiveValue); + const isAllSelected = selectableValues.every(code => selectedSet.has(code)); + const displayedValue = isAllSelected ? [LANGUAGE_OPTION_ALL, ...effectiveValue] : effectiveValue; + + const summaryFormatter = (_selected: DropdownListOption[], fallback: string) => { + if (isAllSelected) { + return 'All languages'; + } + + const labels: string[] = []; + + if (selectedSet.has(LANGUAGE_OPTION_DEFAULT)) { + labels.push(defaultLabel); + } + + nonDefaultLanguages.forEach(lang => { + if (selectedSet.has(lang.code)) { + labels.push(lang.language); + } + }); + + if (labels.length === 0) { + return placeholder || fallback; + } + + if (labels.length === 1) { + return labels[0]; + } + + const [first, second, ...rest] = labels; + const suffix = rest.length > 0 ? ` +${rest.length}` : ''; + return `${first}, ${second ?? ''}${suffix}`.trim(); + }; + + const handleChange = (nextValue: string[] | string) => { + const nextArray = Array.isArray(nextValue) ? nextValue : [nextValue]; + const includesAll = nextArray.includes(LANGUAGE_OPTION_ALL); + const toggledAllOn = includesAll && !isAllSelected; + const toggledAllOff = + isAllSelected && !includesAll && nextArray.length === effectiveValue.length; + + let resolved = nextArray.filter(code => code !== LANGUAGE_OPTION_ALL); + + if (toggledAllOn) { + resolved = [LANGUAGE_OPTION_ALL]; + } else if (toggledAllOff) { + resolved = []; + } + + const normalized = normalizeLanguageSelection(resolved); + onChange(normalized); + }; + + return ( + + ); +}; + + diff --git a/src/frontend/src/components/LoginForm.tsx b/src/frontend/src/components/LoginForm.tsx new file mode 100644 index 00000000..0795d289 --- /dev/null +++ b/src/frontend/src/components/LoginForm.tsx @@ -0,0 +1,231 @@ +import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from 'react'; +import { LoginCredentials } from '../types'; + +interface LoginFormProps { + onSubmit: (credentials: LoginCredentials) => void; + error?: string | null; + isLoading?: boolean; + autoFocus?: boolean; +} + +const EyeIcon = () => ( + + + + +); + +const EyeSlashIcon = () => ( + + + +); + +export const LoginForm = ({ + onSubmit, + error = null, + isLoading = false, + autoFocus = true, +}: LoginFormProps) => { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [rememberMe, setRememberMe] = useState(true); + const [showPassword, setShowPassword] = useState(false); + const usernameRef = useRef(null); + const passwordRef = useRef(null); + + useEffect(() => { + if (autoFocus) { + usernameRef.current?.focus(); + } + }, [autoFocus]); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const usernameValue = (formData.get('username') as string)?.trim() || ''; + const passwordValue = (formData.get('password') as string) || ''; + + if (usernameValue && passwordValue && !isLoading) { + onSubmit({ + username: usernameValue, + password: passwordValue, + remember_me: rememberMe, + }); + } + }; + + const handleUsernameKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + passwordRef.current?.focus(); + } + }; + + return ( +
+ {error && ( +
+ {error} +
+ )} +
+
+ + setUsername(event.target.value)} + onKeyDown={handleUsernameKeyDown} + disabled={isLoading} + className="w-full px-4 py-2.5 rounded-lg border focus:outline-none focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" + style={{ + backgroundColor: 'var(--input-background)', + borderColor: 'var(--border-color)', + color: 'var(--text-color)', + }} + required + /> +
+ +
+ +
+ setPassword(event.target.value)} + disabled={isLoading} + className="w-full px-4 py-2.5 rounded-lg border focus:outline-none focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed pr-10 transition-colors" + style={{ + backgroundColor: 'var(--input-background)', + borderColor: 'var(--border-color)', + color: 'var(--text-color)', + }} + required + /> + +
+
+ +
+ setRememberMe(event.target.checked)} + disabled={isLoading} + className="w-4 h-4 rounded focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed accent-sky-900" + style={{ borderColor: 'var(--border-color)' }} + /> + +
+ + +
+
+ ); +}; + + diff --git a/src/frontend/src/components/ResultsSection.tsx b/src/frontend/src/components/ResultsSection.tsx index c4f0f13a..29c010c2 100644 --- a/src/frontend/src/components/ResultsSection.tsx +++ b/src/frontend/src/components/ResultsSection.tsx @@ -1,5 +1,10 @@ +import { useState, useEffect } from 'react'; import { Book, ButtonStateInfo } from '../types'; -import { BookCard } from './BookCard'; +import { CardView } from './resultsViews/CardView'; +import { CompactView } from './resultsViews/CompactView'; +import { ListView } from './resultsViews/ListView'; +import { Dropdown } from './Dropdown'; +import { SORT_OPTIONS } from '../data/filterOptions'; interface ResultsSectionProps { books: Book[]; @@ -7,6 +12,8 @@ interface ResultsSectionProps { onDetails: (id: string) => Promise; onDownload: (book: Book) => Promise; getButtonState: (bookId: string) => ButtonStateInfo; + sortValue: string; + onSortChange: (value: string) => void; } export const ResultsSection = ({ @@ -15,39 +22,243 @@ export const ResultsSection = ({ onDetails, onDownload, getButtonState, + sortValue, + onSortChange, }: ResultsSectionProps) => { + const [viewMode, setViewMode] = useState<'card' | 'compact' | 'list'>(() => { + const saved = localStorage.getItem('bookViewMode'); + return saved === 'card' || saved === 'compact' || saved === 'list' ? saved : 'compact'; + }); + + const [isDesktop, setIsDesktop] = useState(false); + + useEffect(() => { + localStorage.setItem('bookViewMode', viewMode); + }, [viewMode]); + + // Track whether we're in desktop layout (sm breakpoint and above) + useEffect(() => { + const checkDesktop = () => { + setIsDesktop(window.innerWidth >= 640); // sm breakpoint + }; + + checkDesktop(); + window.addEventListener('resize', checkDesktop); + return () => window.removeEventListener('resize', checkDesktop); + }, []); + if (!visible) return null; return ( -
-
-

Search Results

-
-
- {books.map((book, index) => ( -
+
+ + + {/* View toggle buttons - Desktop: show all 3, Mobile: show Compact and List only */} +
+ {isDesktop && ( + + )} +
- ))} + + + + + + + + +
+ {viewMode === 'list' ? ( + + ) : ( +
+ {books.map((book, index) => { + const shouldUseCardLayout = isDesktop && viewMode === 'card'; + + const animationDelay = index * 50; + + return shouldUseCardLayout ? ( + + ) : ( + + ); + })} +
+ )} {books.length === 0 && (
No results found.
)}
); }; + +interface SortControlProps { + value: string; + onChange: (value: string) => void; +} + +const SortControl = ({ value, onChange }: SortControlProps) => { + const selected = SORT_OPTIONS.find(option => option.value === value) ?? SORT_OPTIONS[0]; + + return ( + ( + + )} + > + {({ close }) => ( +
+ {SORT_OPTIONS.map(option => { + const isSelected = option.value === selected.value; + return ( + + ); + })} +
+ )} +
+ ); +}; diff --git a/src/frontend/src/components/SearchSection.tsx b/src/frontend/src/components/SearchSection.tsx index 617d08de..5b11626f 100644 --- a/src/frontend/src/components/SearchSection.tsx +++ b/src/frontend/src/components/SearchSection.tsx @@ -1,18 +1,24 @@ -import { useState } from 'react'; -import { Language } from '../types'; +import { KeyboardEvent } from 'react'; +import { AdvancedFilterState, Language } from '../types'; +import { getLanguageFilterValues, normalizeLanguageSelection } from '../utils/languageFilters'; +import { LanguageMultiSelect } from './LanguageMultiSelect'; +import { DropdownList } from './DropdownList'; +import { CONTENT_OPTIONS } from '../data/filterOptions'; interface SearchSectionProps { onSearch: (query: string) => void; isLoading: boolean; isInitialState: boolean; bookLanguages: Language[]; - defaultLanguage: string; + defaultLanguage: string[]; supportedFormats: string[]; logoUrl: string; searchInput: string; onSearchInputChange: (value: string) => void; showAdvanced: boolean; onAdvancedToggle: () => void; + advancedFilters: AdvancedFilterState; + onAdvancedFiltersChange: (updates: Partial) => void; } export const SearchSection = ({ @@ -27,16 +33,10 @@ export const SearchSection = ({ onSearchInputChange, showAdvanced, onAdvancedToggle, + advancedFilters, + onAdvancedFiltersChange, }: SearchSectionProps) => { - const [isbn, setIsbn] = useState(''); - const [author, setAuthor] = useState(''); - const [title, setTitle] = useState(''); - const [lang, setLang] = useState(defaultLanguage || 'all'); - const [sort, setSort] = useState(''); - const [content, setContent] = useState(''); - const [formats, setFormats] = useState( - supportedFormats.filter(f => f !== 'pdf') - ); + const { isbn, author, title, lang, content, formats } = advancedFilters; const buildQuery = () => { const q: string[] = []; @@ -48,8 +48,8 @@ export const SearchSection = ({ if (isbn) q.push(`isbn=${encodeURIComponent(isbn)}`); if (author) q.push(`author=${encodeURIComponent(author)}`); if (title) q.push(`title=${encodeURIComponent(title)}`); - if (lang && lang !== 'all') q.push(`lang=${encodeURIComponent(lang)}`); - if (sort) q.push(`sort=${encodeURIComponent(sort)}`); + const selectedLanguages = getLanguageFilterValues(lang, bookLanguages, defaultLanguage); + selectedLanguages?.forEach(code => q.push(`lang=${encodeURIComponent(code)}`)); if (content) q.push(`content=${encodeURIComponent(content)}`); formats.forEach(f => q.push(`format=${encodeURIComponent(f)}`)); @@ -61,17 +61,27 @@ export const SearchSection = ({ onSearch(query); }; - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter') { handleSearch(); (e.target as HTMLInputElement).blur(); } }; + const handleLanguageChange = (next: string[]) => { + onAdvancedFiltersChange({ lang: normalizeLanguageSelection(next) }); + }; + + const handleContentChange = (next: string[] | string) => { + const value = Array.isArray(next) ? next[0] ?? '' : next; + onAdvancedFiltersChange({ content: value }); + }; + const toggleFormat = (format: string) => { - setFormats(prev => - prev.includes(format) ? prev.filter(f => f !== format) : [...prev, format] - ); + const nextFormats = formats.includes(format) + ? formats.filter(f => f !== format) + : [...formats, format]; + onAdvancedFiltersChange({ formats: nextFormats }); }; return ( @@ -80,11 +90,11 @@ export const SearchSection = ({ className={`transition-all duration-500 ease-in-out ${ isInitialState ? 'search-initial-state mb-6' - : 'mb-0' + : 'mb-3 sm:mb-4' }`} > -
Logo

Book Search & Download

@@ -191,7 +201,7 @@ export const SearchSection = ({ borderColor: 'var(--border-muted)', }} value={isbn} - onChange={e => setIsbn(e.target.value)} + onChange={e => onAdvancedFiltersChange({ isbn: e.target.value })} />
@@ -210,7 +220,7 @@ export const SearchSection = ({ borderColor: 'var(--border-muted)', }} value={author} - onChange={e => setAuthor(e.target.value)} + onChange={e => onAdvancedFiltersChange({ author: e.target.value })} />
@@ -229,83 +239,23 @@ export const SearchSection = ({ borderColor: 'var(--border-muted)', }} value={title} - onChange={e => setTitle(e.target.value)} + onChange={e => onAdvancedFiltersChange({ title: e.target.value })} />
-
- - -
-
- - -
-
- - -
+ +
diff --git a/src/frontend/src/components/index.ts b/src/frontend/src/components/index.ts index 8f4b4918..fb4574c5 100644 --- a/src/frontend/src/components/index.ts +++ b/src/frontend/src/components/index.ts @@ -1,9 +1,11 @@ export { Header } from './Header'; export { SearchSection } from './SearchSection'; -export { BookCard } from './BookCard'; export { ResultsSection } from './ResultsSection'; export { DetailsModal } from './DetailsModal'; export { StatusSection } from './StatusSection'; export { DownloadsSidebar } from './DownloadsSidebar'; export { ToastContainer } from './ToastContainer'; export { Footer } from './Footer'; +export { CardView } from './resultsViews/CardView'; +export { CompactView } from './resultsViews/CompactView'; +export { ListView } from './resultsViews/ListView'; diff --git a/src/frontend/src/components/resultsViews/CardView.tsx b/src/frontend/src/components/resultsViews/CardView.tsx new file mode 100644 index 00000000..826b3ba7 --- /dev/null +++ b/src/frontend/src/components/resultsViews/CardView.tsx @@ -0,0 +1,151 @@ +import { useState } from 'react'; +import { Book, ButtonStateInfo } from '../../types'; +import { BookDownloadButton } from '../BookDownloadButton'; + +const SkeletonLoader = () => ( +
+); + +interface CardViewProps { + book: Book; + onDetails: (id: string) => Promise; + onDownload: (book: Book) => Promise; + buttonState: ButtonStateInfo; + animationDelay?: number; +} + +export const CardView = ({ book, onDetails, onDownload, buttonState, animationDelay = 0 }: CardViewProps) => { + const [isLoadingDetails, setIsLoadingDetails] = useState(false); + const [imageLoaded, setImageLoaded] = useState(false); + const [imageError, setImageError] = useState(false); + const [isHovered, setIsHovered] = useState(false); + + const handleDetails = async (id: string) => { + setIsLoadingDetails(true); + try { + await onDetails(id); + } finally { + setIsLoadingDetails(false); + } + }; + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > +
+ {book.preview && !imageError ? ( + <> + {!imageLoaded && ( +
+ +
+ )} + {book.title setImageLoaded(true)} + onError={() => setImageError(true)} + /> + + ) : ( +
+ No Cover +
+ )} + +
+ + +
+ +
+
+

+ {book.title || 'Untitled'} +

+

{book.author || 'Unknown author'}

+
+ {book.year || '-'} + • + {book.language || '-'} + • + {book.format || '-'} + {book.size && ( + <> + • + {book.size} + + )} +
+
+ +
+ + onDownload(book)} size="sm" className="flex-1" /> +
+
+ + onDownload(book)} + className="hidden sm:flex rounded-none" + fullWidth + style={{ + borderBottomLeftRadius: '.75rem', + borderBottomRightRadius: '.75rem', + }} + /> +
+ ); +}; + diff --git a/src/frontend/src/components/resultsViews/CompactView.tsx b/src/frontend/src/components/resultsViews/CompactView.tsx new file mode 100644 index 00000000..e24d4bd3 --- /dev/null +++ b/src/frontend/src/components/resultsViews/CompactView.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react'; +import { Book, ButtonStateInfo } from '../../types'; +import { BookDownloadButton } from '../BookDownloadButton'; + +const SkeletonLoader = () => ( +
+); + +interface CompactViewProps { + book: Book; + onDetails: (id: string) => Promise; + onDownload: (book: Book) => Promise; + buttonState: ButtonStateInfo; + showDetailsButton?: boolean; + animationDelay?: number; +} + +export const CompactView = ({ book, onDetails, onDownload, buttonState, showDetailsButton = false, animationDelay = 0 }: CompactViewProps) => { + const [isLoadingDetails, setIsLoadingDetails] = useState(false); + const [imageLoaded, setImageLoaded] = useState(false); + const [imageError, setImageError] = useState(false); + const [isHovered, setIsHovered] = useState(false); + + const handleDetails = async (id: string) => { + setIsLoadingDetails(true); + try { + await onDetails(id); + } finally { + setIsLoadingDetails(false); + } + }; + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > +
+ {book.preview && !imageError ? ( + <> + {!imageLoaded && ( +
+ +
+ )} + {book.title setImageLoaded(true)} + onError={() => setImageError(true)} + /> + + ) : ( +
+ No Cover +
+ )} + +
+ + {!showDetailsButton && ( + + )} +
+ +
+
+

+ {book.title || 'Untitled'} +

+

{book.author || 'Unknown author'}

+
+ {book.year || '-'} +
+
+ +
+
+ {book.language || '-'} + • + {book.format || '-'} + {book.size && ( + <> + • + {book.size} + + )} +
+ + {showDetailsButton ? ( +
+ + onDownload(book)} size="sm" className="flex-1" /> +
+ ) : ( + onDownload(book)} size="sm" fullWidth /> + )} +
+
+
+ ); +}; + diff --git a/src/frontend/src/components/resultsViews/ListView.tsx b/src/frontend/src/components/resultsViews/ListView.tsx new file mode 100644 index 00000000..d587c57e --- /dev/null +++ b/src/frontend/src/components/resultsViews/ListView.tsx @@ -0,0 +1,294 @@ +import { useState, useEffect } from 'react'; +import { Book, ButtonStateInfo } from '../../types'; +import { CircularProgress } from '../CircularProgress'; + +interface ListViewProps { + books: Book[]; + onDetails: (id: string) => Promise; + onDownload: (book: Book) => Promise; + getButtonState: (bookId: string) => ButtonStateInfo; +} + +interface ListViewDownloadButtonProps { + buttonState: ButtonStateInfo; + onDownload: () => Promise; +} + +const ListViewDownloadButton = ({ buttonState, onDownload }: ListViewDownloadButtonProps) => { + const [isQueuing, setIsQueuing] = useState(false); + + useEffect(() => { + if (isQueuing && buttonState.state !== 'download') { + setIsQueuing(false); + } + }, [buttonState.state, isQueuing]); + + const isCompleted = buttonState.state === 'completed'; + const hasError = buttonState.state === 'error'; + const isInProgress = ['queued', 'resolving', 'bypassing', 'downloading', 'verifying', 'ingesting'].includes( + buttonState.state, + ); + const isDisabled = buttonState.state !== 'download' || isQueuing || isCompleted; + const showCircularProgress = buttonState.state === 'downloading' && buttonState.progress !== undefined; + const showSpinner = (isInProgress && !showCircularProgress) || isQueuing; + + const handleDownload = async () => { + if (isDisabled) return; + setIsQueuing(true); + try { + await onDownload(); + } catch (error) { + setIsQueuing(false); + } + }; + + const baseClasses = 'flex items-center justify-center p-1.5 sm:p-2 rounded-full transition-all duration-200'; + const stateClasses = isCompleted + ? 'bg-green-600 text-white cursor-not-allowed' + : hasError + ? 'bg-red-600 text-white cursor-not-allowed opacity-75' + : isInProgress + ? 'bg-gray-500 text-white cursor-not-allowed opacity-75' + : 'text-gray-600 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700'; + + return ( + + ); +}; + +const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: string }) => { + const [imageLoaded, setImageLoaded] = useState(false); + const [imageError, setImageError] = useState(false); + + if (!preview || imageError) { + return ( +
+ No Cover +
+ ); + } + + return ( +
+ {!imageLoaded && ( +
+ )} + {title setImageLoaded(true)} + onError={() => setImageError(true)} + style={{ opacity: imageLoaded ? 1 : 0, transition: 'opacity 0.2s ease-in-out' }} + /> +
+ ); +}; + +const getLanguageColor = (language?: string): string => { + if (!language || language === '-') return 'bg-gray-400 dark:bg-gray-600'; + const lang = language.toLowerCase(); + const colorMap: Record = { + en: 'bg-blue-500 dark:bg-blue-600', + english: 'bg-blue-500 dark:bg-blue-600', + es: 'bg-orange-500 dark:bg-orange-600', + spanish: 'bg-orange-500 dark:bg-orange-600', + fr: 'bg-purple-500 dark:bg-purple-600', + french: 'bg-purple-500 dark:bg-purple-600', + de: 'bg-yellow-500 dark:bg-yellow-600', + german: 'bg-yellow-500 dark:bg-yellow-600', + it: 'bg-green-500 dark:bg-green-600', + italian: 'bg-green-500 dark:bg-green-600', + pt: 'bg-teal-500 dark:bg-teal-600', + portuguese: 'bg-teal-500 dark:bg-teal-600', + ru: 'bg-red-500 dark:bg-red-600', + russian: 'bg-red-500 dark:bg-red-600', + ja: 'bg-pink-500 dark:bg-pink-600', + japanese: 'bg-pink-500 dark:bg-pink-600', + zh: 'bg-rose-500 dark:bg-rose-600', + chinese: 'bg-rose-500 dark:bg-rose-600', + }; + return colorMap[lang] || 'bg-indigo-500 dark:bg-indigo-600'; +}; + +const getFormatColor = (format?: string): string => { + if (!format || format === '-') return 'bg-gray-400 dark:bg-gray-600'; + const fmt = format.toLowerCase(); + const colorMap: Record = { + pdf: 'bg-red-500 dark:bg-red-600', + epub: 'bg-green-500 dark:bg-green-600', + mobi: 'bg-blue-500 dark:bg-blue-600', + azw3: 'bg-purple-500 dark:bg-purple-600', + txt: 'bg-gray-500 dark:bg-gray-600', + djvu: 'bg-orange-500 dark:bg-orange-600', + fb2: 'bg-teal-500 dark:bg-teal-600', + cbr: 'bg-yellow-500 dark:bg-yellow-600', + cbz: 'bg-amber-500 dark:bg-amber-600', + }; + return colorMap[fmt] || 'bg-cyan-500 dark:bg-cyan-600'; +}; + +export const ListView = ({ books, onDetails, onDownload, getButtonState }: ListViewProps) => { + const [detailsLoadingId, setDetailsLoadingId] = useState(null); + + if (books.length === 0) { + return null; + } + + const handleDetails = async (bookId: string) => { + setDetailsLoadingId(bookId); + try { + await onDetails(bookId); + } finally { + setDetailsLoadingId((current) => (current === bookId ? null : current)); + } + }; + + return ( +
+
+ {books.map((book, index) => { + const buttonState = getButtonState(book.id); + const isLoadingDetails = detailsLoadingId === book.id; + + return ( +
+ {/* Mobile and Desktop: Single row layout */} +
+ {/* Thumbnail */} +
+ +
+ + {/* Title and Author */} +
+

+ {book.title || 'Untitled'} +

+

+ {book.author || 'Unknown author'} + {book.year && • {book.year}} +

+
+ + {/* Format and Size - Mobile only */} +
+ {book.format || '-'} + {book.size && {book.size}} +
+ + {/* Year - Desktop only */} +
+ {book.year || '-'} +
+ + {/* Language Badge - Desktop only */} +
+ + {book.language || '-'} + +
+ + {/* Format Badge - Desktop only */} +
+ + {book.format || '-'} + +
+ + {/* Size - Desktop only */} +
+ {book.size || '-'} +
+ + {/* Action Buttons */} +
+ + onDownload(book)} + /> +
+
+
+ ); + })} +
+
+ ); +}; + diff --git a/src/frontend/src/data/filterOptions.ts b/src/frontend/src/data/filterOptions.ts new file mode 100644 index 00000000..b84e584e --- /dev/null +++ b/src/frontend/src/data/filterOptions.ts @@ -0,0 +1,23 @@ +export const SORT_OPTIONS = [ + { value: '', label: 'Most relevant' }, + { value: 'newest', label: 'Newest (publication year)' }, + { value: 'oldest', label: 'Oldest (publication year)' }, + { value: 'largest', label: 'Largest (filesize)' }, + { value: 'smallest', label: 'Smallest (filesize)' }, + { value: 'newest_added', label: 'Newest (open sourced)' }, + { value: 'oldest_added', label: 'Oldest (open sourced)' }, +]; + +export const CONTENT_OPTIONS = [ + { value: '', label: 'All' }, + { value: 'book_nonfiction', label: 'Book (non-fiction)' }, + { value: 'book_fiction', label: 'Book (fiction)' }, + { value: 'book_unknown', label: 'Book (unknown)' }, + { value: 'magazine', label: 'Magazine' }, + { value: 'book_comic', label: 'Comic Book' }, + { value: 'standards_document', label: 'Standards document' }, + { value: 'other', label: 'Other' }, + { value: 'musical_score', label: 'Musical score' }, + { value: 'audiobook', label: 'Audiobook' }, +]; + diff --git a/src/frontend/src/main.tsx b/src/frontend/src/main.tsx index d1022693..3e807b79 100644 --- a/src/frontend/src/main.tsx +++ b/src/frontend/src/main.tsx @@ -1,5 +1,6 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; import App from './App'; const root = document.getElementById('root'); @@ -7,6 +8,8 @@ if (!root) throw new Error('Root element not found'); createRoot(root).render( - + + + ); diff --git a/src/frontend/src/pages/LoginPage.tsx b/src/frontend/src/pages/LoginPage.tsx new file mode 100644 index 00000000..5e42479d --- /dev/null +++ b/src/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,36 @@ +import { LoginForm } from '../components/LoginForm'; +import { LoginCredentials } from '../types'; + +interface LoginPageProps { + onLogin: (credentials: LoginCredentials) => void; + error: string | null; + isLoading: boolean; +} + +export const LoginPage = ({ onLogin, error, isLoading }: LoginPageProps) => { + return ( +
+
+
+ Logo +

Sign in to continue

+
+
+ +
+
+
+ ); +}; + + diff --git a/src/frontend/src/services/api.ts b/src/frontend/src/services/api.ts index 0cf43332..28d0f9ef 100644 --- a/src/frontend/src/services/api.ts +++ b/src/frontend/src/services/api.ts @@ -1,4 +1,4 @@ -import { Book, StatusData, AppConfig } from '../types'; +import { Book, StatusData, AppConfig, LoginCredentials, AuthResponse } from '../types'; const API_BASE = '/request/api'; @@ -11,13 +11,51 @@ const API = { cancelDownload: `${API_BASE}/download`, setPriority: `${API_BASE}/queue`, clearCompleted: `${API_BASE}/queue/clear`, - config: `${API_BASE}/config` + config: `${API_BASE}/config`, + login: `${API_BASE}/auth/login`, + logout: `${API_BASE}/auth/logout`, + authCheck: `${API_BASE}/auth/check` }; -// Utility function for JSON fetch +// Custom error class for authentication failures +export class AuthenticationError extends Error { + constructor(message: string) { + super(message); + this.name = 'AuthenticationError'; + } +} + +// Utility function for JSON fetch with credentials async function fetchJSON(url: string, opts: RequestInit = {}): Promise { - const res = await fetch(url, opts); - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + const res = await fetch(url, { + ...opts, + credentials: 'include', // Enable cookies for session + headers: { + 'Content-Type': 'application/json', + ...opts.headers, + }, + }); + + if (!res.ok) { + // Try to parse error message from response body + let errorMessage = `${res.status} ${res.statusText}`; + try { + const errorData = await res.json(); + if (errorData.error) { + errorMessage = errorData.error; + } + } catch (e) { + // If we can't parse JSON, use the default error message + } + + // Throw appropriate error based on status code + if (res.status === 401) { + throw new AuthenticationError(errorMessage); + } + + throw new Error(errorMessage); + } + return res.json(); } @@ -40,16 +78,31 @@ export const getStatus = async (): Promise => { }; export const cancelDownload = async (id: string): Promise => { - await fetch(`${API.cancelDownload}/${encodeURIComponent(id)}/cancel`, { method: 'DELETE' }); + await fetchJSON(`${API.cancelDownload}/${encodeURIComponent(id)}/cancel`, { method: 'DELETE' }); }; export const clearCompleted = async (): Promise => { - const response = await fetch(`${API_BASE}/queue/clear`, { - method: 'DELETE', - }); - if (!response.ok) throw new Error('Failed to clear completed'); + await fetchJSON(`${API_BASE}/queue/clear`, { method: 'DELETE' }); }; export const getConfig = async (): Promise => { return fetchJSON(API.config); }; + +// Authentication functions +export const login = async (credentials: LoginCredentials): Promise => { + return fetchJSON(API.login, { + method: 'POST', + body: JSON.stringify(credentials), + }); +}; + +export const logout = async (): Promise => { + return fetchJSON(API.logout, { + method: 'POST', + }); +}; + +export const checkAuth = async (): Promise => { + return fetchJSON(API.authCheck); +}; diff --git a/src/frontend/src/styles.css b/src/frontend/src/styles.css index a3bc52f5..36db61be 100644 --- a/src/frontend/src/styles.css +++ b/src/frontend/src/styles.css @@ -145,6 +145,28 @@ footer { } } +@keyframes fade-in-down { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fade-out-up { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-10px); + } +} + .animate-slide-up { animation: slide-up 0.5s ease-out; } @@ -153,6 +175,14 @@ footer { animation: fade-in-up 0.4s ease-out; } +.animate-fade-in-down { + animation: fade-in-down 0.2s ease-out; +} + +.animate-fade-out-up { + animation: fade-out-up 0.15s ease-in; +} + /* Button spinner styles */ #search-spinner { border-radius: 50%; @@ -187,104 +217,38 @@ footer { .modal-overlay { display: none; position: fixed; - top: 0; - left: 0; + inset: 0; width: 100%; height: 100%; background: var(--loading-overlay); z-index: 1000; + padding: 1.5rem; + justify-content: center; + align-items: center; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); } .modal-overlay.active { display: flex; - justify-content: center; - align-items: center; } .details-container { - background: var(--card-background); - color: var(--text-color); - padding: 1.5rem; - border-radius: 4px; - max-width: 800px; - width: 90%; + width: 100%; + max-width: 64rem; max-height: 90vh; - overflow-y: auto; - position: relative; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); -} - -.details-header { - display: grid; - grid-template-columns: auto 1fr; - gap: 2rem; - margin-bottom: 1.5rem; -} - -.details-header img { - max-width: 200px; - height: auto; - border-radius: 4px; -} - -.details-info h3 { - margin-bottom: 1rem; - color: var(--text-color); -} - -.details-info p { - margin-bottom: 0.5rem; -} - -.details-actions { - display: flex; - gap: 1rem; - margin-top: 1.5rem; - justify-content: flex-end; -} - -.details-actions button { - padding: 0.75rem 1.5rem; - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 1rem; - transition: background-color 0.3s ease; -} - -.details-actions button:first-child { - background: var(--primary-color); - color: white; -} - -.details-actions button:last-child { - background: #f5f5f5; - color: var(--text-color); -} - -.details-actions button:hover { - opacity: 0.9; + pointer-events: auto; } /* Responsive Design */ @media (max-width: 768px) { - .details-header { - grid-template-columns: 1fr; - text-align: center; - } - - .details-header img { - margin: 0 auto; - } - - .details-actions { - flex-direction: column; - } - - /* Minimal equal padding for details container on mobile */ - .details-container { + .modal-overlay { padding: 1rem; } + + .details-container { + max-width: none; + } } /* Accessibility */ @@ -481,35 +445,10 @@ button:disabled { overflow: visible; } - /* Make sure download buttons can expand to fit their content */ - [data-action="download"], - #download-button { - min-width: 0; - flex: 1 1 auto; - overflow: visible; - box-sizing: border-box; - } - /* Make text span flexible to use available space */ .download-button-text { white-space: nowrap; overflow: visible; - /* Take up all available space in the button, allow shrinking if needed */ - flex: 1 1 0%; - min-width: 0; - display: block; - /* Center text within its available space */ - text-align: center; - box-sizing: border-box; - /* Force layout recalculation by ensuring the element is treated as a flex item */ - position: relative; - } - - /* Ensure button's flex container properly distributes space on mobile */ - [data-action="download"].flex, - #download-button.flex { - /* Ensure gap doesn't cause issues */ - gap: 0.5rem; } /* Ensure spinner doesn't interfere with text layout */ diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index 741eca61..7e7ac4bb 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -49,6 +49,16 @@ export interface Language { language: string; } +export interface AdvancedFilterState { + isbn: string; + author: string; + title: string; + lang: string[]; + sort: string; + content: string; + formats: string[]; +} + // Toast notification export interface Toast { id: string; @@ -64,6 +74,20 @@ export interface AppConfig { build_version: string; release_version: string; book_languages: Language[]; - default_language: string; + default_language: string[]; supported_formats: string[]; } + +// Authentication types +export interface LoginCredentials { + username: string; + password: string; + remember_me: boolean; +} + +export interface AuthResponse { + success?: boolean; + authenticated?: boolean; + auth_required?: boolean; + error?: string; +} diff --git a/src/frontend/src/utils/languageFilters.ts b/src/frontend/src/utils/languageFilters.ts new file mode 100644 index 00000000..00fd2f6b --- /dev/null +++ b/src/frontend/src/utils/languageFilters.ts @@ -0,0 +1,89 @@ +import { Language } from '../types'; + +export const LANGUAGE_OPTION_DEFAULT = 'default'; +export const LANGUAGE_OPTION_ALL = 'all'; + +export const normalizeLanguageSelection = (selected: string[]): string[] => { + const sanitized = (selected ?? []).filter(Boolean); + + if (sanitized.length === 0) { + return [LANGUAGE_OPTION_DEFAULT]; + } + + const unique: string[] = []; + const seen = new Set(); + + for (const value of sanitized) { + if (!seen.has(value)) { + unique.push(value); + seen.add(value); + } + } + + if (unique.includes(LANGUAGE_OPTION_ALL)) { + return [LANGUAGE_OPTION_ALL]; + } + + return unique.length ? unique : [LANGUAGE_OPTION_DEFAULT]; +}; + +export const getLanguageFilterValues = ( + selection: string[], + supportedLanguages: Language[], + defaultLanguageCodes: string[] = [], +): string[] | null => { + if (!selection || selection.length === 0) { + return null; + } + + const uniqueSelection = Array.from(new Set(selection.filter(Boolean))); + + if (uniqueSelection.includes(LANGUAGE_OPTION_ALL)) { + return [LANGUAGE_OPTION_ALL]; + } + + const onlyDefaultSelected = + uniqueSelection.length === 1 && uniqueSelection[0] === LANGUAGE_OPTION_DEFAULT; + if (onlyDefaultSelected) { + return null; + } + + const supportedCodes = new Set(supportedLanguages.map(lang => lang.code)); + const defaultCodes = defaultLanguageCodes.filter(code => supportedCodes.has(code)); + const resolved = new Set(); + + uniqueSelection.forEach(code => { + if (code === LANGUAGE_OPTION_DEFAULT) { + defaultCodes.forEach(defaultCode => resolved.add(defaultCode)); + return; + } + + if (supportedCodes.has(code)) { + resolved.add(code); + } + }); + + return resolved.size ? Array.from(resolved) : null; +}; + +export const formatDefaultLanguageLabel = ( + languageCodes: string[], + supportedLanguages: Language[], +): string => { + if (!languageCodes || languageCodes.length === 0) { + return 'Default (env config)'; + } + + const languageNames = supportedLanguages + .filter(lang => languageCodes.includes(lang.code)) + .map(lang => lang.language); + + if (languageNames.length === 0) { + return 'Default (env config)'; + } + + const joined = languageNames.slice(0, 3).join(', '); + const suffix = languageNames.length > 3 ? '…' : ''; + return `Default (${joined}${suffix})`; +}; + diff --git a/src/frontend/tailwind.config.js b/src/frontend/tailwind.config.js index dca8ba02..a2bd508f 100644 --- a/src/frontend/tailwind.config.js +++ b/src/frontend/tailwind.config.js @@ -4,6 +4,7 @@ export default { "./index.html", "./src/**/*.{js,ts,jsx,tsx}", ], + darkMode: ['selector', '[data-theme="dark"]'], theme: { extend: {}, },