mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 18:40:29 +01:00
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:
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 |
@@ -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/'):
|
||||
|
||||
@@ -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/"))
|
||||
|
||||
Generated
+51
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 [];
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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})`;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ export default {
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: ['selector', '[data-theme="dark"]'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user