WebUI - Additional Search Features (#310)

### Main Points / To Do List

- [X] New Compact mode, with automatic and manual activation
- [X] New List mode, additional manual view 
- [X] Move sorting options to the main search results pane - dropdown
menu alongside view toggles
- [X] New language handling, including default language and multi-select
options.
- [x] New details view 
- [X] Various refactoring, including reuseable components for the three
search view components (Card, Compact & List), the download button, and
a reuseable dropdown list component.

---

### Card sizes: 

**Compact**
<img width="1246" height="612" alt="Screenshot 2025-11-15 at 15 35 55"
src="https://github.com/user-attachments/assets/445bceee-b876-4de7-880c-21c65f5f03eb"
/>

Mobile: Compact by default:
<img width="319" height="695" alt="Screenshot 2025-11-15 at 15 37 35"
src="https://github.com/user-attachments/assets/218361b3-326c-4e04-9b8a-03c503b28ae2"
/>


**List**
<img width="1263" height="623" alt="Screenshot 2025-11-15 at 15 35 24"
src="https://github.com/user-attachments/assets/7fcd2fb6-9b33-4f27-8b4b-c20247d92b16"
/>

Mobile: Optional
<img width="319" height="695" alt="Screenshot 2025-11-15 at 15 37 59"
src="https://github.com/user-attachments/assets/0e69069a-7e45-4499-b818-08e6d8dc2636"
/>

---
### Redesigned details pane: 
<img width="1487" height="729" alt="Screenshot 2025-11-15 at 15 39 50"
src="https://github.com/user-attachments/assets/bdfc61ae-4550-4c31-9bbb-80acb815bc72"
/>

Mobile: 
<img width="314" height="691" alt="Screenshot 2025-11-15 at 15 40 41"
src="https://github.com/user-attachments/assets/1dac3baa-ec9b-4da4-8e4a-d7d381d9bee2"
/>

--- 
### Multi-select languages
<img width="245" height="345" alt="Screenshot 2025-11-15 at 15 41 29"
src="https://github.com/user-attachments/assets/49ce7b96-06a7-4473-857a-ccfb52c2676f"
/>
This commit is contained in:
Alex
2025-11-16 00:10:06 -05:00
committed by GitHub
parent 03321a5435
commit c5d22e0f91
33 changed files with 2845 additions and 903 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

+250 -51
View File
@@ -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('/<path:path>')
@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/'):
+7
View File
@@ -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/"))
+51
View File
@@ -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",
+1
View File
@@ -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": {
+210 -26
View File
@@ -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<boolean>(false);
const [authRequired, setAuthRequired] = useState<boolean>(true);
const [authChecked, setAuthChecked] = useState<boolean>(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [isLoggingIn, setIsLoggingIn] = useState<boolean>(false);
const navigate = useNavigate();
const [books, setBooks] = useState<Book[]>([]);
const [selectedBook, setSelectedBook] = useState<Book | null>(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<AdvancedFilterState>({
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<AdvancedFilterState>) => {
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<StatusData>({});
// 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 = (
<>
<Header
calibreWebUrl={config?.calibre_web_url || ''}
@@ -299,6 +421,9 @@ function App() {
onDownloadsClick={() => 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() {
<AdvancedFilters
visible={showAdvanced && !isInitialState}
bookLanguages={config?.book_languages || DEFAULT_LANGUAGES}
defaultLanguage={config?.default_language || 'en'}
supportedFormats={config?.supported_formats || DEFAULT_SUPPORTED_FORMATS}
onFiltersChange={setAdvancedFilters}
bookLanguages={bookLanguages}
defaultLanguage={defaultLanguageCodes}
supportedFormats={supportedFormats}
filters={advancedFilters}
onFiltersChange={updateAdvancedFilters}
/>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<main className="w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-6">
<SearchSection
onSearch={handleSearch}
isLoading={isSearching}
isInitialState={isInitialState}
bookLanguages={config?.book_languages || DEFAULT_LANGUAGES}
defaultLanguage={config?.default_language || 'en'}
bookLanguages={bookLanguages}
defaultLanguage={defaultLanguageCodes}
supportedFormats={config?.supported_formats || DEFAULT_SUPPORTED_FORMATS}
logoUrl="/logo.png"
searchInput={searchInput}
onSearchInputChange={setSearchInput}
showAdvanced={showAdvanced}
onAdvancedToggle={() => setShowAdvanced(!showAdvanced)}
advancedFilters={advancedFilters}
onAdvancedFiltersChange={updateAdvancedFilters}
/>
<ResultsSection
@@ -349,6 +485,8 @@ function App() {
onDetails={handleShowDetails}
onDownload={handleDownload}
getButtonState={getButtonState}
sortValue={advancedFilters.sort}
onSortChange={handleSortChange}
/>
{selectedBook && (
@@ -360,12 +498,12 @@ function App() {
/>
)}
</main>
</main>
<Footer
buildVersion={config?.build_version || 'dev'}
releaseVersion={config?.release_version || 'dev'}
appEnv={config?.app_env || 'development'}
appEnv={config?.app_env || 'development'}
/>
<ToastContainer toasts={toasts} />
@@ -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 (
<div aria-live="polite" style={visuallyHiddenStyle}>
Checking authentication
</div>
);
}
const shouldRedirectFromLogin = !authRequired || isAuthenticated;
const appElement = authRequired && !isAuthenticated ? (
<Navigate to="/login" replace />
) : (
mainAppContent
);
return (
<Routes>
<Route
path="/login"
element={
shouldRedirectFromLogin ? (
<Navigate to="/" replace />
) : (
<LoginPage
onLogin={handleLogin}
error={loginError}
isLoading={isLoggingIn}
/>
)
}
/>
<Route path="/*" element={appElement} />
</Routes>
);
}
export default App;
+36 -131
View File
@@ -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<AdvancedFilterState>) => 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<string[]>(
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 });
}}
/>
</div>
@@ -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 });
}}
/>
</div>
@@ -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 });
}}
/>
</div>
<div>
<label htmlFor="lang-input" className="block text-sm mb-1 opacity-80">
Language
</label>
<select
id="lang-input"
className="w-full px-3 py-2 rounded-md border"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={lang}
onChange={e => {
setLang(e.target.value);
notifyChange({ lang: e.target.value });
}}
>
<option value="all">All</option>
{bookLanguages.map(l => (
<option key={l.code} value={l.code}>
{l.language}
</option>
))}
</select>
</div>
<div>
<label htmlFor="sort-input" className="block text-sm mb-1 opacity-80">
Sort
</label>
<select
id="sort-input"
className="w-full px-3 py-2 rounded-md border"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={sort}
onChange={e => {
setSort(e.target.value);
notifyChange({ sort: e.target.value });
}}
>
<option value="">Most relevant</option>
<option value="newest">Newest (publication year)</option>
<option value="oldest">Oldest (publication year)</option>
<option value="largest">Largest (filesize)</option>
<option value="smallest">Smallest (filesize)</option>
<option value="newest_added">Newest (open sourced)</option>
<option value="oldest_added">Oldest (open sourced)</option>
</select>
</div>
<div>
<label htmlFor="content-input" className="block text-sm mb-1 opacity-80">
Content
</label>
<select
id="content-input"
className="w-full px-3 py-2 rounded-md border"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={content}
onChange={e => {
setContent(e.target.value);
notifyChange({ content: e.target.value });
}}
>
<option value="">All</option>
<option value="book_nonfiction">Book (non-fiction)</option>
<option value="book_fiction">Book (fiction)</option>
<option value="book_unknown">Book (unknown)</option>
<option value="magazine">Magazine</option>
<option value="book_comic">Comic Book</option>
<option value="standards_document">Standards document</option>
<option value="other">Other</option>
<option value="musical_score">Musical score</option>
<option value="audiobook">Audiobook</option>
</select>
</div>
<LanguageMultiSelect
options={bookLanguages}
value={lang}
onChange={handleLangChange}
defaultLanguageCodes={defaultLanguage}
label="Language"
/>
<DropdownList
label="Content"
options={CONTENT_OPTIONS}
value={content}
onChange={handleContentChange}
placeholder="All"
/>
<div className="md:col-span-2 lg:col-span-3">
<label className="block text-sm mb-1 opacity-80">Formats</label>
<div className="flex flex-wrap gap-3 text-sm">
-257
View File
@@ -1,257 +0,0 @@
import { useState, useEffect } from 'react';
import { Book, ButtonStateInfo } from '../types';
import { CircularProgress } from './CircularProgress';
const SkeletonLoader = () => (
<div className="w-full h-full bg-gradient-to-r from-gray-300 via-gray-200 to-gray-300 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
);
interface BookCardProps {
book: Book;
onDetails: (id: string) => Promise<void>;
onDownload: (book: Book) => Promise<void>;
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 (
<article
className="book-card overflow-hidden flex flex-col sm:flex-col max-sm:flex-row space-between w-full sm:max-w-[292px] max-sm:h-[180px] h-full transition-shadow duration-300"
style={{
background: 'var(--bg-soft)',
borderRadius: '.75rem',
boxShadow: isHovered ? '0 10px 30px rgba(0, 0, 0, 0.15)' : 'none'
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Book Cover Image - 2:3 aspect ratio on desktop, fixed width on mobile */}
<div
className="relative w-full sm:w-full max-sm:w-[120px] max-sm:h-full max-sm:flex-shrink-0 group"
style={{ aspectRatio: '2/3' }}
>
{book.preview && !imageError ? (
<>
{!imageLoaded && (
<div className="absolute inset-0">
<SkeletonLoader />
</div>
)}
<img
src={book.preview}
alt={book.title || 'Book cover'}
className="w-full h-full"
style={{
opacity: imageLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
objectPosition: 'top'
}}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
</>
) : (
<div
className="w-full h-full flex items-center justify-center text-sm opacity-50"
style={{ background: 'var(--border-muted)' }}
>
No Cover
</div>
)}
{/* Hover overlay with 2% white opacity */}
<div
className="absolute inset-0 bg-white transition-opacity duration-300 pointer-events-none"
style={{ opacity: isHovered ? 0.02 : 0 }}
/>
{/* Info button - appears on hover, positioned bottom-right */}
<button
className="absolute bottom-2 right-2 w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110 max-sm:hidden"
style={{
opacity: (isHovered || isLoadingDetails) ? 1 : 0,
pointerEvents: (isHovered || isLoadingDetails) ? 'auto' : 'none'
}}
onClick={(e) => {
e.stopPropagation();
handleDetails(book.id);
}}
disabled={isLoadingDetails}
aria-label="Book details"
>
{isLoadingDetails ? (
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
)}
</button>
</div>
{/* Book Details Section */}
<div className="p-4 max-sm:p-3 max-sm:py-2 flex flex-col gap-3 max-sm:gap-2 max-sm:flex-1 max-sm:justify-between max-sm:min-w-0 sm:flex-1 sm:flex sm:flex-col sm:justify-end">
<div className="space-y-1 max-sm:space-y-0.5 max-sm:min-w-0">
<h3
className="font-semibold leading-tight line-clamp-2 text-base max-sm:line-clamp-3 max-sm:min-w-0"
title={book.title || 'Untitled'}
>
{book.title || 'Untitled'}
</h3>
<p className="text-sm max-sm:text-xs opacity-80 truncate max-sm:min-w-0">{book.author || 'Unknown author'}</p>
<div className="text-xs max-sm:text-[10px] opacity-70 flex flex-wrap gap-2 max-sm:gap-1">
<span>{book.year || '-'}</span>
<span></span>
<span>{book.language || '-'}</span>
<span></span>
<span>{book.format || '-'}</span>
{book.size && (
<>
<span></span>
<span>{book.size}</span>
</>
)}
</div>
</div>
{/* Mobile: Details and Download buttons side by side */}
<div className="flex gap-1.5 sm:hidden">
<button
className="px-2 py-1.5 rounded border text-xs flex-1 flex items-center justify-center gap-1"
onClick={() => handleDetails(book.id)}
style={{ borderColor: 'var(--border-muted)' }}
disabled={isLoadingDetails}
>
<span className="details-button-text">
{isLoadingDetails ? 'Loading' : 'Details'}
</span>
<div
className={`details-spinner w-3 h-3 border-2 border-current border-t-transparent rounded-full ${
isLoadingDetails ? '' : 'hidden'
}`}
/>
</button>
<button
className={`px-2 py-1.5 rounded text-white text-xs flex-1 flex items-center justify-center gap-1 ${
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'
}`}
onClick={handleDownload}
disabled={isDisabled || isInProgress}
data-action="download"
>
<span className="download-button-text">{displayText}</span>
{isCompleted && (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
{hasError && (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
{showCircularProgress && <CircularProgress progress={buttonState.progress} size={12} />}
{showSpinner && (
<div className="w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin" />
)}
</button>
</div>
</div>
{/* Desktop: Full-width Download button at bottom */}
<button
className={`hidden sm:flex w-full px-4 py-3 text-white text-sm items-center justify-center gap-2 ${
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'
}`}
onClick={handleDownload}
disabled={isDisabled || isInProgress}
data-action="download"
style={{
borderBottomLeftRadius: '.75rem',
borderBottomRightRadius: '.75rem'
}}
>
<span className="download-button-text">{displayText}</span>
{isCompleted && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
{hasError && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
{showCircularProgress && <CircularProgress progress={buttonState.progress} size={16} />}
{showSpinner && (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
)}
</button>
</article>
);
};
@@ -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<void>;
size?: ButtonSize;
fullWidth?: boolean;
className?: string;
showIcon?: boolean;
style?: CSSProperties;
}
const sizeClasses: Record<ButtonSize, string> = {
sm: 'px-2.5 py-1.5 text-xs',
md: 'px-4 py-2.5 text-sm',
};
const iconSizes: Record<ButtonSize, string> = {
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 (
<button
className={`${baseClasses} ${sizeClasses[size]} ${stateClasses} ${widthClasses} ${className}`.trim()}
onClick={handleDownload}
disabled={isDisabled || isInProgress}
data-action="download"
style={style}
>
{showIcon && !isCompleted && !hasError && !showCircularProgress && !showSpinner && (
<svg className={iconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v12m0 0l-4-4m4 4 4-4M6 20h12" />
</svg>
)}
<span className="download-button-text">{displayText}</span>
{isCompleted && (
<svg className={iconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
{hasError && (
<svg className={iconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
{showCircularProgress && <CircularProgress progress={buttonState.progress} size={size === 'sm' ? 12 : 16} />}
{showSpinner && (
<div
className={`${size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'} border-2 border-white border-t-transparent rounded-full animate-spin`}
/>
)}
</button>
);
};
@@ -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 (
<svg width={size} height={size} className="transform -rotate-90">
<svg width={size} height={size} className={svgClassName}>
{/* Background circle */}
<circle
cx={size / 2}
+170 -84
View File
@@ -34,8 +34,18 @@ export const DetailsModal = ({ book, onClose, onDownload, buttonState }: Details
return () => 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 (
<div
className="modal-overlay active"
className="modal-overlay active px-4 py-6 sm:px-6"
onClick={e => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="details-container">
<div className="p-4 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
{book.preview && (
<img
src={book.preview}
alt="Cover"
className="w-full h-88 object-cover rounded"
/>
)}
<div
className="details-container w-full max-w-4xl animate-fade-in-up"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="flex max-h-[90vh] flex-col overflow-hidden rounded-2xl border border-[var(--border-muted)] bg-[var(--bg-soft)] text-[var(--text)] shadow-2xl">
<header className="flex items-start gap-4 border-b border-[var(--border-muted)] px-5 py-4">
<div className="flex-1 space-y-1">
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Book</p>
<h3 id={titleId} className="text-lg font-semibold leading-snug">
{book.title || 'Untitled'}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-300">
{book.author || 'Unknown author'}
</p>
</div>
<div>
<h3 className="text-lg font-semibold mb-1">{book.title || 'Untitled'}</h3>
<p className="text-sm opacity-80">{book.author || 'Unknown author'}</p>
<div className="text-sm mt-2 space-y-1">
<p>
<strong>Publisher:</strong> {book.publisher || '-'}
</p>
<p>
<strong>Year:</strong> {book.year || '-'}
</p>
<p>
<strong>Language:</strong> {book.language || '-'}
</p>
<p>
<strong>Format:</strong> {book.format || '-'}
</p>
<p>
<strong>Size:</strong> {book.size || '-'}
</p>
<button
type="button"
onClick={onClose}
className="rounded-full p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-700 dark:hover:text-gray-100"
aria-label="Close details"
>
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</header>
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-stretch lg:gap-8 lg:min-h-0">
<div className="flex w-full justify-center lg:w-auto lg:flex-none lg:justify-start lg:self-stretch lg:pr-4">
{book.preview ? (
<div
className="flex w-full items-center justify-center lg:h-full lg:max-w-none"
style={{ maxHeight: artworkMaxHeight, maxWidth: artworkMaxWidth }}
>
<img
src={book.preview}
alt="Book cover"
className="h-auto max-h-full w-auto max-w-full rounded-xl object-contain shadow-lg"
style={{ maxHeight: '100%', maxWidth: '100%' }}
/>
</div>
) : (
<div
className="flex w-full items-center justify-center rounded-xl border border-dashed border-[var(--border-muted)] bg-[var(--bg)]/60 p-6 text-sm text-gray-500 lg:h-full lg:max-w-none"
style={{ maxHeight: artworkMaxHeight, maxWidth: artworkMaxWidth }}
>
No cover
</div>
)}
</div>
<div className="flex flex-1 flex-col gap-4 sm:gap-5 lg:min-h-0">
<div className="space-y-4 text-sm">
<div
className="rounded-2xl border border-[var(--border-muted)] px-4 py-3"
style={{ background: 'var(--bg)' }}
>
<p className="text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400">
{publisherInfo.label}
</p>
<p className="font-medium text-gray-900 dark:text-gray-100">{publisherInfo.value}</p>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-2 lg:grid-cols-4 lg:gap-4">
{metadata.map(item => (
<div
key={item.label}
className="rounded-2xl border border-[var(--border-muted)] px-4 py-3"
style={{ background: 'var(--bg)' }}
>
<p className="text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400">
{item.label}
</p>
<p className="font-medium text-gray-900 dark:text-gray-100">{item.value}</p>
</div>
))}
</div>
</div>
{additionalInfo.length > 0 && (
<section
className="space-y-3 rounded-2xl border border-[var(--border-muted)] px-4 py-4"
style={{ background: 'var(--bg)' }}
>
<h4 className="text-sm font-semibold">Further Information</h4>
<ul className="space-y-2 text-sm">
{additionalInfo.map(([key, value]) => (
<li key={key} className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{key}
</span>
<span className="text-gray-900 dark:text-gray-100">
{Array.isArray(value) ? value.join(', ') : value}
</span>
</li>
))}
</ul>
</section>
)}
</div>
</div>
</div>
{book.info && Object.keys(book.info).length > 0 && (
<div>
<h4 className="font-semibold mb-2">Further Information</h4>
<ul className="list-disc pl-6 space-y-1 text-sm">
{Object.entries(book.info).map(([k, v]) => (
<li key={k}>
<strong>{k}:</strong>{' '}
{Array.isArray(v) ? v.join(', ') : v}
</li>
))}
</ul>
<footer className="border-t border-[var(--border-muted)] bg-[var(--bg-soft)] px-5 py-4">
<div className="flex justify-end">
<button
id="download-button"
data-id={book.id}
type="button"
className={`inline-flex items-center justify-center gap-2 rounded-full px-4 py-3 text-sm font-medium text-white transition-colors ${
isCompleted
? 'bg-green-600'
: hasError
? 'bg-red-600'
: isInProgress || isQueuing
? 'bg-gray-500'
: 'bg-sky-700 hover:bg-sky-800'
} ${isDisabled ? 'cursor-not-allowed opacity-75' : ''}`}
onClick={handleDownload}
disabled={isDisabled || isInProgress}
>
<span className="download-button-text">{displayText}</span>
{isCompleted && (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
{hasError && (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
{showCircularProgress && <CircularProgress progress={buttonState.progress} size={16} />}
{showSpinner && (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
)}
</button>
</div>
)}
<div className="flex gap-2">
<button
id="download-button"
data-id={book.id}
className={`px-3 py-2 rounded text-white text-sm flex items-center justify-center gap-2 ${
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-blue-600 hover:bg-blue-700'
}`}
onClick={handleDownload}
disabled={isDisabled || isInProgress}
>
<span className="download-button-text">{displayText}</span>
{isCompleted && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
{hasError && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
{showCircularProgress && <CircularProgress progress={buttonState.progress} size={16} />}
{showSpinner && (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
)}
</button>
<button
id="close-details"
className="px-3 py-2 rounded border text-sm"
style={{ borderColor: 'var(--border-muted)' }}
onClick={onClose}
>
Close
</button>
</div>
</footer>
</div>
</div>
</div>
@@ -218,6 +218,7 @@ export const DownloadsSidebar = ({
{/* Cancel Button for in-progress items */}
{isInProgress && (
<button
type="button"
onClick={() => onCancel(book.id)}
className="text-xs px-2 py-1 rounded border hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
style={{ borderColor: 'var(--border-muted)' }}
@@ -289,13 +290,11 @@ export const DownloadsSidebar = ({
{/* Header */}
<div
className="flex items-center justify-between p-4 border-b"
style={{
borderColor: 'var(--border-muted)',
paddingTop: 'calc(1rem + env(safe-area-inset-top))'
}}
style={{ borderColor: 'var(--border-muted)' }}
>
<h2 className="text-lg font-semibold">Downloads</h2>
<button
type="button"
onClick={onClose}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label="Close sidebar"
@@ -319,6 +318,7 @@ export const DownloadsSidebar = ({
style={{ borderColor: 'var(--border-muted)' }}
>
<button
type="button"
onClick={onClearCompleted}
className="flex-1 flex items-center justify-center px-3 py-2 rounded border text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
style={{ borderColor: 'var(--border-muted)' }}
@@ -326,6 +326,7 @@ export const DownloadsSidebar = ({
Clear Completed
</button>
<button
type="button"
onClick={onRefresh}
className="flex items-center justify-center px-3 py-2 rounded border text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
style={{ borderColor: 'var(--border-muted)' }}
@@ -350,10 +351,7 @@ export const DownloadsSidebar = ({
</div>
{/* Queue Items */}
<div
className="flex-1 overflow-y-auto p-4 space-y-3"
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{allDownloadItems.length > 0 ? (
allDownloadItems.map((item) => renderDownloadItem(item))
) : (
@@ -367,10 +365,7 @@ export const DownloadsSidebar = ({
{activeCount > 0 && (
<div
className="p-3 border-t text-xs text-center opacity-70"
style={{
borderColor: 'var(--border-muted)',
paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))'
}}
style={{ borderColor: 'var(--border-muted)' }}
>
{activeCount} active {activeCount === 1 ? 'download' : 'downloads'}
</div>
+112
View File
@@ -0,0 +1,112 @@
import { ReactNode, useEffect, useRef, useState } from 'react';
interface DropdownProps {
label?: string;
summary?: ReactNode;
children: (helpers: { close: () => void }) => ReactNode;
align?: 'left' | 'right';
widthClassName?: string;
buttonClassName?: string;
panelClassName?: string;
disabled?: boolean;
renderTrigger?: (props: { isOpen: boolean; toggle: () => void }) => ReactNode;
}
export const Dropdown = ({
label,
summary,
children,
align = 'left',
widthClassName = 'w-full',
buttonClassName = '',
panelClassName = '',
disabled = false,
renderTrigger,
}: DropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const toggleOpen = () => {
if (disabled) return;
setIsOpen(prev => !prev);
};
const close = () => setIsOpen(false);
useEffect(() => {
if (!isOpen) return;
const handleClick = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
close();
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
close();
}
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);
return (
<div className={`relative ${widthClassName}`} ref={containerRef}>
{label && (
<label className="block text-sm mb-1 opacity-80" onClick={toggleOpen}>
{label}
</label>
)}
{renderTrigger ? (
renderTrigger({ isOpen, toggle: toggleOpen })
) : (
<button
type="button"
onClick={toggleOpen}
disabled={disabled}
className={`w-full px-3 py-2 rounded-md border flex items-center justify-between text-left text-base focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 ${buttonClassName}`}
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
>
<span className="truncate text-base">
{summary ?? <span className="opacity-60">Select an option</span>}
</span>
<svg
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth="1.5"
>
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
)}
{isOpen && (
<div
className={`absolute ${align === 'right' ? 'right-0' : 'left-0'} mt-2 rounded-md border shadow-lg z-20 ${panelClassName || widthClassName}`}
style={{
background: 'var(--bg)',
borderColor: 'var(--border-muted)',
}}
>
<div className="max-h-64 overflow-auto">
{children({ close })}
</div>
</div>
)}
</div>
);
};
@@ -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 <span className="opacity-60 text-base">{placeholder}</span>;
}
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 (
<Dropdown
label={label}
summary={renderSummary()}
align={align}
widthClassName={widthClassName}
buttonClassName={buttonClassName}
>
{({ close }) => (
<div role="listbox" aria-multiselectable={multiple}>
{options.map(option => (
<button
type="button"
key={option.value}
className={`w-full px-3 py-2 text-left text-base flex items-center gap-2 hover:bg-gray-100 dark:hover:bg-gray-800 ${
option.disabled ? 'opacity-50 cursor-not-allowed' : ''
}`}
onClick={() => handleOptionClick(option, close)}
disabled={option.disabled}
>
{checkboxEnabled && (
<input
type="checkbox"
checked={selectedValues.includes(option.value)}
readOnly
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500 pointer-events-none"
/>
)}
{option.icon}
<div className="flex flex-col">
<span className="text-base">{option.label}</span>
{option.description && (
<span className="text-xs opacity-70">{option.description}</span>
)}
</div>
</button>
))}
</div>
)}
</Dropdown>
);
};
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 [];
};
+15 -13
View File
@@ -6,7 +6,7 @@ interface FooterProps {
export const Footer = ({ buildVersion, releaseVersion, appEnv }: FooterProps) => {
return (
<footer className="mt-10 border-t pt-6 pb-10" style={{ borderColor: 'var(--border-muted)', paddingBottom: 'calc(2.5rem + env(safe-area-inset-bottom))' }}>
<footer className="mt-10 border-t pt-6 pb-10" style={{ borderColor: 'var(--border-muted)' }}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-between">
<div>
<p className="text-sm opacity-80">Calibre Web Book Downloader</p>
@@ -15,19 +15,21 @@ export const Footer = ({ buildVersion, releaseVersion, appEnv }: FooterProps) =>
{appEnv || 'development'}
</p>
</div>
<a
href="https://github.com/calibrain/calibre-web-automated-book-downloader"
className="opacity-80 hover:opacity-100"
aria-label="GitHub"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
className="w-6 h-6"
<div className="flex items-center gap-4">
<a
href="https://github.com/calibrain/calibre-web-automated-book-downloader"
className="opacity-80 hover:opacity-100"
aria-label="GitHub"
>
<path d="M12 1C5.923 1 1 5.923 1 12c0 4.867 3.149 8.979 7.521 10.436.55.096.756-.233.756-.522 0-.262-.013-1.128-.013-2.049-2.764.509-3.479-.674-3.699-1.292-.124-.317-.66-1.293-1.127-1.554-.385-.207-.936-.715-.014-.729.866-.014 1.485.797 1.691 1.128.99 1.663 2.571 1.196 3.204.907.096-.715.385-1.196.701-1.471-2.448-.275-5.005-1.224-5.005-5.432 0-1.196.426-2.186 1.128-2.956-.111-.275-.496-1.402.11-2.915 0 0 .921-.288 3.024 1.128a10.193 10.193 0 0 1 2.75-.371c.936 0 1.871.123 2.75.371 2.104-1.43 3.025-1.128 3.025-1.128.605 1.513.221 2.64.111 2.915.701.77 1.127 1.747 1.127 2.956 0 4.222-2.571 5.157-5.019 5.432.399.344.743 1.004.743 2.035 0 1.471-.014 2.654-.014 3.025 0 .289.206.632.756.522C19.851 20.979 23 16.854 23 12c0-6.077-4.922-11-11-11Z"></path>
</svg>
</a>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
className="w-6 h-6"
>
<path d="M12 1C5.923 1 1 5.923 1 12c0 4.867 3.149 8.979 7.521 10.436.55.096.756-.233.756-.522 0-.262-.013-1.128-.013-2.049-2.764.509-3.479-.674-3.699-1.292-.124-.317-.66-1.293-1.127-1.554-.385-.207-.936-.715-.014-.729.866-.014 1.485.797 1.691 1.128.99 1.663 2.571 1.196 3.204.907.096-.715.385-1.196.701-1.471-2.448-.275-5.005-1.224-5.005-5.432 0-1.196.426-2.186 1.128-2.956-.111-.275-.496-1.402.11-2.915 0 0 .921-.288 3.024 1.128a10.193 10.193 0 0 1 2.75-.371c.936 0 1.871.123 2.75.371 2.104-1.43 3.025-1.128 3.025-1.128.605 1.513.221 2.64.111 2.915.701.77 1.127 1.747 1.127 2.956 0 4.222-2.571 5.157-5.019 5.432.399.344.743 1.004.743 2.035 0 1.471-.014 2.654-.014 3.025 0 .289.206.632.756.522C19.851 20.979 23 16.854 23 12c0-6.077-4.922-11-11-11Z"></path>
</svg>
</a>
</div>
</div>
</footer>
);
+206 -86
View File
@@ -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<string>('auto');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [shouldAnimateIn, setShouldAnimateIn] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(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 = () => (
<div className="flex items-center gap-2">
{/* Calibre-Web Button */}
{calibreWebUrl && (
<a
href={calibreWebUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 text-gray-900 dark:text-gray-100"
aria-label="Open Calibre-Web"
title="Go To Library"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
<span className="text-sm font-medium">Go To Library</span>
</a>
)}
{/* Downloads Button */}
{onDownloadsClick && (
<button
onClick={onDownloadsClick}
className="relative p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
className="relative flex items-center gap-2 px-3 py-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 text-gray-900 dark:text-gray-100"
aria-label="View downloads"
title="Downloads"
>
<div className="relative">
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
{/* Show badge with appropriate color based on status */}
{(statusCounts.ongoing > 0 || statusCounts.completed > 0 || statusCounts.errored > 0) && (
<span
className={`absolute -top-1 -right-1 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center ${
statusCounts.errored > 0
? 'bg-red-500'
: statusCounts.ongoing > 0
? 'bg-blue-500'
: 'bg-green-500'
}`}
title={`${statusCounts.ongoing} ongoing, ${statusCounts.completed} completed, ${statusCounts.errored} failed`}
>
{statusCounts.ongoing + statusCounts.completed + statusCounts.errored}
</span>
)}
</div>
<span className="hidden sm:inline text-sm font-medium">Downloads</span>
</button>
)}
{/* User Menu Dropdown */}
<div className="relative" ref={dropdownRef}>
<button
onClick={toggleDropdown}
className={`relative p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
isDropdownOpen ? 'bg-gray-100 dark:bg-gray-700' : ''
}`}
aria-label="User menu"
aria-expanded={isDropdownOpen}
aria-haspopup="true"
>
<svg
className="w-5 h-5"
@@ -110,100 +233,97 @@ export const Header = ({
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
{/* Show badge with appropriate color based on status */}
{(statusCounts.ongoing > 0 || statusCounts.completed > 0 || statusCounts.errored > 0) && (
<span
className={`absolute -top-1 -right-1 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center ${
statusCounts.errored > 0
? 'bg-red-500'
: statusCounts.ongoing > 0
? 'bg-blue-500'
: 'bg-green-500'
}`}
title={`${statusCounts.ongoing} ongoing, ${statusCounts.completed} completed, ${statusCounts.errored} failed`}
>
{statusCounts.ongoing + statusCounts.completed + statusCounts.errored}
</span>
)}
</button>
)}
{/* Theme Toggle Button */}
<button
onClick={cycleTheme}
className="relative p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label={`Current theme: ${theme}. Click to cycle`}
title={`Theme: ${theme.charAt(0).toUpperCase() + theme.slice(1)}`}
>
{theme === 'light' && (
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
)}
{theme === 'dark' && (
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
)}
{theme === 'auto' && (
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25" />
</svg>
)}
</button>
{/* Dropdown Menu */}
{(isDropdownOpen || isClosing) && (
<div
className={`absolute right-0 mt-2 w-48 rounded-lg shadow-lg border z-50 ${
isClosing ? 'animate-fade-out-up' : shouldAnimateIn ? 'animate-fade-in-down' : ''
}`}
style={{
background: 'var(--bg)',
borderColor: 'var(--border-muted)',
}}
>
<div className="py-1">
{/* Theme Button */}
<button
type="button"
onClick={cycleTheme}
className="w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-3"
>
{theme === 'light' && (
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
)}
{theme === 'dark' && (
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
)}
{theme === 'auto' && (
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25" />
</svg>
)}
<span>Theme: {theme.charAt(0).toUpperCase() + theme.slice(1)}</span>
</button>
{/* Calibre-Web Button */}
{calibreWebUrl && (
<a
href={calibreWebUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label="Open Calibre-Web"
title="Go To Library"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
<span className="text-sm font-medium">Go To Library</span>
</a>
)}
{/* Debug Buttons */}
{debug && (
<>
<form action="/request/debug" method="get" className="w-full">
<button
className="w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-3 text-orange-600 dark:text-orange-400"
type="submit"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 12.75c1.148 0 2.278.08 3.383.237 1.037.146 1.866.966 1.866 2.013 0 3.728-2.35 6.75-5.25 6.75S6.75 18.728 6.75 15c0-1.046.83-1.867 1.866-2.013A24.204 24.204 0 0112 12.75zm0 0c2.883 0 5.647.508 8.207 1.44a23.91 23.91 0 01-1.152 6.06M12 12.75c-2.883 0-5.647.508-8.208 1.44.125 2.104.52 4.136 1.153 6.06M12 12.75a2.25 2.25 0 002.248-2.354M12 12.75a2.25 2.25 0 01-2.248-2.354M12 8.25c.995 0 1.971-.08 2.922-.236.403-.066.74-.358.795-.762a3.778 3.778 0 00-.399-2.25M12 8.25c-.995 0-1.97-.08-2.922-.236-.402-.066-.74-.358-.795-.762a3.734 3.734 0 01.4-2.253M12 8.25a2.25 2.25 0 00-2.248 2.146M12 8.25a2.25 2.25 0 012.248 2.146M8.683 5a6.032 6.032 0 01-1.155-1.002c.07-.63.27-1.222.574-1.747m.581 2.749A3.75 3.75 0 0115.318 5m0 0c.427-.283.815-.62 1.155-.999a4.471 4.471 0 00-.575-1.752M4.921 6a24.048 24.048 0 00-.392 3.314c1.668.546 3.416.914 5.223 1.082M19.08 6c.205 1.08.337 2.187.392 3.314a23.882 23.882 0 01-5.223 1.082" />
</svg>
<span>Debug</span>
</button>
</form>
<form action="/request/api/restart" method="get" className="w-full">
<button
className="w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-3 text-orange-600 dark:text-orange-400"
type="submit"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
<span>Restart</span>
</button>
</form>
</>
)}
{/* Debug Buttons */}
{debug && (
<>
<form action="/request/debug" method="get">
<button
className="p-2 rounded-full bg-red-600/80 hover:bg-red-600 text-white transition-colors"
type="submit"
title="Debug"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 12.75c1.148 0 2.278.08 3.383.237 1.037.146 1.866.966 1.866 2.013 0 3.728-2.35 6.75-5.25 6.75S6.75 18.728 6.75 15c0-1.046.83-1.867 1.866-2.013A24.204 24.204 0 0112 12.75zm0 0c2.883 0 5.647.508 8.207 1.44a23.91 23.91 0 01-1.152 6.06M12 12.75c-2.883 0-5.647.508-8.208 1.44.125 2.104.52 4.136 1.153 6.06M12 12.75a2.25 2.25 0 002.248-2.354M12 12.75a2.25 2.25 0 01-2.248-2.354M12 8.25c.995 0 1.971-.08 2.922-.236.403-.066.74-.358.795-.762a3.778 3.778 0 00-.399-2.25M12 8.25c-.995 0-1.97-.08-2.922-.236-.402-.066-.74-.358-.795-.762a3.734 3.734 0 01.4-2.253M12 8.25a2.25 2.25 0 00-2.248 2.146M12 8.25a2.25 2.25 0 012.248 2.146M8.683 5a6.032 6.032 0 01-1.155-1.002c.07-.63.27-1.222.574-1.747m.581 2.749A3.75 3.75 0 0115.318 5m0 0c.427-.283.815-.62 1.155-.999a4.471 4.471 0 00-.575-1.752M4.921 6a24.048 24.048 0 00-.392 3.314c1.668.546 3.416.914 5.223 1.082M19.08 6c.205 1.08.337 2.187.392 3.314a23.882 23.882 0 01-5.223 1.082" />
</svg>
</button>
</form>
<form action="/request/api/restart" method="get">
<button
className="p-2 rounded-full bg-red-600 hover:bg-red-700 text-white transition-colors"
type="submit"
title="Restart"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
</button>
</form>
</>
)}
{/* Logout Button */}
{authRequired && isAuthenticated && onLogout && (
<button
type="button"
onClick={handleLogout}
className="w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-3 text-red-600 dark:text-red-400"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg>
<span>Sign Out</span>
</button>
)}
</div>
</div>
)}
</div>
</div>
);
return (
<header className="w-full sticky top-0 z-40 backdrop-blur-sm header-with-fade" style={{ background: 'var(--bg)', paddingTop: 'env(safe-area-inset-top)' }}>
<header className="w-full sticky top-0 z-40 backdrop-blur-sm header-with-fade" style={{ background: 'var(--bg)' }}>
<div className={`max-w-full mx-auto px-4 sm:px-6 lg:px-8 transition-all duration-500 ${
showSearch ? 'h-auto py-4' : 'h-24'
}`}>
@@ -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 (
<DropdownList
label={label}
options={optionList}
multiple
showCheckboxes
value={displayedValue}
onChange={handleChange}
placeholder={placeholder}
summaryFormatter={summaryFormatter}
keepOpenOnSelect
/>
);
};
+231
View File
@@ -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 = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-5 h-5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
);
const EyeSlashIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-5 h-5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"
/>
</svg>
);
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<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (autoFocus) {
usernameRef.current?.focus();
}
}, [autoFocus]);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
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<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
passwordRef.current?.focus();
}
};
return (
<div>
{error && (
<div className="mb-4 p-3 rounded-lg text-sm bg-red-600 text-white">
{error}
</div>
)}
<form
method="post"
action="/api/login"
autoComplete="on"
id="login-form"
name="login"
data-form-type="login"
onSubmit={handleSubmit}
>
<div className="mb-4">
<label htmlFor="username" className="block text-sm font-medium mb-2">
Username
</label>
<input
ref={usernameRef}
type="text"
id="username"
name="username"
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
inputMode="text"
enterKeyHint="next"
value={username}
onChange={(event) => 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
/>
</div>
<div className="mb-4">
<label htmlFor="password" className="block text-sm font-medium mb-2">
Password
</label>
<div className="relative">
<input
ref={passwordRef}
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
autoComplete="current-password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
inputMode="text"
enterKeyHint="go"
value={password}
onChange={(event) => 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
/>
<button
type="button"
onClick={() => setShowPassword((current) => !current)}
disabled={isLoading}
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeSlashIcon /> : <EyeIcon />}
</button>
</div>
</div>
<div className="mb-6 flex items-center">
<input
type="checkbox"
id="remember-me"
name="remember_me"
checked={rememberMe}
onChange={(event) => 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)' }}
/>
<label htmlFor="remember-me" className="ml-2 text-sm">
Remember me for 7 days
</label>
</div>
<button
type="submit"
name="submit"
disabled={isLoading}
className="w-full py-2.5 px-4 rounded-lg font-medium text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed bg-sky-700 hover:bg-sky-800 disabled:hover:bg-sky-700"
aria-label="Sign in"
>
{isLoading ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Signing in...
</span>
) : (
'Sign In'
)}
</button>
</form>
</div>
);
};
+236 -25
View File
@@ -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<void>;
onDownload: (book: Book) => Promise<void>;
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 (
<section id="results-section" className="mb-8">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-semibold animate-fade-in-up">Search Results</h2>
</div>
<div
id="results-grid"
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8 items-stretch"
>
{books.map((book, index) => (
<div
key={book.id}
className="animate-slide-up"
style={{
animationDelay: `${index * 50}ms`,
animationFillMode: 'both',
}}
<section id="results-section" className="mb-4 sm:mb-8 w-full">
<div className="flex items-center justify-between mb-2 sm:mb-3">
<SortControl value={sortValue} onChange={onSortChange} />
{/* View toggle buttons - Desktop: show all 3, Mobile: show Compact and List only */}
<div className="flex items-center gap-2">
{isDesktop && (
<button
onClick={() => setViewMode('card')}
className={`p-2 rounded-full transition-all duration-200 ${
viewMode === 'card'
? 'text-white bg-sky-700 hover:bg-sky-800'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100'
}`}
title="Card view"
aria-label="Card view"
aria-pressed={viewMode === 'card'}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth="1.5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z"
/>
</svg>
</button>
)}
<button
onClick={() => setViewMode('compact')}
className={`p-2 rounded-full transition-all duration-200 ${
viewMode === 'compact'
? 'text-white bg-sky-700 hover:bg-sky-800'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100'
}`}
title="Compact view"
aria-label="Compact view"
aria-pressed={viewMode === 'compact'}
>
<BookCard
book={book}
onDetails={onDetails}
onDownload={onDownload}
buttonState={getButtonState(book.id)}
/>
</div>
))}
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth="1.5"
>
<rect x="3.75" y="4.5" width="6" height="6" rx="1.125" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6h8.25M12 8.25h6" />
<rect x="3.75" y="13.5" width="6" height="6" rx="1.125" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15h8.25M12 17.25h6" />
</svg>
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded-full transition-all duration-200 ${
viewMode === 'list'
? 'text-white bg-sky-700 hover:bg-sky-800'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100'
}`}
title="List view"
aria-label="List view"
aria-pressed={viewMode === 'list'}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth="1.5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"
/>
</svg>
</button>
</div>
</div>
{viewMode === 'list' ? (
<ListView books={books} onDetails={onDetails} onDownload={onDownload} getButtonState={getButtonState} />
) : (
<div
id="results-grid"
className={`grid gap-8 ${
!isDesktop
? 'grid-cols-1 items-start'
: viewMode === 'card'
? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 items-stretch'
: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 items-start'
}`}
>
{books.map((book, index) => {
const shouldUseCardLayout = isDesktop && viewMode === 'card';
const animationDelay = index * 50;
return shouldUseCardLayout ? (
<CardView
key={book.id}
book={book}
onDetails={onDetails}
onDownload={onDownload}
buttonState={getButtonState(book.id)}
animationDelay={animationDelay}
/>
) : (
<CompactView
key={book.id}
book={book}
onDetails={onDetails}
onDownload={onDownload}
buttonState={getButtonState(book.id)}
showDetailsButton={!isDesktop}
animationDelay={animationDelay}
/>
);
})}
</div>
)}
{books.length === 0 && (
<div className="mt-4 text-sm opacity-80">No results found.</div>
)}
</section>
);
};
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 (
<Dropdown
align="left"
widthClassName="w-60 sm:w-72"
renderTrigger={({ isOpen, toggle }) => (
<button
type="button"
onClick={toggle}
className={`relative flex items-center gap-2 px-3 py-2 rounded-full transition-all duration-200 text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700 ${
isOpen ? 'bg-gray-100 dark:bg-gray-700' : ''
} animate-fade-in-up`}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-label="Change sort order"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-5 h-5 sm:w-6 sm:h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 7.5 7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5"
/>
</svg>
<span className="text-sm font-medium whitespace-nowrap">{selected.label}</span>
</button>
)}
>
{({ close }) => (
<div role="listbox" aria-label="Sort results">
{SORT_OPTIONS.map(option => {
const isSelected = option.value === selected.value;
return (
<button
type="button"
key={option.value || 'default'}
className={`w-full px-3 py-2 text-left text-base flex items-center justify-between gap-2 hover:bg-gray-100 dark:hover:bg-gray-800 ${
isSelected ? 'text-sky-600 dark:text-sky-300 font-medium' : ''
}`}
onClick={() => {
onChange(option.value);
close();
}}
role="option"
aria-selected={isSelected}
>
<span>{option.label}</span>
{isSelected && (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-4 h-4"
>
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
)}
</button>
);
})}
</div>
)}
</Dropdown>
);
};
+48 -98
View File
@@ -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<AdvancedFilterState>) => 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<string[]>(
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'
}`}
>
<div className={`flex items-center justify-center gap-3 mb-8 transition-all duration-300 ${
isInitialState ? 'opacity-100' : 'opacity-0 h-0 mb-0 overflow-hidden'
<div className={`flex items-center justify-center gap-3 transition-all duration-300 ${
isInitialState ? 'opacity-100 mb-6 sm:mb-8' : 'opacity-0 h-0 mb-0 overflow-hidden'
}`}>
<img src={logoUrl} alt="Logo" className="h-8 w-8" />
<h1 className="text-2xl font-semibold">Book Search & Download</h1>
@@ -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 })}
/>
</div>
<div>
@@ -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 })}
/>
</div>
<div>
@@ -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 })}
/>
</div>
<div>
<label htmlFor="lang-input" className="block text-sm mb-1 opacity-80">
Language
</label>
<select
id="lang-input"
className="w-full px-3 py-2 rounded-md border"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={lang}
onChange={e => setLang(e.target.value)}
>
<option value="all">All</option>
{bookLanguages.map(l => (
<option key={l.code} value={l.code}>
{l.language}
</option>
))}
</select>
</div>
<div>
<label htmlFor="sort-input" className="block text-sm mb-1 opacity-80">
Sort
</label>
<select
id="sort-input"
className="w-full px-3 py-2 rounded-md border"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={sort}
onChange={e => setSort(e.target.value)}
>
<option value="">Most relevant</option>
<option value="newest">Newest (publication year)</option>
<option value="oldest">Oldest (publication year)</option>
<option value="largest">Largest (filesize)</option>
<option value="smallest">Smallest (filesize)</option>
<option value="newest_added">Newest (open sourced)</option>
<option value="oldest_added">Oldest (open sourced)</option>
</select>
</div>
<div>
<label htmlFor="content-input" className="block text-sm mb-1 opacity-80">
Content
</label>
<select
id="content-input"
className="w-full px-3 py-2 rounded-md border"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={content}
onChange={e => setContent(e.target.value)}
>
<option value="">All</option>
<option value="book_nonfiction">Book (non-fiction)</option>
<option value="book_fiction">Book (fiction)</option>
<option value="book_unknown">Book (unknown)</option>
<option value="magazine">Magazine</option>
<option value="book_comic">Comic Book</option>
<option value="standards_document">Standards document</option>
<option value="other">Other</option>
<option value="musical_score">Musical score</option>
<option value="audiobook">Audiobook</option>
</select>
</div>
<LanguageMultiSelect
options={bookLanguages}
value={lang}
onChange={handleLanguageChange}
defaultLanguageCodes={defaultLanguage}
label="Language"
/>
<DropdownList
label="Content"
options={CONTENT_OPTIONS}
value={content}
onChange={handleContentChange}
placeholder="All"
/>
<div className="md:col-span-2 lg:col-span-3">
<label className="block text-sm mb-1 opacity-80">Formats</label>
<div className="flex flex-wrap gap-3 text-sm">
+3 -1
View File
@@ -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';
@@ -0,0 +1,151 @@
import { useState } from 'react';
import { Book, ButtonStateInfo } from '../../types';
import { BookDownloadButton } from '../BookDownloadButton';
const SkeletonLoader = () => (
<div className="w-full h-full bg-gradient-to-r from-gray-300 via-gray-200 to-gray-300 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
);
interface CardViewProps {
book: Book;
onDetails: (id: string) => Promise<void>;
onDownload: (book: Book) => Promise<void>;
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 (
<article
className="book-card overflow-hidden flex flex-col sm:flex-col max-sm:flex-row space-between w-full sm:max-w-[292px] max-sm:h-[180px] h-full transition-shadow duration-300 animate-slide-up"
style={{
background: 'var(--bg-soft)',
borderRadius: '.75rem',
boxShadow: isHovered ? '0 10px 30px rgba(0, 0, 0, 0.15)' : 'none',
animationDelay: `${animationDelay}ms`,
animationFillMode: 'both',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative w-full sm:w-full max-sm:w-[120px] max-sm:h-full max-sm:flex-shrink-0 group" style={{ aspectRatio: '2/3' }}>
{book.preview && !imageError ? (
<>
{!imageLoaded && (
<div className="absolute inset-0">
<SkeletonLoader />
</div>
)}
<img
src={book.preview}
alt={book.title || 'Book cover'}
className="w-full h-full"
style={{
opacity: imageLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
objectPosition: 'top',
}}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center text-sm opacity-50" style={{ background: 'var(--border-muted)' }}>
No Cover
</div>
)}
<div
className="absolute inset-0 bg-white transition-opacity duration-300 pointer-events-none"
style={{ opacity: isHovered ? 0.02 : 0 }}
/>
<button
className="absolute bottom-2 right-2 w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110 max-sm:hidden"
style={{
opacity: isHovered || isLoadingDetails ? 1 : 0,
pointerEvents: isHovered || isLoadingDetails ? 'auto' : 'none',
}}
onClick={(e) => {
e.stopPropagation();
handleDetails(book.id);
}}
disabled={isLoadingDetails}
aria-label="Book details"
>
{isLoadingDetails ? (
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
</button>
</div>
<div className="p-4 max-sm:p-3 max-sm:py-2 flex flex-col gap-3 max-sm:gap-2 max-sm:flex-1 max-sm:justify-between max-sm:min-w-0 sm:flex-1 sm:flex sm:flex-col sm:justify-end">
<div className="space-y-1 max-sm:space-y-0.5 max-sm:min-w-0">
<h3 className="font-semibold leading-tight line-clamp-2 text-base max-sm:line-clamp-3 max-sm:min-w-0" title={book.title || 'Untitled'}>
{book.title || 'Untitled'}
</h3>
<p className="text-sm max-sm:text-xs opacity-80 truncate max-sm:min-w-0">{book.author || 'Unknown author'}</p>
<div className="text-xs max-sm:text-[10px] opacity-70 flex flex-wrap gap-2 max-sm:gap-1">
<span>{book.year || '-'}</span>
<span></span>
<span>{book.language || '-'}</span>
<span></span>
<span>{book.format || '-'}</span>
{book.size && (
<>
<span></span>
<span>{book.size}</span>
</>
)}
</div>
</div>
<div className="flex gap-1.5 sm:hidden">
<button
className="px-2 py-1.5 rounded border text-xs flex-1 flex items-center justify-center gap-1"
onClick={() => handleDetails(book.id)}
style={{ borderColor: 'var(--border-muted)' }}
disabled={isLoadingDetails}
>
<span className="details-button-text">{isLoadingDetails ? 'Loading' : 'Details'}</span>
<div
className={`details-spinner w-3 h-3 border-2 border-current border-t-transparent rounded-full ${isLoadingDetails ? '' : 'hidden'}`}
/>
</button>
<BookDownloadButton buttonState={buttonState} onDownload={() => onDownload(book)} size="sm" className="flex-1" />
</div>
</div>
<BookDownloadButton
buttonState={buttonState}
onDownload={() => onDownload(book)}
className="hidden sm:flex rounded-none"
fullWidth
style={{
borderBottomLeftRadius: '.75rem',
borderBottomRightRadius: '.75rem',
}}
/>
</article>
);
};
@@ -0,0 +1,146 @@
import { useState } from 'react';
import { Book, ButtonStateInfo } from '../../types';
import { BookDownloadButton } from '../BookDownloadButton';
const SkeletonLoader = () => (
<div className="w-full h-full bg-gradient-to-r from-gray-300 via-gray-200 to-gray-300 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
);
interface CompactViewProps {
book: Book;
onDetails: (id: string) => Promise<void>;
onDownload: (book: Book) => Promise<void>;
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 (
<article
className="book-card overflow-hidden !flex !flex-row w-full !h-[180px] transition-shadow duration-300 animate-slide-up"
style={{
background: 'var(--bg-soft)',
borderRadius: '.75rem',
boxShadow: isHovered ? '0 10px 30px rgba(0, 0, 0, 0.15)' : 'none',
animationDelay: `${animationDelay}ms`,
animationFillMode: 'both',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative w-[120px] h-full flex-shrink-0">
{book.preview && !imageError ? (
<>
{!imageLoaded && (
<div className="absolute inset-0">
<SkeletonLoader />
</div>
)}
<img
src={book.preview}
alt={book.title || 'Book cover'}
className="w-full h-full"
style={{
opacity: imageLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
objectPosition: 'top',
}}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center text-sm opacity-50" style={{ background: 'var(--border-muted)' }}>
No Cover
</div>
)}
<div className="absolute inset-0 bg-white transition-opacity duration-300 pointer-events-none" style={{ opacity: isHovered ? 0.02 : 0 }} />
{!showDetailsButton && (
<button
className="absolute bottom-2 right-2 w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
style={{
opacity: isHovered || isLoadingDetails ? 1 : 0,
pointerEvents: isHovered || isLoadingDetails ? 'auto' : 'none',
}}
onClick={(e) => {
e.stopPropagation();
handleDetails(book.id);
}}
disabled={isLoadingDetails}
aria-label="Book details"
>
{isLoadingDetails ? (
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
</button>
)}
</div>
<div className="p-3 py-2 flex flex-col flex-1 min-w-0">
<div className="space-y-0.5 min-w-0">
<h3 className="font-semibold leading-tight line-clamp-3 text-base min-w-0" title={book.title || 'Untitled'}>
{book.title || 'Untitled'}
</h3>
<p className="text-xs opacity-80 truncate min-w-0">{book.author || 'Unknown author'}</p>
<div className="text-[10px] opacity-70">
<span>{book.year || '-'}</span>
</div>
</div>
<div className="mt-auto flex flex-col gap-2">
<div className="text-[10px] opacity-70 flex flex-wrap gap-1">
<span>{book.language || '-'}</span>
<span></span>
<span>{book.format || '-'}</span>
{book.size && (
<>
<span></span>
<span>{book.size}</span>
</>
)}
</div>
{showDetailsButton ? (
<div className="flex gap-1.5">
<button
className="px-2 py-1.5 rounded border text-xs flex-shrink-0 flex items-center justify-center gap-1"
onClick={() => handleDetails(book.id)}
style={{ borderColor: 'var(--border-muted)' }}
disabled={isLoadingDetails}
>
<span className="details-button-text">{isLoadingDetails ? 'Loading' : 'Details'}</span>
{isLoadingDetails && <div className="w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin" />}
</button>
<BookDownloadButton buttonState={buttonState} onDownload={() => onDownload(book)} size="sm" className="flex-1" />
</div>
) : (
<BookDownloadButton buttonState={buttonState} onDownload={() => onDownload(book)} size="sm" fullWidth />
)}
</div>
</div>
</article>
);
};
@@ -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<void>;
onDownload: (book: Book) => Promise<void>;
getButtonState: (bookId: string) => ButtonStateInfo;
}
interface ListViewDownloadButtonProps {
buttonState: ButtonStateInfo;
onDownload: () => Promise<void>;
}
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 (
<button
className={`${baseClasses} ${stateClasses}`}
onClick={handleDownload}
disabled={isDisabled || isInProgress}
data-action="download"
aria-label={buttonState.text}
>
{isCompleted ? (
<svg className="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : hasError ? (
<svg className="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
) : showCircularProgress ? (
<>
<CircularProgress
progress={buttonState.progress}
size={16}
className="block sm:hidden"
/>
<CircularProgress
progress={buttonState.progress}
size={20}
className="hidden sm:block"
/>
</>
) : showSpinner ? (
<div className="w-4 h-4 sm:w-5 sm:h-5 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
)}
</button>
);
};
const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: string }) => {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
if (!preview || imageError) {
return (
<div
className="w-7 h-10 sm:w-10 sm:h-14 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] sm:text-[9px] font-medium text-gray-500 dark:text-gray-300"
aria-label="No cover available"
>
No Cover
</div>
);
}
return (
<div className="relative w-7 h-10 sm:w-10 sm:h-14 rounded overflow-hidden bg-gray-100 dark:bg-gray-800 border border-white/40 dark:border-gray-700/70">
{!imageLoaded && (
<div className="absolute inset-0 bg-gradient-to-r from-gray-200 via-gray-100 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
)}
<img
src={preview}
alt={title || 'Book cover'}
className="w-full h-full object-cover object-top"
loading="lazy"
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
style={{ opacity: imageLoaded ? 1 : 0, transition: 'opacity 0.2s ease-in-out' }}
/>
</div>
);
};
const getLanguageColor = (language?: string): string => {
if (!language || language === '-') return 'bg-gray-400 dark:bg-gray-600';
const lang = language.toLowerCase();
const colorMap: Record<string, string> = {
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<string, string> = {
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<string | null>(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 (
<article
className="w-full overflow-hidden rounded-lg sm:rounded-2xl"
style={{
background: 'var(--bg-soft)',
boxShadow: '0 10px 30px rgba(15, 23, 42, 0.08)',
}}
role="region"
aria-label="List view of books"
>
<div className="divide-y divide-gray-200/60 dark:divide-gray-800/60 w-full">
{books.map((book, index) => {
const buttonState = getButtonState(book.id);
const isLoadingDetails = detailsLoadingId === book.id;
return (
<div
key={book.id}
className="px-1.5 sm:px-2 py-1.5 sm:py-2 transition-colors duration-200 hover:bg-white/60 dark:hover:bg-gray-800/40 w-full animate-slide-up"
style={{
animationDelay: `${index * 50}ms`,
animationFillMode: 'both',
}}
role="article"
>
{/* Mobile and Desktop: Single row layout */}
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto_auto] sm:grid-cols-[auto_minmax(0,2fr)_minmax(50px,0.25fr)_minmax(60px,0.3fr)_minmax(60px,0.3fr)_minmax(60px,0.3fr)_auto] items-center gap-2 sm:gap-y-1 sm:gap-x-0.5 w-full">
{/* Thumbnail */}
<div className="flex items-center pl-1 sm:pl-3">
<ListViewThumbnail preview={book.preview} title={book.title} />
</div>
{/* Title and Author */}
<div className="min-w-0 flex flex-col justify-center sm:pl-3">
<h3 className="font-semibold text-xs min-[400px]:text-sm sm:text-base leading-tight line-clamp-1 sm:line-clamp-2" title={book.title || 'Untitled'}>
{book.title || 'Untitled'}
</h3>
<p className="text-[10px] min-[400px]:text-xs sm:text-sm text-gray-600 dark:text-gray-300 truncate">
{book.author || 'Unknown author'}
{book.year && <span className="sm:hidden"> {book.year}</span>}
</p>
</div>
{/* Format and Size - Mobile only */}
<div className="flex sm:hidden flex-col items-end text-[10px] opacity-70 leading-tight">
<span>{book.format || '-'}</span>
{book.size && <span>{book.size}</span>}
</div>
{/* Year - Desktop only */}
<div className="hidden sm:flex text-xs text-gray-700 dark:text-gray-200 justify-center">
{book.year || '-'}
</div>
{/* Language Badge - Desktop only */}
<div className="hidden sm:flex justify-center">
<span
className={`${getLanguageColor(book.language)} text-white text-[11px] font-semibold px-2 py-0.5 rounded uppercase tracking-wide`}
title={book.language || 'Unknown'}
>
{book.language || '-'}
</span>
</div>
{/* Format Badge - Desktop only */}
<div className="hidden sm:flex justify-center">
<span
className={`${getFormatColor(book.format)} text-white text-[11px] font-semibold px-2 py-0.5 rounded uppercase tracking-wide`}
title={book.format || 'Unknown'}
>
{book.format || '-'}
</span>
</div>
{/* Size - Desktop only */}
<div className="hidden sm:flex text-xs text-gray-700 dark:text-gray-200 justify-center">
{book.size || '-'}
</div>
{/* Action Buttons */}
<div className="flex flex-row justify-end gap-0.5 sm:gap-1">
<button
className="flex items-center justify-center p-1.5 sm:p-2 rounded-full text-gray-600 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200"
onClick={() => handleDetails(book.id)}
disabled={isLoadingDetails}
aria-label={`View details for ${book.title || 'this book'}`}
>
{isLoadingDetails ? (
<div className="w-4 h-4 sm:w-5 sm:h-5 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z" />
</svg>
)}
</button>
<ListViewDownloadButton
buttonState={buttonState}
onDownload={() => onDownload(book)}
/>
</div>
</div>
</div>
);
})}
</div>
</article>
);
};
+23
View File
@@ -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' },
];
+4 -1
View File
@@ -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(
<StrictMode>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);
+36
View File
@@ -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 (
<div
className="min-h-screen flex items-center justify-center px-4 py-8"
style={{ backgroundColor: 'var(--background-color)', color: 'var(--text-color)' }}
>
<div className="w-full max-w-md">
<div className="text-center mb-8">
<img src="/logo.png" alt="Logo" className="mx-auto mb-6 w-20 h-20" />
<h1 className="text-2xl font-semibold">Sign in to continue</h1>
</div>
<div
className="rounded-lg shadow-2xl p-8 border"
style={{
backgroundColor: 'var(--card-background)',
borderColor: 'var(--border-color)',
color: 'var(--text-color)',
}}
>
<LoginForm onSubmit={onLogin} error={error} isLoading={isLoading} />
</div>
</div>
</div>
);
};
+63 -10
View File
@@ -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<T>(url: string, opts: RequestInit = {}): Promise<T> {
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<StatusData> => {
};
export const cancelDownload = async (id: string): Promise<void> => {
await fetch(`${API.cancelDownload}/${encodeURIComponent(id)}/cancel`, { method: 'DELETE' });
await fetchJSON(`${API.cancelDownload}/${encodeURIComponent(id)}/cancel`, { method: 'DELETE' });
};
export const clearCompleted = async (): Promise<void> => {
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<AppConfig> => {
return fetchJSON<AppConfig>(API.config);
};
// Authentication functions
export const login = async (credentials: LoginCredentials): Promise<AuthResponse> => {
return fetchJSON<AuthResponse>(API.login, {
method: 'POST',
body: JSON.stringify(credentials),
});
};
export const logout = async (): Promise<AuthResponse> => {
return fetchJSON<AuthResponse>(API.logout, {
method: 'POST',
});
};
export const checkAuth = async (): Promise<AuthResponse> => {
return fetchJSON<AuthResponse>(API.authCheck);
};
+44 -105
View File
@@ -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 */
+25 -1
View File
@@ -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;
}
+89
View File
@@ -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<string>();
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<string>();
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})`;
};
+1
View File
@@ -4,6 +4,7 @@ export default {
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {},
},