Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17057ecfbe | ||
|
|
2b831dcfa5 | ||
|
|
78c61e88b3 | ||
|
|
57d85d0748 | ||
|
|
6492bd6a3c | ||
|
|
ed88aac5d5 | ||
|
|
5751910426 | ||
|
|
b02ad7452c | ||
|
|
289666aeef | ||
|
|
cc30d24144 | ||
|
|
50e53a13b0 | ||
|
|
a46d302ba8 | ||
|
|
c5d22e0f91 | ||
|
|
03321a5435 | ||
|
|
6aed906dfe | ||
|
|
742da1c43a | ||
|
|
8ea2fee0bb | ||
|
|
1c24312eb0 |
@@ -37,3 +37,13 @@ dist/
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
|
||||
# Frontend build artifacts (built in separate stage)
|
||||
src/frontend/node_modules/
|
||||
src/frontend/dist/
|
||||
src/frontend/.vite/
|
||||
|
||||
# Old frontend code (replaced by src/frontend)
|
||||
templates/
|
||||
static/css/
|
||||
static/js/
|
||||
|
||||
@@ -1,3 +1,28 @@
|
||||
ARG TARGETPLATFORM
|
||||
ARG TARGETARCH
|
||||
ARG BUILDPLATFORM
|
||||
ARG BUILDARCH
|
||||
|
||||
# Frontend build stage.
|
||||
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder
|
||||
|
||||
# Helpful debug output to see what platforms BuildKit thinks it's using
|
||||
RUN echo "BUILDPLATFORM=$BUILDPLATFORM BUILDARCH=$BUILDARCH TARGETPLATFORM=$TARGETPLATFORM TARGETARCH=$TARGETARCH"
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
# Copy frontend package files
|
||||
COPY src/frontend/package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy frontend source
|
||||
COPY src/frontend/ ./
|
||||
|
||||
# Build the frontend
|
||||
RUN npm run build
|
||||
|
||||
# Use python-slim as the base image
|
||||
FROM python:3.10-slim AS base
|
||||
|
||||
@@ -70,6 +95,9 @@ RUN pip install --no-cache-dir -r requirements-base.txt && \
|
||||
# Copy application code *after* dependencies are installed
|
||||
COPY . .
|
||||
|
||||
# Copy built frontend from frontend-builder stage
|
||||
COPY --from=frontend-builder /frontend/dist /app/frontend-dist
|
||||
|
||||
# Final setup: permissions and directories in one layer
|
||||
# Only creating directories and setting executable bits.
|
||||
# Ownership will be handled by the entrypoint script.
|
||||
@@ -82,7 +110,7 @@ EXPOSE ${FLASK_PORT}
|
||||
# Add healthcheck for container status
|
||||
# This will run as root initially, but check localhost which should work if the app binds correctly.
|
||||
HEALTHCHECK --interval=60s --timeout=60s --start-period=60s --retries=3 \
|
||||
CMD curl -s http://localhost:${FLASK_PORT}/request/api/status > /dev/null || exit 1
|
||||
CMD curl -s http://localhost:${FLASK_PORT}/api/status > /dev/null || exit 1
|
||||
|
||||
# Use dumb-init as the entrypoint to handle signals properly
|
||||
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
.PHONY: help install dev build preview typecheck clean up down docker-build refresh
|
||||
|
||||
# Frontend directory
|
||||
FRONTEND_DIR := src/frontend
|
||||
|
||||
# Docker compose file
|
||||
COMPOSE_FILE := docker-compose.dev.yml
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo ""
|
||||
@echo "Frontend:"
|
||||
@echo " install - Install frontend dependencies"
|
||||
@echo " dev - Start development server"
|
||||
@echo " build - Build frontend for production"
|
||||
@echo " preview - Preview production build"
|
||||
@echo " typecheck - Run TypeScript type checking"
|
||||
@echo " clean - Remove node_modules and build artifacts"
|
||||
@echo ""
|
||||
@echo "Backend (Docker):"
|
||||
@echo " up - Start backend services"
|
||||
@echo " down - Stop backend services"
|
||||
@echo " docker-build - Build Docker image"
|
||||
@echo " refresh - Rebuild and restart backend services"
|
||||
|
||||
# Install dependencies
|
||||
install:
|
||||
@echo "Installing frontend dependencies..."
|
||||
cd $(FRONTEND_DIR) && npm install
|
||||
|
||||
# Start development server
|
||||
dev:
|
||||
@echo "Starting development server..."
|
||||
cd $(FRONTEND_DIR) && npm run dev
|
||||
|
||||
# Build for production
|
||||
build:
|
||||
@echo "Building frontend for production..."
|
||||
cd $(FRONTEND_DIR) && npm run build
|
||||
|
||||
# Preview production build
|
||||
preview:
|
||||
@echo "Previewing production build..."
|
||||
cd $(FRONTEND_DIR) && npm run preview
|
||||
|
||||
# Type checking
|
||||
typecheck:
|
||||
@echo "Running TypeScript type checking..."
|
||||
cd $(FRONTEND_DIR) && npm run typecheck
|
||||
|
||||
# Clean build artifacts and dependencies
|
||||
clean:
|
||||
@echo "Cleaning build artifacts and dependencies..."
|
||||
rm -rf $(FRONTEND_DIR)/node_modules
|
||||
rm -rf $(FRONTEND_DIR)/dist
|
||||
|
||||
# Start backend services
|
||||
up:
|
||||
@echo "Starting backend services..."
|
||||
docker compose -f $(COMPOSE_FILE) up -d
|
||||
|
||||
# Stop backend services
|
||||
down:
|
||||
@echo "Stopping backend services..."
|
||||
docker compose -f $(COMPOSE_FILE) down
|
||||
|
||||
# Build Docker image
|
||||
docker-build:
|
||||
@echo "Building Docker image..."
|
||||
docker compose -f $(COMPOSE_FILE) build
|
||||
|
||||
# Rebuild and restart backend services
|
||||
refresh:
|
||||
@echo "Rebuilding and restarting backend services..."
|
||||
docker compose -f $(COMPOSE_FILE) down
|
||||
docker compose -f $(COMPOSE_FILE) build
|
||||
docker compose -f $(COMPOSE_FILE) up -d
|
||||
|
Before Width: | Height: | Size: 874 KiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 419 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 371 KiB |
@@ -3,20 +3,24 @@
|
||||
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, render_template, 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
|
||||
from werkzeug.security import check_password_hash
|
||||
from werkzeug.wrappers import Response
|
||||
from flask import url_for as flask_url_for
|
||||
import typing
|
||||
|
||||
from logger import setup_logger
|
||||
from config import _SUPPORTED_BOOK_LANGUAGE, BOOK_LANGUAGE, SUPPORTED_FORMATS
|
||||
from env import FLASK_HOST, FLASK_PORT, APP_ENV, CWA_DB_PATH, DEBUG, USING_EXTERNAL_BYPASSER, BUILD_VERSION, RELEASE_VERSION
|
||||
from env import FLASK_HOST, FLASK_PORT, APP_ENV, CWA_DB_PATH, DEBUG, USING_EXTERNAL_BYPASSER, BUILD_VERSION, RELEASE_VERSION, CALIBRE_WEB_URL
|
||||
import backend
|
||||
|
||||
from models import SearchFilters
|
||||
from websocket_manager import ws_manager
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
app = Flask(__name__)
|
||||
@@ -24,6 +28,116 @@ app.wsgi_app = ProxyFix(app.wsgi_app) # type: ignore
|
||||
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 # Disable caching
|
||||
app.config['APPLICATION_ROOT'] = '/'
|
||||
|
||||
# Determine async mode based on environment
|
||||
# In production with Gunicorn + gevent worker, use 'gevent'
|
||||
# In development with Flask dev server, use 'threading'
|
||||
if APP_ENV == 'prod':
|
||||
async_mode = 'gevent'
|
||||
else:
|
||||
async_mode = 'threading'
|
||||
|
||||
# Initialize Flask-SocketIO with reverse proxy support
|
||||
socketio = SocketIO(
|
||||
app,
|
||||
cors_allowed_origins="*",
|
||||
async_mode=async_mode,
|
||||
logger=False,
|
||||
engineio_logger=False,
|
||||
# Reverse proxy / Traefik compatibility settings
|
||||
path='/socket.io',
|
||||
ping_timeout=60, # Time to wait for pong response
|
||||
ping_interval=25, # Send ping every 25 seconds
|
||||
# Allow both websocket and polling for better compatibility
|
||||
transports=['websocket', 'polling'],
|
||||
# Enable CORS for all origins (you can restrict this in production)
|
||||
allow_upgrades=True,
|
||||
# Important for proxies that buffer
|
||||
http_compression=True
|
||||
)
|
||||
|
||||
# Initialize WebSocket manager
|
||||
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={
|
||||
r"/*": {
|
||||
"origins": ["http://localhost:5173", "http://127.0.0.1:5173"],
|
||||
"supports_credentials": True,
|
||||
"allow_headers": ["Content-Type", "Authorization"],
|
||||
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
||||
}
|
||||
})
|
||||
|
||||
# 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 requests
|
||||
if hasattr(record, 'getMessage'):
|
||||
message = record.getMessage()
|
||||
if 'GET /api/status' in message:
|
||||
return False
|
||||
return True
|
||||
|
||||
# Flask logger
|
||||
app.logger.handlers = logger.handlers
|
||||
app.logger.setLevel(logger.level)
|
||||
@@ -31,14 +145,34 @@ 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
|
||||
|
||||
# Secure cookie handling (HTTP vs HTTPS)
|
||||
# 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 in ['true', 'yes', '1']:
|
||||
SESSION_COOKIE_SECURE = True
|
||||
elif session_cookie_secure_env in ['false', 'no', '0']:
|
||||
SESSION_COOKIE_SECURE = False
|
||||
else:
|
||||
# Auto mode: align with deployment environment
|
||||
SESSION_COOKIE_SECURE = APP_ENV == 'prod'
|
||||
|
||||
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):
|
||||
@@ -46,80 +180,51 @@ 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
|
||||
|
||||
def register_dual_routes(app : Flask) -> None:
|
||||
"""
|
||||
Register each route both with and without the /request prefix.
|
||||
This function should be called after all routes are defined.
|
||||
"""
|
||||
# Store original url_map rules
|
||||
rules = list(app.url_map.iter_rules())
|
||||
|
||||
# Add /request prefix to each rule
|
||||
for rule in rules:
|
||||
if rule.rule != '/request/' and rule.rule != '/request': # Skip if it's already a request route
|
||||
# Create new routes with /request prefix, both with and without trailing slash
|
||||
base_rule = rule.rule[:-1] if rule.rule.endswith('/') else rule.rule
|
||||
if base_rule == '': # Special case for root path
|
||||
app.add_url_rule('/request', f"root_request",
|
||||
view_func=app.view_functions[rule.endpoint],
|
||||
methods=rule.methods)
|
||||
app.add_url_rule('/request/', f"root_request_slash",
|
||||
view_func=app.view_functions[rule.endpoint],
|
||||
methods=rule.methods)
|
||||
else:
|
||||
app.add_url_rule(f"/request{base_rule}",
|
||||
f"{rule.endpoint}_request",
|
||||
view_func=app.view_functions[rule.endpoint],
|
||||
methods=rule.methods)
|
||||
app.add_url_rule(f"/request{base_rule}/",
|
||||
f"{rule.endpoint}_request_slash",
|
||||
view_func=app.view_functions[rule.endpoint],
|
||||
methods=rule.methods)
|
||||
app.jinja_env.globals['url_for'] = url_for_with_request
|
||||
|
||||
def url_for_with_request(endpoint : str, **values : typing.Any) -> str:
|
||||
"""Generate URLs with /request prefix by default."""
|
||||
if endpoint == 'static':
|
||||
# For static files, add /request prefix
|
||||
url = flask_url_for(endpoint, **values)
|
||||
return f"/request{url}"
|
||||
return flask_url_for(endpoint, **values)
|
||||
# Serve frontend static files
|
||||
@app.route('/assets/<path:filename>')
|
||||
def serve_frontend_assets(filename: str) -> Response:
|
||||
"""
|
||||
Serve static assets from the built frontend.
|
||||
"""
|
||||
return send_from_directory(os.path.join(app.root_path, 'frontend-dist', 'assets'), filename)
|
||||
|
||||
@app.route('/')
|
||||
@login_required
|
||||
def index() -> str:
|
||||
def index() -> Response:
|
||||
"""
|
||||
Render main page with search and status table.
|
||||
Serve the React frontend application.
|
||||
Authentication is handled by the React app itself.
|
||||
"""
|
||||
return render_template('index.html',
|
||||
book_languages=_SUPPORTED_BOOK_LANGUAGE,
|
||||
default_language=BOOK_LANGUAGE,
|
||||
supported_formats=SUPPORTED_FORMATS,
|
||||
debug=DEBUG,
|
||||
build_version=BUILD_VERSION,
|
||||
release_version=RELEASE_VERSION,
|
||||
app_env=APP_ENV
|
||||
)
|
||||
return send_from_directory(os.path.join(app.root_path, 'frontend-dist'), 'index.html')
|
||||
|
||||
|
||||
@app.route('/logo.png')
|
||||
def logo() -> Response:
|
||||
"""
|
||||
Serve logo from built frontend assets.
|
||||
"""
|
||||
return send_from_directory(os.path.join(app.root_path, 'frontend-dist'),
|
||||
'logo.png', mimetype='image/png')
|
||||
|
||||
@app.route('/favicon.ico')
|
||||
@app.route('/favico<path:_>')
|
||||
@app.route('/request/favico<path:_>')
|
||||
@app.route('/request/static/favico<path:_>')
|
||||
def favicon(_ : typing.Any) -> Response:
|
||||
return send_from_directory(os.path.join(app.root_path, 'static', 'media'),
|
||||
def favicon(_ : typing.Any = None) -> Response:
|
||||
"""
|
||||
Serve favicon from built frontend assets.
|
||||
"""
|
||||
return send_from_directory(os.path.join(app.root_path, 'frontend-dist'),
|
||||
'favicon.ico', mimetype='image/vnd.microsoft.icon')
|
||||
|
||||
from typing import Union, Tuple
|
||||
@@ -267,6 +372,28 @@ def api_download() -> Union[Response, Tuple[Response, int]]:
|
||||
logger.error_trace(f"Download error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/config', methods=['GET'])
|
||||
@login_required
|
||||
def api_config() -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
Get application configuration for frontend.
|
||||
"""
|
||||
try:
|
||||
config = {
|
||||
"calibre_web_url": CALIBRE_WEB_URL,
|
||||
"debug": DEBUG,
|
||||
"app_env": APP_ENV,
|
||||
"build_version": BUILD_VERSION,
|
||||
"release_version": RELEASE_VERSION,
|
||||
"book_languages": _SUPPORTED_BOOK_LANGUAGE,
|
||||
"default_language": BOOK_LANGUAGE,
|
||||
"supported_formats": SUPPORTED_FORMATS
|
||||
}
|
||||
return jsonify(config)
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Config error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/status', methods=['GET'])
|
||||
@login_required
|
||||
def api_status() -> Union[Response, Tuple[Response, int]]:
|
||||
@@ -451,6 +578,11 @@ def api_clear_completed() -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
try:
|
||||
removed_count = backend.clear_completed()
|
||||
|
||||
# Broadcast status update after clearing
|
||||
if ws_manager and ws_manager.is_enabled():
|
||||
ws_manager.broadcast_status_update(backend.queue_status())
|
||||
|
||||
return jsonify({"status": "cleared", "removed_count": removed_count})
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Clear completed error: {e}")
|
||||
@@ -484,59 +616,207 @@ 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
|
||||
|
||||
# Register all routes with /request prefix
|
||||
register_dual_routes(app)
|
||||
@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>')
|
||||
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/'):
|
||||
return jsonify({"error": "Resource not found"}), 404
|
||||
# Otherwise serve the React app
|
||||
return send_from_directory(os.path.join(app.root_path, 'frontend-dist'), 'index.html')
|
||||
|
||||
# WebSocket event handlers
|
||||
@socketio.on('connect')
|
||||
def handle_connect():
|
||||
"""Handle client connection."""
|
||||
logger.info("WebSocket client connected")
|
||||
# Send initial status to the newly connected client
|
||||
try:
|
||||
status = backend.queue_status()
|
||||
emit('status_update', status)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending initial status: {e}")
|
||||
|
||||
@socketio.on('disconnect')
|
||||
def handle_disconnect():
|
||||
"""Handle client disconnection."""
|
||||
logger.info("WebSocket client disconnected")
|
||||
|
||||
@socketio.on('request_status')
|
||||
def handle_status_request():
|
||||
"""Handle manual status request from client."""
|
||||
try:
|
||||
status = backend.queue_status()
|
||||
emit('status_update', status)
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling status request: {e}")
|
||||
emit('error', {'message': 'Failed to get status'})
|
||||
|
||||
logger.log_resource_usage()
|
||||
|
||||
if __name__ == '__main__':
|
||||
logger.info(f"Starting Flask application on {FLASK_HOST}:{FLASK_PORT} IN {APP_ENV} mode")
|
||||
app.run(
|
||||
logger.info(f"Starting Flask application with WebSocket support on {FLASK_HOST}:{FLASK_PORT} IN {APP_ENV} mode")
|
||||
socketio.run(
|
||||
app,
|
||||
host=FLASK_HOST,
|
||||
port=FLASK_PORT,
|
||||
debug=DEBUG
|
||||
debug=DEBUG,
|
||||
allow_unsafe_werkzeug=True # For development only
|
||||
)
|
||||
|
||||
@@ -11,12 +11,20 @@ from threading import Event
|
||||
|
||||
from logger import setup_logger
|
||||
from config import CUSTOM_SCRIPT
|
||||
from env import INGEST_DIR, TMP_DIR, MAIN_LOOP_SLEEP_TIME, USE_BOOK_TITLE, MAX_CONCURRENT_DOWNLOADS, DOWNLOAD_PROGRESS_UPDATE_INTERVAL
|
||||
from env import (INGEST_DIR, DOWNLOAD_PATHS, TMP_DIR, MAIN_LOOP_SLEEP_TIME, USE_BOOK_TITLE,
|
||||
MAX_CONCURRENT_DOWNLOADS, DOWNLOAD_PROGRESS_UPDATE_INTERVAL)
|
||||
from models import book_queue, BookInfo, QueueStatus, SearchFilters
|
||||
import book_manager
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# Import WebSocket manager (will be initialized by app.py)
|
||||
try:
|
||||
from websocket_manager import ws_manager
|
||||
except ImportError:
|
||||
logger.warning("WebSocket manager not available")
|
||||
ws_manager = None
|
||||
|
||||
def _sanitize_filename(filename: str) -> str:
|
||||
"""Sanitize a filename by replacing spaces with underscores and removing invalid characters."""
|
||||
keepcharacters = (' ','.','_')
|
||||
@@ -69,6 +77,11 @@ def queue_book(book_id: str, priority: int = 0) -> bool:
|
||||
book_info = book_manager.get_book_info(book_id)
|
||||
book_queue.add(book_id, book_info, priority)
|
||||
logger.info(f"Book queued with priority {priority}: {book_info.title}")
|
||||
|
||||
# Broadcast status update via WebSocket
|
||||
if ws_manager and ws_manager.is_enabled():
|
||||
ws_manager.broadcast_status_update(queue_status())
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Error queueing book: {e}")
|
||||
@@ -78,7 +91,7 @@ def queue_status() -> Dict[str, Dict[str, Any]]:
|
||||
"""Get current status of the download queue.
|
||||
|
||||
Returns:
|
||||
Dict: Queue status organized by status type
|
||||
Dict: Queue status organized by status type with serialized book data
|
||||
"""
|
||||
status = book_queue.get_status()
|
||||
for _, books in status.items():
|
||||
@@ -87,9 +100,12 @@ def queue_status() -> Dict[str, Dict[str, Any]]:
|
||||
if not os.path.exists(book_info.download_path):
|
||||
book_info.download_path = None
|
||||
|
||||
# Convert Enum keys to strings and properly format the response
|
||||
# Convert Enum keys to strings and BookInfo objects to dicts for JSON serialization
|
||||
return {
|
||||
status_type.value: books
|
||||
status_type.value: {
|
||||
book_id: _book_info_to_dict(book_info)
|
||||
for book_id, book_info in books.items()
|
||||
}
|
||||
for status_type, books in status.items()
|
||||
}
|
||||
|
||||
@@ -120,6 +136,13 @@ def _book_info_to_dict(book: BookInfo) -> Dict[str, Any]:
|
||||
if value is not None
|
||||
}
|
||||
|
||||
def _prepare_download_folder(book_info: BookInfo) -> Path:
|
||||
"""Prepare final content-type subdir"""
|
||||
content = book_info.content
|
||||
content_dir = DOWNLOAD_PATHS.get(content) if content and content in DOWNLOAD_PATHS else INGEST_DIR
|
||||
os.makedirs(content_dir, exist_ok=True)
|
||||
return content_dir
|
||||
|
||||
def _download_book_with_cancellation(book_id: str, cancel_flag: Event) -> Optional[str]:
|
||||
"""Download and process a book with cancellation support.
|
||||
|
||||
@@ -143,6 +166,9 @@ def _download_book_with_cancellation(book_id: str, cancel_flag: Event) -> Option
|
||||
book_name = _sanitize_filename(book_info.title)
|
||||
else:
|
||||
book_name = book_id
|
||||
# If format is not set, use the format of the first download URL
|
||||
if book_info.format == "":
|
||||
book_info.format = book_info.download_urls[0].split(".")[-1]
|
||||
book_name += f".{book_info.format}"
|
||||
book_path = TMP_DIR / book_name
|
||||
|
||||
@@ -152,7 +178,8 @@ def _download_book_with_cancellation(book_id: str, cancel_flag: Event) -> Option
|
||||
return None
|
||||
|
||||
progress_callback = lambda progress: update_download_progress(book_id, progress)
|
||||
success = book_manager.download_book(book_info, book_path, progress_callback, cancel_flag)
|
||||
status_callback = lambda status: update_download_status(book_id, status)
|
||||
success_download_url = book_manager.download_book(book_info, book_path, progress_callback, cancel_flag, status_callback)
|
||||
|
||||
# Stop progress updates
|
||||
cancel_flag.wait(0.1) # Brief pause for progress thread cleanup
|
||||
@@ -164,7 +191,7 @@ def _download_book_with_cancellation(book_id: str, cancel_flag: Event) -> Option
|
||||
book_path.unlink()
|
||||
return None
|
||||
|
||||
if not success:
|
||||
if not success_download_url:
|
||||
raise Exception("Unknown error downloading book")
|
||||
|
||||
# Check cancellation before post-processing
|
||||
@@ -174,12 +201,28 @@ def _download_book_with_cancellation(book_id: str, cancel_flag: Event) -> Option
|
||||
book_path.unlink()
|
||||
return None
|
||||
|
||||
# Update status to verifying
|
||||
book_queue.update_status(book_id, QueueStatus.VERIFYING)
|
||||
if ws_manager and ws_manager.is_enabled():
|
||||
ws_manager.broadcast_status_update(queue_status())
|
||||
logger.info(f"Verifying download: {book_info.title}")
|
||||
|
||||
if CUSTOM_SCRIPT:
|
||||
logger.info(f"Running custom script: {CUSTOM_SCRIPT}")
|
||||
subprocess.run([CUSTOM_SCRIPT, book_path])
|
||||
|
||||
intermediate_path = INGEST_DIR / f"{book_id}.crdownload"
|
||||
final_path = INGEST_DIR / book_name
|
||||
# Update status to ingesting
|
||||
book_queue.update_status(book_id, QueueStatus.INGESTING)
|
||||
if ws_manager and ws_manager.is_enabled():
|
||||
ws_manager.broadcast_status_update(queue_status())
|
||||
|
||||
if success_download_url and book_info.format == "":
|
||||
book_info.format = success_download_url.split(".")[-1]
|
||||
book_name += f".{book_info.format}"
|
||||
|
||||
final_dir = _prepare_download_folder(book_info)
|
||||
intermediate_path = final_dir / f"{book_id}.crdownload"
|
||||
final_path = final_dir / book_name
|
||||
|
||||
if os.path.exists(book_path):
|
||||
logger.info(f"Moving book to ingest directory: {book_path} -> {final_path}")
|
||||
@@ -215,6 +258,35 @@ def _download_book_with_cancellation(book_id: str, cancel_flag: Event) -> Option
|
||||
def update_download_progress(book_id: str, progress: float) -> None:
|
||||
"""Update download progress."""
|
||||
book_queue.update_progress(book_id, progress)
|
||||
|
||||
# Broadcast progress via WebSocket
|
||||
if ws_manager and ws_manager.is_enabled():
|
||||
ws_manager.broadcast_download_progress(book_id, progress, 'downloading')
|
||||
|
||||
def update_download_status(book_id: str, status: str) -> None:
|
||||
"""Update download status."""
|
||||
# Map string status to QueueStatus enum
|
||||
status_map = {
|
||||
'queued': QueueStatus.QUEUED,
|
||||
'resolving': QueueStatus.RESOLVING,
|
||||
'bypassing': QueueStatus.BYPASSING,
|
||||
'downloading': QueueStatus.DOWNLOADING,
|
||||
'verifying': QueueStatus.VERIFYING,
|
||||
'ingesting': QueueStatus.INGESTING,
|
||||
'complete': QueueStatus.COMPLETE,
|
||||
'available': QueueStatus.AVAILABLE,
|
||||
'error': QueueStatus.ERROR,
|
||||
'done': QueueStatus.DONE,
|
||||
'cancelled': QueueStatus.CANCELLED,
|
||||
}
|
||||
|
||||
queue_status_enum = status_map.get(status.lower())
|
||||
if queue_status_enum:
|
||||
book_queue.update_status(book_id, queue_status_enum)
|
||||
|
||||
# Broadcast status update via WebSocket
|
||||
if ws_manager and ws_manager.is_enabled():
|
||||
ws_manager.broadcast_status_update(queue_status())
|
||||
|
||||
def cancel_download(book_id: str) -> bool:
|
||||
"""Cancel a download.
|
||||
@@ -225,7 +297,13 @@ def cancel_download(book_id: str) -> bool:
|
||||
Returns:
|
||||
bool: True if cancellation was successful
|
||||
"""
|
||||
return book_queue.cancel_download(book_id)
|
||||
result = book_queue.cancel_download(book_id)
|
||||
|
||||
# Broadcast status update via WebSocket
|
||||
if result and ws_manager and ws_manager.is_enabled():
|
||||
ws_manager.broadcast_status_update(queue_status())
|
||||
|
||||
return result
|
||||
|
||||
def set_book_priority(book_id: str, priority: int) -> bool:
|
||||
"""Set priority for a queued book.
|
||||
@@ -265,21 +343,29 @@ def clear_completed() -> int:
|
||||
def _process_single_download(book_id: str, cancel_flag: Event) -> None:
|
||||
"""Process a single download job."""
|
||||
try:
|
||||
book_queue.update_status(book_id, QueueStatus.DOWNLOADING)
|
||||
# Status will be updated through callbacks during download process
|
||||
# (resolving -> bypassing -> downloading -> verifying -> ingesting -> complete)
|
||||
download_path = _download_book_with_cancellation(book_id, cancel_flag)
|
||||
|
||||
if cancel_flag.is_set():
|
||||
book_queue.update_status(book_id, QueueStatus.CANCELLED)
|
||||
# Broadcast cancellation
|
||||
if ws_manager and ws_manager.is_enabled():
|
||||
ws_manager.broadcast_status_update(queue_status())
|
||||
return
|
||||
|
||||
if download_path:
|
||||
book_queue.update_download_path(book_id, download_path)
|
||||
new_status = QueueStatus.AVAILABLE
|
||||
new_status = QueueStatus.COMPLETE
|
||||
else:
|
||||
new_status = QueueStatus.ERROR
|
||||
|
||||
book_queue.update_status(book_id, new_status)
|
||||
|
||||
# Broadcast final status (completed or error)
|
||||
if ws_manager and ws_manager.is_enabled():
|
||||
ws_manager.broadcast_status_update(queue_status())
|
||||
|
||||
logger.info(
|
||||
f"Book {book_id} download {'successful' if download_path else 'failed'}"
|
||||
)
|
||||
@@ -291,6 +377,10 @@ def _process_single_download(book_id: str, cancel_flag: Event) -> None:
|
||||
else:
|
||||
logger.info(f"Download cancelled: {book_id}")
|
||||
book_queue.update_status(book_id, QueueStatus.CANCELLED)
|
||||
|
||||
# Broadcast error/cancelled status
|
||||
if ws_manager and ws_manager.is_enabled():
|
||||
ws_manager.broadcast_status_update(queue_status())
|
||||
|
||||
def concurrent_download_loop() -> None:
|
||||
"""Main download coordinator using ThreadPoolExecutor for concurrent downloads."""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Book download manager handling search and retrieval operations."""
|
||||
|
||||
import time, json, re
|
||||
import time, json, os, re
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from typing import List, Optional, Dict, Union, Callable
|
||||
@@ -10,7 +10,7 @@ from bs4 import BeautifulSoup, Tag, NavigableString, ResultSet
|
||||
import downloader
|
||||
from logger import setup_logger
|
||||
from config import SUPPORTED_FORMATS, BOOK_LANGUAGE, AA_BASE_URL
|
||||
from env import AA_DONATOR_KEY, USE_CF_BYPASS, PRIORITIZE_WELIB
|
||||
from env import AA_DONATOR_KEY, USE_CF_BYPASS, PRIORITIZE_WELIB, ALLOW_USE_WELIB, DOWNLOAD_PATHS
|
||||
from models import BookInfo, SearchFilters
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -110,6 +110,9 @@ def search_books(query: str, filters: SearchFilters) -> List[BookInfo]:
|
||||
def _parse_search_result_row(row: Tag) -> Optional[BookInfo]:
|
||||
"""Parse a single search result row into a BookInfo object."""
|
||||
try:
|
||||
# Skip ad rows
|
||||
if row.text.strip().lower().startswith("your ad here"):
|
||||
return None
|
||||
cells = row.find_all("td")
|
||||
preview_img = cells[0].find("img")
|
||||
preview = preview_img["src"] if preview_img else None
|
||||
@@ -122,6 +125,7 @@ def _parse_search_result_row(row: Tag) -> Optional[BookInfo]:
|
||||
publisher=cells[3].find("span").next,
|
||||
year=cells[4].find("span").next,
|
||||
language=cells[7].find("span").next,
|
||||
content=cells[8].find("span").next.lower(),
|
||||
format=cells[9].find("span").next.lower(),
|
||||
size=cells[10].find("span").next,
|
||||
)
|
||||
@@ -169,21 +173,6 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
|
||||
|
||||
data = soup.find_all("div", {"class": "main-inner"})[0].find_next("div")
|
||||
divs = list(data.children)
|
||||
_details = divs[13].text.strip().lower().split(" · ")
|
||||
format = ""
|
||||
size = ""
|
||||
for f in _details:
|
||||
if format == "" and f.strip().lower() in SUPPORTED_FORMATS:
|
||||
format = f.strip().lower()
|
||||
if size == "" and any(u in f.strip().lower() for u in ["mb", "kb", "gb"]):
|
||||
size = f.strip().lower()
|
||||
|
||||
if format == "" or size == "":
|
||||
for f in _details:
|
||||
if f == "" and not " " in f.strip().lower():
|
||||
format = f.strip().lower()
|
||||
if size == "" and "." in f.strip().lower():
|
||||
size = f.strip().lower()
|
||||
|
||||
every_url = soup.find_all("a")
|
||||
slow_urls_no_waitlist = set()
|
||||
@@ -237,20 +226,55 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
|
||||
# Remove empty urls
|
||||
urls = [url for url in urls if url != ""]
|
||||
|
||||
# Filter out divs that are not text
|
||||
original_divs = divs
|
||||
divs = [div for div in divs if div.text.strip() != ""]
|
||||
|
||||
all_details = _find_in_divs(divs, " · ")
|
||||
format = ""
|
||||
size = ""
|
||||
content = ""
|
||||
|
||||
for _details in all_details:
|
||||
_details = _details.split(" · ")
|
||||
for f in _details:
|
||||
if format == "" and f.strip().lower() in SUPPORTED_FORMATS:
|
||||
format = f.strip().lower()
|
||||
if size == "" and any(u in f.strip().lower() for u in ["mb", "kb", "gb"]):
|
||||
size = f.strip().lower()
|
||||
if content == "":
|
||||
for ct in DOWNLOAD_PATHS.keys():
|
||||
if ct in f.strip().lower():
|
||||
content = ct
|
||||
break
|
||||
if format == "" or size == "":
|
||||
for f in _details:
|
||||
stripped = f.strip().lower()
|
||||
if format == "" and stripped and " " not in stripped:
|
||||
format = stripped
|
||||
if size == "" and "." in stripped:
|
||||
size = stripped
|
||||
|
||||
book_title = _find_in_divs(divs, "🔍")[0].strip("🔍").strip()
|
||||
|
||||
# Extract basic information
|
||||
description = _extract_book_description(soup)
|
||||
|
||||
book_info = BookInfo(
|
||||
id=book_id,
|
||||
preview=preview,
|
||||
title=divs[7].next.strip(),
|
||||
publisher=divs[11].text.strip(),
|
||||
author=divs[9].text.strip(),
|
||||
title=book_title,
|
||||
content=content,
|
||||
publisher=_find_in_divs(divs, "icon-[mdi--company]", isClass=True)[0],
|
||||
author=_find_in_divs(divs, "icon-[mdi--user-edit]", isClass=True)[0],
|
||||
format=format,
|
||||
size=size,
|
||||
description=description,
|
||||
download_urls=urls,
|
||||
)
|
||||
|
||||
# Extract additional metadata
|
||||
info = _extract_book_metadata(divs[-6])
|
||||
info = _extract_book_metadata(original_divs[-6])
|
||||
book_info.info = info
|
||||
|
||||
# Set language and year from metadata if available
|
||||
@@ -259,9 +283,26 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
|
||||
if info.get("Year"):
|
||||
book_info.year = info["Year"][0]
|
||||
|
||||
# TODO :
|
||||
# Backfill missing metadata from original book
|
||||
# To do this, we need to cache the results of search_books() in some kind of LRU
|
||||
|
||||
return book_info
|
||||
|
||||
def _find_in_divs(divs: List[str], text: str, isClass: bool = False) -> List[str]:
|
||||
divs_found = []
|
||||
for div in divs:
|
||||
if isClass:
|
||||
if div.find(class_ = text):
|
||||
divs_found.append(div.text.strip())
|
||||
else:
|
||||
if text in div.text.strip():
|
||||
divs_found.append(div.text.strip())
|
||||
return divs_found
|
||||
|
||||
def _get_download_urls_from_welib(book_id: str) -> set[str]:
|
||||
if ALLOW_USE_WELIB == False:
|
||||
return set()
|
||||
"""Get download urls from welib.org."""
|
||||
url = f"https://welib.org/md5/{book_id}"
|
||||
logger.info(f"Getting download urls from welib.org for {book_id}. While this uses the bypasser, it will not start downloading them yet.")
|
||||
@@ -275,9 +316,53 @@ def _get_download_urls_from_welib(book_id: str) -> set[str]:
|
||||
download_links = [downloader.get_absolute_url(url, link) for link in download_links]
|
||||
return set(download_links)
|
||||
|
||||
def _extract_book_metadata(
|
||||
metadata_divs
|
||||
) -> Dict[str, List[str]]:
|
||||
def _get_next_value_div(label_div: Tag) -> Optional[Tag]:
|
||||
"""Find the next sibling div that holds the value for a metadata label."""
|
||||
sibling = label_div.next_sibling
|
||||
while sibling:
|
||||
if isinstance(sibling, Tag) and sibling.name == "div":
|
||||
return sibling
|
||||
sibling = sibling.next_sibling
|
||||
return None
|
||||
|
||||
def _extract_book_description(soup: BeautifulSoup) -> Optional[str]:
|
||||
"""Extract the primary or alternative description from the book page."""
|
||||
container = soup.select_one(".js-md5-top-box-description")
|
||||
if not container:
|
||||
return None
|
||||
|
||||
description: Optional[str] = None
|
||||
alternative: Optional[str] = None
|
||||
|
||||
label_divs = container.select("div.text-xs.text-gray-500.uppercase")
|
||||
for label_div in label_divs:
|
||||
label_text = label_div.get_text(strip=True).lower()
|
||||
value_div = _get_next_value_div(label_div)
|
||||
if not value_div:
|
||||
continue
|
||||
|
||||
value_text = value_div.get_text(separator=" ", strip=True)
|
||||
if not value_text:
|
||||
continue
|
||||
|
||||
if label_text == "description":
|
||||
return value_text
|
||||
if label_text == "alternative description" and not alternative:
|
||||
alternative = value_text
|
||||
|
||||
if alternative:
|
||||
return alternative
|
||||
|
||||
# Fallback to the first text block inside the description container
|
||||
fallback_div = container.find("div", class_="mb-1")
|
||||
if fallback_div:
|
||||
fallback_text = fallback_div.get_text(separator=" ", strip=True)
|
||||
if fallback_text:
|
||||
return fallback_text
|
||||
|
||||
return None
|
||||
|
||||
def _extract_book_metadata(metadata_divs) -> Dict[str, List[str]]:
|
||||
"""Extract metadata from book info divs."""
|
||||
info: Dict[str, List[str]] = {}
|
||||
|
||||
@@ -315,15 +400,18 @@ def _extract_book_metadata(
|
||||
}
|
||||
|
||||
|
||||
def download_book(book_info: BookInfo, book_path: Path, progress_callback: Optional[Callable[[float], None]] = None, cancel_flag: Optional[Event] = None) -> bool:
|
||||
def download_book(book_info: BookInfo, book_path: Path, progress_callback: Optional[Callable[[float], None]] = None, cancel_flag: Optional[Event] = None, status_callback: Optional[Callable[[str], None]] = None) -> Optional[str]:
|
||||
"""Download a book from available sources.
|
||||
|
||||
Args:
|
||||
book_id: Book identifier (MD5 hash)
|
||||
title: Book title for logging
|
||||
progress_callback: Optional callback for download progress updates
|
||||
cancel_flag: Optional cancellation flag
|
||||
status_callback: Optional callback for status updates
|
||||
|
||||
Returns:
|
||||
Optional[BytesIO]: Book content buffer if successful
|
||||
str: Download URL if successful, None otherwise
|
||||
"""
|
||||
|
||||
if len(book_info.download_urls) == 0:
|
||||
@@ -339,8 +427,16 @@ def download_book(book_info: BookInfo, book_path: Path, progress_callback: Optio
|
||||
|
||||
for link in download_links:
|
||||
try:
|
||||
download_url = _get_download_url(link, book_info.title, cancel_flag)
|
||||
# Update status to resolving before attempting download URL fetch
|
||||
if status_callback:
|
||||
status_callback("resolving")
|
||||
|
||||
download_url = _get_download_url(link, book_info.title, cancel_flag, status_callback)
|
||||
if download_url != "":
|
||||
# Update status to downloading before starting actual download
|
||||
if status_callback:
|
||||
status_callback("downloading")
|
||||
|
||||
logger.info(f"Downloading `{book_info.title}` from `{download_url}`")
|
||||
|
||||
data = downloader.download_url(download_url, book_info.size or "", progress_callback, cancel_flag)
|
||||
@@ -351,25 +447,25 @@ def download_book(book_info: BookInfo, book_path: Path, progress_callback: Optio
|
||||
with open(book_path, "wb") as f:
|
||||
f.write(data.getbuffer())
|
||||
logger.info(f"Writing `{book_info.title}` successfully")
|
||||
return True
|
||||
return download_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Failed to download from {link}: {e}")
|
||||
continue
|
||||
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _get_download_url(link: str, title: str, cancel_flag: Optional[Event] = None) -> str:
|
||||
def _get_download_url(link: str, title: str, cancel_flag: Optional[Event] = None, status_callback: Optional[Callable[[str], None]] = None) -> str:
|
||||
"""Extract actual download URL from various source pages."""
|
||||
|
||||
url = ""
|
||||
|
||||
if link.startswith(f"{AA_BASE_URL}/dyn/api/fast_download.json"):
|
||||
page = downloader.html_get_page(link)
|
||||
page = downloader.html_get_page(link, status_callback=status_callback)
|
||||
url = json.loads(page).get("download_url")
|
||||
else:
|
||||
html = downloader.html_get_page(link)
|
||||
html = downloader.html_get_page(link, status_callback=status_callback)
|
||||
|
||||
if html == "":
|
||||
return ""
|
||||
@@ -390,7 +486,7 @@ def _get_download_url(link: str, title: str, cancel_flag: Optional[Event] = None
|
||||
if cancel_flag is not None and cancel_flag.wait(timeout=sleep_time):
|
||||
logger.info(f"Cancelled wait for {title}")
|
||||
return ""
|
||||
url = _get_download_url(link, title, cancel_flag)
|
||||
url = _get_download_url(link, title, cancel_flag, status_callback)
|
||||
else:
|
||||
url = download_links[0]["href"]
|
||||
else:
|
||||
|
||||
@@ -22,13 +22,14 @@ if USE_CF_BYPASS:
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def html_get_page(url: str, retry: int = MAX_RETRY, use_bypasser: bool = False) -> str:
|
||||
def html_get_page(url: str, retry: int = MAX_RETRY, use_bypasser: bool = False, status_callback: Optional[Callable[[str], None]] = None) -> str:
|
||||
"""Fetch HTML content from a URL with retry mechanism.
|
||||
|
||||
Args:
|
||||
url: Target URL
|
||||
retry: Number of retry attempts
|
||||
skip_404: Whether to skip 404 errors
|
||||
use_bypasser: Whether to use Cloudflare bypasser
|
||||
status_callback: Optional callback for status updates
|
||||
|
||||
Returns:
|
||||
str: HTML content if successful, None otherwise
|
||||
@@ -37,6 +38,8 @@ def html_get_page(url: str, retry: int = MAX_RETRY, use_bypasser: bool = False)
|
||||
try:
|
||||
logger.debug(f"html_get_page: {url}, retry: {retry}, use_bypasser: {use_bypasser}")
|
||||
if use_bypasser and USE_CF_BYPASS:
|
||||
if status_callback:
|
||||
status_callback("bypassing")
|
||||
logger.info(f"GET Using Cloudflare Bypasser for: {url}")
|
||||
return get_bypassed_page(url)
|
||||
else:
|
||||
@@ -61,14 +64,14 @@ def html_get_page(url: str, retry: int = MAX_RETRY, use_bypasser: bool = False)
|
||||
return ""
|
||||
elif response is not None and response.status_code == 403:
|
||||
logger.warning(f"403 detected for URL: {url}. Should retry using cloudflare bypass.")
|
||||
return html_get_page(url, retry - 1, True)
|
||||
return html_get_page(url, retry - 1, True, status_callback)
|
||||
|
||||
sleep_time = DEFAULT_SLEEP * (MAX_RETRY - retry + 1)
|
||||
logger.warning(
|
||||
f"Retrying GET {url} in {sleep_time} seconds due to error: {e}"
|
||||
)
|
||||
time.sleep(sleep_time)
|
||||
return html_get_page(url, retry - 1, use_bypasser)
|
||||
return html_get_page(url, retry - 1, use_bypasser, status_callback)
|
||||
|
||||
def download_url(link: str, size: str = "", progress_callback: Optional[Callable[[float], None]] = None, cancel_flag: Optional[Event] = None) -> Optional[BytesIO]:
|
||||
"""Download content from URL into a BytesIO buffer.
|
||||
|
||||
@@ -108,7 +108,11 @@ make_writable /cwa-book-ingest
|
||||
# Set the command to run based on the environment
|
||||
is_prod=$(echo "$APP_ENV" | tr '[:upper:]' '[:lower:]')
|
||||
if [ "$is_prod" = "prod" ]; then
|
||||
command="gunicorn -t 300 -b ${FLASK_HOST:-0.0.0.0}:${FLASK_PORT:-8084} app:app"
|
||||
# Use geventwebsocket worker for SocketIO + WebSocket compatibility
|
||||
# This special worker class handles WebSocket upgrades properly
|
||||
# --workers 1: SocketIO requires sticky sessions, use 1 worker or configure sticky sessions
|
||||
# -t 300: 300 second timeout for long-running requests
|
||||
command="gunicorn --worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker --workers 1 -t 300 -b ${FLASK_HOST:-0.0.0.0}:${FLASK_PORT:-8084} app:app"
|
||||
else
|
||||
command="python3 app.py"
|
||||
fi
|
||||
|
||||
@@ -4,12 +4,40 @@ 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 in dev, True in prod, can be overridden with environment variable
|
||||
# - '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/"))
|
||||
LOG_DIR = LOG_ROOT / "cwa-book-downloader"
|
||||
TMP_DIR = Path(os.getenv("TMP_DIR", "/tmp/cwa-book-downloader"))
|
||||
INGEST_DIR = Path(os.getenv("INGEST_DIR", "/cwa-book-ingest"))
|
||||
INGEST_DIR_BOOK_FICTION = os.getenv("INGEST_DIR_BOOK_FICTION", "")
|
||||
INGEST_DIR_BOOK_NON_FICTION = os.getenv("INGEST_DIR_BOOK_NON_FICTION", "")
|
||||
INGEST_DIR_BOOK_UNKNOWN = os.getenv("INGEST_DIR_BOOK_UNKNOWN", "")
|
||||
INGEST_DIR_MAGAZINE = os.getenv("INGEST_DIR_MAGAZINE", "")
|
||||
INGEST_DIR_COMIC_BOOK = os.getenv("INGEST_DIR_COMIC_BOOK", "")
|
||||
INGEST_DIR_AUDIOBOOK = os.getenv("INGEST_DIR_AUDIOBOOK", "")
|
||||
INGEST_DIR_STANDARDS_DOCUMENT = os.getenv("INGEST_DIR_STANDARDS_DOCUMENT", "")
|
||||
INGEST_DIR_MUSICAL_SCORE = os.getenv("INGEST_DIR_MUSICAL_SCORE", "")
|
||||
INGEST_DIR_OTHER = os.getenv("INGEST_DIR_OTHER", "")
|
||||
DOWNLOAD_PATHS = {
|
||||
"book (fiction)": Path(INGEST_DIR_BOOK_FICTION) if INGEST_DIR_BOOK_FICTION else INGEST_DIR,
|
||||
"book (non-fiction)": Path(INGEST_DIR_BOOK_NON_FICTION) if INGEST_DIR_BOOK_NON_FICTION else INGEST_DIR,
|
||||
"book (unknown)": Path(INGEST_DIR_BOOK_UNKNOWN) if INGEST_DIR_BOOK_UNKNOWN else INGEST_DIR,
|
||||
"magazine": Path(INGEST_DIR_MAGAZINE) if INGEST_DIR_MAGAZINE else INGEST_DIR,
|
||||
"comic book": Path(INGEST_DIR_COMIC_BOOK) if INGEST_DIR_COMIC_BOOK else INGEST_DIR,
|
||||
"audiobook": Path(INGEST_DIR_AUDIOBOOK) if INGEST_DIR_AUDIOBOOK else INGEST_DIR,
|
||||
"standards document": Path(INGEST_DIR_STANDARDS_DOCUMENT) if INGEST_DIR_STANDARDS_DOCUMENT else INGEST_DIR,
|
||||
"musical score": Path(INGEST_DIR_MUSICAL_SCORE) if INGEST_DIR_MUSICAL_SCORE else INGEST_DIR,
|
||||
"other": Path(INGEST_DIR_OTHER) if INGEST_DIR_OTHER else INGEST_DIR,
|
||||
}
|
||||
|
||||
STATUS_TIMEOUT = int(os.getenv("STATUS_TIMEOUT", "3600"))
|
||||
USE_BOOK_TITLE = string_to_bool(os.getenv("USE_BOOK_TITLE", "false"))
|
||||
MAX_RETRY = int(os.getenv("MAX_RETRY", "10"))
|
||||
@@ -28,6 +56,7 @@ FLASK_PORT = int(os.getenv("FLASK_PORT", "8084"))
|
||||
DEBUG = string_to_bool(os.getenv("DEBUG", "false"))
|
||||
APP_ENV = os.getenv("APP_ENV", "N/A").lower()
|
||||
PRIORITIZE_WELIB = string_to_bool(os.getenv("PRIORITIZE_WELIB", "false"))
|
||||
ALLOW_USE_WELIB = string_to_bool(os.getenv("ALLOW_USE_WELIB", "true"))
|
||||
|
||||
# Version information from Docker build
|
||||
BUILD_VERSION = os.getenv("BUILD_VERSION", "N/A")
|
||||
@@ -63,4 +92,7 @@ if USING_TOR:
|
||||
USE_DOH = False
|
||||
HTTP_PROXY = ""
|
||||
HTTPS_PROXY = ""
|
||||
|
||||
# Calibre-Web URL for navigation button
|
||||
CALIBRE_WEB_URL = os.getenv("CALIBRE_WEB_URL", "").strip()
|
||||
|
||||
@@ -13,21 +13,25 @@ class CustomLogger(logging.Logger):
|
||||
def error_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log an error message with full stack trace."""
|
||||
self.log_resource_usage()
|
||||
kwargs.pop('exc_info', None)
|
||||
self.error(msg, *args, exc_info=True, **kwargs)
|
||||
|
||||
def warning_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log a warning message with full stack trace."""
|
||||
self.log_resource_usage()
|
||||
kwargs.pop('exc_info', None)
|
||||
self.warning(msg, *args, exc_info=True, **kwargs)
|
||||
|
||||
def info_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log an info message with full stack trace."""
|
||||
self.log_resource_usage()
|
||||
kwargs.pop('exc_info', None)
|
||||
self.info(msg, *args, exc_info=True, **kwargs)
|
||||
|
||||
def debug_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log a debug message with full stack trace."""
|
||||
self.log_resource_usage()
|
||||
kwargs.pop('exc_info', None)
|
||||
self.debug(msg, *args, exc_info=True, **kwargs)
|
||||
|
||||
def log_resource_usage(self):
|
||||
|
||||
@@ -13,7 +13,12 @@ from env import INGEST_DIR, STATUS_TIMEOUT
|
||||
class QueueStatus(str, Enum):
|
||||
"""Enum for possible book queue statuses."""
|
||||
QUEUED = "queued"
|
||||
RESOLVING = "resolving"
|
||||
BYPASSING = "bypassing"
|
||||
DOWNLOADING = "downloading"
|
||||
VERIFYING = "verifying"
|
||||
INGESTING = "ingesting"
|
||||
COMPLETE = "complete"
|
||||
AVAILABLE = "available"
|
||||
ERROR = "error"
|
||||
DONE = "done"
|
||||
@@ -42,9 +47,11 @@ class BookInfo:
|
||||
publisher: Optional[str] = None
|
||||
year: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
format: Optional[str] = None
|
||||
size: Optional[str] = None
|
||||
info: Optional[Dict[str, List[str]]] = None
|
||||
description: Optional[str] = None
|
||||
download_urls: List[str] = field(default_factory=list)
|
||||
download_path: Optional[str] = None
|
||||
priority: int = 0
|
||||
@@ -116,7 +123,7 @@ class BookQueue:
|
||||
self._update_status(book_id, status)
|
||||
|
||||
# Clean up active download tracking when finished
|
||||
if status in [QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.DONE, QueueStatus.CANCELLED]:
|
||||
if status in [QueueStatus.COMPLETE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.DONE, QueueStatus.CANCELLED]:
|
||||
self._active_downloads.pop(book_id, None)
|
||||
self._cancel_flags.pop(book_id, None)
|
||||
|
||||
@@ -184,7 +191,8 @@ class BookQueue:
|
||||
with self._lock:
|
||||
current_status = self._status.get(book_id)
|
||||
|
||||
if current_status == QueueStatus.DOWNLOADING:
|
||||
# Allow cancellation during any active state
|
||||
if current_status in [QueueStatus.RESOLVING, QueueStatus.BYPASSING, QueueStatus.DOWNLOADING, QueueStatus.VERIFYING, QueueStatus.INGESTING]:
|
||||
# Signal active download to stop
|
||||
if book_id in self._cancel_flags:
|
||||
self._cancel_flags[book_id].set()
|
||||
@@ -283,7 +291,7 @@ class BookQueue:
|
||||
with self._lock:
|
||||
to_remove = []
|
||||
for book_id, status in self._status.items():
|
||||
if status in [QueueStatus.DONE, QueueStatus.ERROR, QueueStatus.CANCELLED]:
|
||||
if status in [QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.CANCELLED]:
|
||||
to_remove.append(book_id)
|
||||
|
||||
removed_count = len(to_remove)
|
||||
@@ -318,7 +326,7 @@ class BookQueue:
|
||||
# Check for stale status entries
|
||||
last_update = self._status_timestamps.get(book_id)
|
||||
if last_update and (current_time - last_update) > self._status_timeout:
|
||||
if status in [QueueStatus.DONE, QueueStatus.ERROR, QueueStatus.AVAILABLE, QueueStatus.CANCELLED]:
|
||||
if status in [QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.ERROR, QueueStatus.AVAILABLE, QueueStatus.CANCELLED]:
|
||||
to_remove.append(book_id)
|
||||
|
||||
# Remove stale entries
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 📚 Calibre-Web-Automated-Book-Downloader
|
||||
|
||||

|
||||

|
||||
|
||||
An intuitive web interface for searching and requesting book downloads, designed to work seamlessly with [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated). This project streamlines the process of downloading books and preparing them for integration into your Calibre library.
|
||||
|
||||
@@ -83,9 +83,34 @@ Note that if using TOR, the TZ will be calculated automatically based on IP.
|
||||
| `AA_DONATOR_KEY` | Optional Donator key for Anna's Archive fast download API | `` |
|
||||
| `USE_BOOK_TITLE` | Use book title as filename instead of ID | `false` |
|
||||
| `PRIORITIZE_WELIB` | When downloading, download from WELIB first instead of AA | `false` |
|
||||
| `ALLOW_USE_WELIB` | Allow usage of welib for downloading books if found there | `true` |
|
||||
|
||||
If you change `BOOK_LANGUAGE`, you can add multiple comma separated languages, such as `en,fr,ru` etc.
|
||||
|
||||
Use the following environment variables to set specific folders in which to download
|
||||
different content types (Book, Magazine, Comic, etc.):
|
||||
|
||||
| Variable | Description | Default Value |
|
||||
|---------------------------------|--------------------------------|---------------|
|
||||
| `INGEST_DIR_BOOK_FICTION` | Book (fiction) folder name | `` |
|
||||
| `INGEST_DIR_BOOK_NON_FICTION` | Book (non-fiction) folder name | `` |
|
||||
| `INGEST_DIR_BOOK_UNKNOWN` | Book (unknown) folder name | `` |
|
||||
| `INGEST_DIR_MAGAZINE` | Magazine folder name | `` |
|
||||
| `INGEST_DIR_COMIC_BOOK` | Comic book folder name | `` |
|
||||
| `INGEST_DIR_AUDIOBOOK` | Audiobook folder name | `` |
|
||||
| `INGEST_DIR_STANDARDS_DOCUMENT` | Standards document folder name | `` |
|
||||
| `INGEST_DIR_MUSICAL_SCORE` | Musical score folder name | `` |
|
||||
|
||||
If no specific path is set for a content type the default is `INGEST_DIR`.
|
||||
Remember to map the specified paths to where your instance of Calibre-Web-Automated (CWA) will find them, e.g.:
|
||||
```
|
||||
volumes:
|
||||
- /tmp/data/calibre-web/comicbook-ingest:/cwa-comicbook-ingest
|
||||
```
|
||||
if `INGEST_DIR_COMIC_BOOK=/cwa-comicbook-ingest` and your CWA is configured to use `/tmp/data/calibre-web/comicbook-ingest`
|
||||
for comic books.
|
||||
|
||||
|
||||
#### AA
|
||||
|
||||
| Variable | Description | Default Value |
|
||||
@@ -242,9 +267,25 @@ This feature is designed to work with any resolver that implements the `FlareSol
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
The application consists of a single service:
|
||||
The application consists of a Flask backend with a React-based frontend:
|
||||
|
||||
1. **calibre-web-automated-bookdownloader**: Main application providing web interface and download functionality
|
||||
### Backend
|
||||
- **Flask Application**: Python-based backend (`app.py`, `backend.py`) providing REST API and WebSocket support
|
||||
- **Download Manager**: Handles book search, download requests, and queue management (`downloader.py`, `book_manager.py`)
|
||||
- **Network Layer**: Cloudflare bypass and proxy support (`cloudflare_bypasser.py`, `network.py`)
|
||||
|
||||
### Frontend
|
||||
- **React + TypeScript**: Modern web interface built with Vite (`src/frontend`)
|
||||
- **Real-time Updates**: WebSocket integration for live download status
|
||||
- **Responsive UI**: TailwindCSS-based design for mobile and desktop
|
||||
|
||||
For frontend development, use the provided Makefile:
|
||||
```bash
|
||||
make install # Install dependencies
|
||||
make dev # Start development server
|
||||
make build # Build for production
|
||||
```
|
||||
If you run the docker compose file, the frontend will be built and served automatically. But if you run the frontend dev server it will supercede the docker compose frontend.
|
||||
|
||||
## 🏥 Health Monitoring
|
||||
|
||||
@@ -258,7 +299,7 @@ Checks run every 30 seconds with a 30-second timeout and 3 retries.
|
||||
You can enable by adding this to your compose :
|
||||
```
|
||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
||||
CMD pyrequests http://localhost:8084/request/api/status || exit 1
|
||||
CMD curl -s http://localhost:8084/api/status || exit 1
|
||||
```
|
||||
|
||||
## 📝 Logging
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
flask
|
||||
flask-cors
|
||||
flask-socketio
|
||||
python-socketio
|
||||
requests[socks]
|
||||
beautifulsoup4
|
||||
tqdm
|
||||
dnspython
|
||||
gunicorn
|
||||
gevent
|
||||
gevent-websocket
|
||||
psutil
|
||||
emoji
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Source Code Documentation
|
||||
|
||||
This directory contains the frontend application for Calibre-Web Automated Book Downloader.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/
|
||||
└── frontend/ # React + TypeScript frontend application
|
||||
├── public/ # Static assets (logo, favicon)
|
||||
├── src/ # Source code
|
||||
│ ├── components/ # React components
|
||||
│ ├── App.tsx # Main application component
|
||||
│ └── styles.css # Global styles
|
||||
├── package.json # Dependencies and scripts
|
||||
├── vite.config.ts # Vite configuration
|
||||
└── tsconfig.json # TypeScript configuration
|
||||
```
|
||||
|
||||
## Frontend Development
|
||||
|
||||
### Prerequisites
|
||||
- Node.js (v16 or higher)
|
||||
- npm or yarn
|
||||
|
||||
### Quick Start
|
||||
|
||||
From the project root:
|
||||
```bash
|
||||
# Install dependencies
|
||||
make install
|
||||
|
||||
# Start development server (http://localhost:5173)
|
||||
make dev
|
||||
|
||||
# Build for production
|
||||
make build
|
||||
|
||||
# Preview production build
|
||||
make preview
|
||||
|
||||
# Run type checking
|
||||
make typecheck
|
||||
```
|
||||
|
||||
Alternatively, from `src/frontend`:
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Technology Stack
|
||||
- **Framework**: React 18 with TypeScript
|
||||
- **Build Tool**: Vite 5
|
||||
- **Styling**: TailwindCSS 3
|
||||
- **Communication**: WebSocket for real-time updates
|
||||
|
||||
### Key Features
|
||||
- **Search Interface**: Real-time book search with filtering
|
||||
- **Download Queue**: Live status updates via WebSocket
|
||||
- **Details Modal**: Rich book information display
|
||||
- **Responsive Design**: Mobile-first approach
|
||||
|
||||
## Development Tips
|
||||
|
||||
### Hot Module Replacement (HMR)
|
||||
The development server supports HMR for instant feedback during development.
|
||||
|
||||
### API Integration
|
||||
The frontend communicates with the Flask backend via:
|
||||
- REST API endpoints (`/api/*`)
|
||||
- WebSocket connection (`ws://localhost:8084/ws`)
|
||||
|
||||
### Building for Production
|
||||
The production build is optimized and minified:
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
Output is generated in `src/frontend/dist/`
|
||||
|
||||
### Type Safety
|
||||
Run TypeScript checks without building:
|
||||
```bash
|
||||
make typecheck
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### Development Server Issues
|
||||
- Ensure port 5173 is available
|
||||
- Check that the backend is running on port 8084
|
||||
- Verify WebSocket connection in browser console
|
||||
|
||||
### Build Issues
|
||||
- Clear `node_modules` and reinstall: `make clean && make install`
|
||||
- Check Node.js version compatibility
|
||||
- Verify TypeScript configuration
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# Production
|
||||
/dist
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# IDE
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.production.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="description" content="Calibre Web Book Downloader - Modern UI" />
|
||||
|
||||
<!-- Theme color with media queries for light/dark mode -->
|
||||
<meta name="theme-color" content="#f8f8f8" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#121212" media="(prefers-color-scheme: dark)" />
|
||||
|
||||
<!-- iOS PWA Meta Tags -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Book Downloader" />
|
||||
|
||||
<!-- App Icons -->
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<title>Book Downloader</title>
|
||||
<script>
|
||||
// Apply theme immediately before first paint to prevent flash
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('preferred-theme') || 'auto';
|
||||
let theme = savedTheme;
|
||||
|
||||
if (savedTheme === 'auto') {
|
||||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
|
||||
// Add class to prevent transitions on initial load
|
||||
document.documentElement.classList.add('preload');
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body style="background: var(--bg); color: var(--text);">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "cwad-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.2",
|
||||
"socket.io-client": "^4.7.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,560 @@
|
||||
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';
|
||||
import { SearchSection } from './components/SearchSection';
|
||||
import { AdvancedFilters } from './components/AdvancedFilters';
|
||||
import { ResultsSection } from './components/ResultsSection';
|
||||
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 { LANGUAGE_OPTION_DEFAULT } from './utils/languageFilters';
|
||||
import { buildSearchQuery } from './utils/buildSearchQuery';
|
||||
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);
|
||||
const [config, setConfig] = useState<AppConfig | null>(null);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [downloadsSidebarOpen, setDownloadsSidebarOpen] = useState(false);
|
||||
const [lastSearchQuery, setLastSearchQuery] = useState('');
|
||||
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilterState>({
|
||||
isbn: '',
|
||||
author: '',
|
||||
title: '',
|
||||
lang: [LANGUAGE_OPTION_DEFAULT],
|
||||
sort: '',
|
||||
content: '',
|
||||
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
|
||||
const wsUrl = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
|
||||
? 'http://localhost:8084'
|
||||
: window.location.origin;
|
||||
|
||||
// Use realtime status with WebSocket and polling fallback
|
||||
const {
|
||||
status: currentStatus,
|
||||
isUsingWebSocket,
|
||||
forceRefresh: fetchStatus
|
||||
} = useRealtimeStatus({
|
||||
wsUrl,
|
||||
pollInterval: 5000,
|
||||
reconnectAttempts: 3,
|
||||
});
|
||||
|
||||
// Calculate status counts for header badges
|
||||
const getStatusCounts = () => {
|
||||
const ongoing = [
|
||||
currentStatus.queued,
|
||||
currentStatus.resolving,
|
||||
currentStatus.bypassing,
|
||||
currentStatus.downloading,
|
||||
currentStatus.verifying,
|
||||
currentStatus.ingesting,
|
||||
].reduce((sum, status) => sum + (status ? Object.keys(status).length : 0), 0);
|
||||
|
||||
const completed = [
|
||||
currentStatus.completed,
|
||||
currentStatus.complete,
|
||||
currentStatus.available,
|
||||
currentStatus.done,
|
||||
].reduce((sum, status) => sum + (status ? Object.keys(status).length : 0), 0);
|
||||
|
||||
const errored = currentStatus.error ? Object.keys(currentStatus.error).length : 0;
|
||||
|
||||
return { ongoing, completed, errored };
|
||||
};
|
||||
|
||||
const statusCounts = getStatusCounts();
|
||||
const activeCount = statusCounts.ongoing;
|
||||
|
||||
// Compute visibility states
|
||||
const hasResults = books.length > 0;
|
||||
const isInitialState = !hasResults;
|
||||
|
||||
// Detect status changes and show notifications
|
||||
const detectChanges = useCallback((prev: StatusData, curr: StatusData) => {
|
||||
if (!prev || Object.keys(prev).length === 0) return;
|
||||
|
||||
// Check for new items in queue
|
||||
const prevQueued = prev.queued || {};
|
||||
const currQueued = curr.queued || {};
|
||||
Object.keys(currQueued).forEach(bookId => {
|
||||
if (!prevQueued[bookId]) {
|
||||
const book = currQueued[bookId];
|
||||
showToast(`${book.title || 'Book'} added to queue`, 'info');
|
||||
}
|
||||
});
|
||||
|
||||
// Check for items that started downloading
|
||||
const prevDownloading = prev.downloading || {};
|
||||
const currDownloading = curr.downloading || {};
|
||||
Object.keys(currDownloading).forEach(bookId => {
|
||||
if (!prevDownloading[bookId]) {
|
||||
const book = currDownloading[bookId];
|
||||
showToast(`${book.title || 'Book'} started downloading`, 'info');
|
||||
}
|
||||
});
|
||||
|
||||
// Check for completed items
|
||||
const prevDownloadingIds = new Set(Object.keys(prevDownloading));
|
||||
const prevQueuedIds = new Set(Object.keys(prevQueued));
|
||||
const currAvailable = curr.available || {};
|
||||
const currDone = curr.done || {};
|
||||
|
||||
Object.keys(currAvailable).forEach(bookId => {
|
||||
if (prevDownloadingIds.has(bookId) || prevQueuedIds.has(bookId)) {
|
||||
const book = currAvailable[bookId];
|
||||
showToast(`${book.title || 'Book'} completed`, 'success');
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(currDone).forEach(bookId => {
|
||||
if (prevDownloadingIds.has(bookId) || prevQueuedIds.has(bookId)) {
|
||||
const book = currDone[bookId];
|
||||
showToast(`${book.title || 'Book'} completed`, 'success');
|
||||
}
|
||||
});
|
||||
}, [showToast]);
|
||||
|
||||
// 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) {
|
||||
detectChanges(prevStatusRef.current, currentStatus);
|
||||
}
|
||||
prevStatusRef.current = currentStatus;
|
||||
}, [currentStatus, detectChanges]);
|
||||
|
||||
// Fetch config on mount and when authentication changes
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const cfg = await getConfig();
|
||||
setConfig(cfg);
|
||||
} catch (error) {
|
||||
console.error('Failed to load config:', error);
|
||||
// Use defaults if config fails to load
|
||||
}
|
||||
};
|
||||
// Only fetch config if authenticated (or auth is not required)
|
||||
if (isAuthenticated) {
|
||||
loadConfig();
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Log WebSocket connection status changes
|
||||
useEffect(() => {
|
||||
if (isUsingWebSocket) {
|
||||
console.log('✅ Using WebSocket for real-time updates');
|
||||
} else {
|
||||
console.log('⏳ Using polling fallback (5s interval)');
|
||||
}
|
||||
}, [isUsingWebSocket]);
|
||||
|
||||
// Fetch status immediately on startup
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, [fetchStatus]);
|
||||
|
||||
// Search handler
|
||||
const handleSearch = async (query: string) => {
|
||||
if (!query) {
|
||||
setBooks([]);
|
||||
setLastSearchQuery('');
|
||||
return;
|
||||
}
|
||||
setIsSearching(true);
|
||||
setLastSearchQuery(query);
|
||||
try {
|
||||
const results = await searchBooks(query);
|
||||
setBooks(results);
|
||||
if (results.length === 0) {
|
||||
showToast('No results found', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AuthenticationError) {
|
||||
setIsAuthenticated(false);
|
||||
if (authRequired) {
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
} else {
|
||||
console.error('Search failed:', error);
|
||||
setBooks([]);
|
||||
}
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Show book details
|
||||
const handleShowDetails = async (id: string): Promise<void> => {
|
||||
try {
|
||||
const book = await getBookInfo(id);
|
||||
setSelectedBook(book);
|
||||
} catch (error) {
|
||||
console.error('Failed to load book details:', error);
|
||||
showToast('Failed to load book details', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Download book
|
||||
const handleDownload = async (book: Book): Promise<void> => {
|
||||
try {
|
||||
await downloadBook(book.id);
|
||||
// Fetch status to update button states (detectChanges will show toast)
|
||||
await fetchStatus();
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
showToast('Failed to queue download', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel download
|
||||
const handleCancel = async (id: string) => {
|
||||
try {
|
||||
await cancelDownload(id);
|
||||
await fetchStatus();
|
||||
} catch (error) {
|
||||
console.error('Cancel failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear completed
|
||||
const handleClearCompleted = async () => {
|
||||
try {
|
||||
await clearCompleted();
|
||||
await fetchStatus();
|
||||
} catch (error) {
|
||||
console.error('Clear completed failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset search state (clear books and search input)
|
||||
const handleResetSearch = () => {
|
||||
setBooks([]);
|
||||
setSearchInput('');
|
||||
setShowAdvanced(false);
|
||||
setLastSearchQuery('');
|
||||
setAdvancedFilters({
|
||||
isbn: '',
|
||||
author: '',
|
||||
title: '',
|
||||
lang: [LANGUAGE_OPTION_DEFAULT],
|
||||
sort: '',
|
||||
content: '',
|
||||
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
|
||||
if (currentStatus.error && currentStatus.error[bookId]) {
|
||||
return { text: 'Failed', state: 'error' };
|
||||
}
|
||||
// Check completed states
|
||||
if (currentStatus.completed && currentStatus.completed[bookId]) {
|
||||
return { text: 'Downloaded', state: 'completed' };
|
||||
}
|
||||
if (currentStatus.complete && currentStatus.complete[bookId]) {
|
||||
return { text: 'Downloaded', state: 'completed' };
|
||||
}
|
||||
if (currentStatus.available && currentStatus.available[bookId]) {
|
||||
return { text: 'Downloaded', state: 'completed' };
|
||||
}
|
||||
if (currentStatus.done && currentStatus.done[bookId]) {
|
||||
return { text: 'Downloaded', state: 'completed' };
|
||||
}
|
||||
// Check in-progress states with detailed status
|
||||
if (currentStatus.ingesting && currentStatus.ingesting[bookId]) {
|
||||
return { text: 'Ingesting', state: 'ingesting' };
|
||||
}
|
||||
if (currentStatus.verifying && currentStatus.verifying[bookId]) {
|
||||
return { text: 'Verifying', state: 'verifying' };
|
||||
}
|
||||
if (currentStatus.downloading && currentStatus.downloading[bookId]) {
|
||||
const book = currentStatus.downloading[bookId];
|
||||
return {
|
||||
text: 'Downloading',
|
||||
state: 'downloading',
|
||||
progress: book.progress
|
||||
};
|
||||
}
|
||||
if (currentStatus.bypassing && currentStatus.bypassing[bookId]) {
|
||||
return { text: 'Bypassing Cloudflare...', state: 'bypassing' };
|
||||
}
|
||||
if (currentStatus.resolving && currentStatus.resolving[bookId]) {
|
||||
return { text: 'Resolving', state: 'resolving' };
|
||||
}
|
||||
if (currentStatus.queued && currentStatus.queued[bookId]) {
|
||||
return { text: 'Queued', state: 'queued' };
|
||||
}
|
||||
return { text: 'Download', state: 'download' };
|
||||
}, [currentStatus]);
|
||||
|
||||
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 || ''}
|
||||
debug={config?.debug || false}
|
||||
logoUrl="/logo.png"
|
||||
showSearch={!isInitialState}
|
||||
searchInput={searchInput}
|
||||
onSearchChange={setSearchInput}
|
||||
onDownloadsClick={() => setDownloadsSidebarOpen(true)}
|
||||
statusCounts={statusCounts}
|
||||
onLogoClick={handleResetSearch}
|
||||
authRequired={authRequired}
|
||||
isAuthenticated={isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
onSearch={() => {
|
||||
const query = buildSearchQuery({
|
||||
searchInput,
|
||||
showAdvanced,
|
||||
advancedFilters,
|
||||
bookLanguages,
|
||||
defaultLanguage: defaultLanguageCodes,
|
||||
});
|
||||
handleSearch(query);
|
||||
}}
|
||||
onAdvancedToggle={() => setShowAdvanced(!showAdvanced)}
|
||||
isLoading={isSearching}
|
||||
/>
|
||||
|
||||
<AdvancedFilters
|
||||
visible={showAdvanced && !isInitialState}
|
||||
bookLanguages={bookLanguages}
|
||||
defaultLanguage={defaultLanguageCodes}
|
||||
supportedFormats={supportedFormats}
|
||||
filters={advancedFilters}
|
||||
onFiltersChange={updateAdvancedFilters}
|
||||
/>
|
||||
|
||||
<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={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
|
||||
books={books}
|
||||
visible={hasResults}
|
||||
onDetails={handleShowDetails}
|
||||
onDownload={handleDownload}
|
||||
getButtonState={getButtonState}
|
||||
sortValue={advancedFilters.sort}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
|
||||
{selectedBook && (
|
||||
<DetailsModal
|
||||
book={selectedBook}
|
||||
onClose={() => setSelectedBook(null)}
|
||||
onDownload={handleDownload}
|
||||
buttonState={getButtonState(selectedBook.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</main>
|
||||
|
||||
<Footer
|
||||
buildVersion={config?.build_version || 'dev'}
|
||||
releaseVersion={config?.release_version || 'dev'}
|
||||
appEnv={config?.app_env || 'development'}
|
||||
/>
|
||||
<ToastContainer toasts={toasts} />
|
||||
|
||||
{/* Downloads Sidebar */}
|
||||
<DownloadsSidebar
|
||||
isOpen={downloadsSidebarOpen}
|
||||
onClose={() => setDownloadsSidebarOpen(false)}
|
||||
status={currentStatus}
|
||||
onRefresh={fetchStatus}
|
||||
onClearCompleted={handleClearCompleted}
|
||||
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;
|
||||
@@ -0,0 +1,165 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { AdvancedFilterState, Language } from '../types';
|
||||
import { normalizeLanguageSelection } from '../utils/languageFilters';
|
||||
import { LanguageMultiSelect } from './LanguageMultiSelect';
|
||||
import { DropdownList } from './DropdownList';
|
||||
import { CONTENT_OPTIONS } from '../data/filterOptions';
|
||||
|
||||
const FORMAT_TYPES = ['pdf', 'epub', 'mobi', 'azw3', 'fb2', 'djvu', 'cbz', 'cbr'] as const;
|
||||
|
||||
interface AdvancedFiltersProps {
|
||||
visible: boolean;
|
||||
bookLanguages: Language[];
|
||||
defaultLanguage: string[];
|
||||
supportedFormats: string[];
|
||||
filters: AdvancedFilterState;
|
||||
onFiltersChange: (updates: Partial<AdvancedFilterState>) => void;
|
||||
formClassName?: string;
|
||||
renderWrapper?: (form: ReactNode) => ReactNode;
|
||||
}
|
||||
|
||||
export const AdvancedFilters = ({
|
||||
visible,
|
||||
bookLanguages,
|
||||
defaultLanguage,
|
||||
supportedFormats,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
formClassName,
|
||||
renderWrapper,
|
||||
}: AdvancedFiltersProps) => {
|
||||
const { isbn, author, title, lang, content, formats } = filters;
|
||||
|
||||
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 handleFormatsChange = (next: string[] | string) => {
|
||||
const nextFormats = Array.isArray(next) ? next : next ? [next] : [];
|
||||
onFiltersChange({ formats: nextFormats });
|
||||
};
|
||||
|
||||
const formatOptions = FORMAT_TYPES.map(format => ({
|
||||
value: format,
|
||||
label: format.toUpperCase(),
|
||||
disabled: !supportedFormats.includes(format),
|
||||
}));
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const form = (
|
||||
<form
|
||||
id="search-filters"
|
||||
className={
|
||||
formClassName ??
|
||||
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 lg:ml-[calc(3rem+1rem)] lg:w-[50vw]'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="isbn-input" className="block text-sm mb-1 opacity-80">
|
||||
ISBN
|
||||
</label>
|
||||
<input
|
||||
id="isbn-input"
|
||||
type="text"
|
||||
placeholder="ISBN"
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 rounded-md border"
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
color: 'var(--text)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
value={isbn}
|
||||
onChange={e => {
|
||||
onFiltersChange({ isbn: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="author-input" className="block text-sm mb-1 opacity-80">
|
||||
Author
|
||||
</label>
|
||||
<input
|
||||
id="author-input"
|
||||
type="text"
|
||||
placeholder="Author"
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 rounded-md border"
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
color: 'var(--text)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
value={author}
|
||||
onChange={e => {
|
||||
onFiltersChange({ author: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="title-input" className="block text-sm mb-1 opacity-80">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
id="title-input"
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 rounded-md border"
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
color: 'var(--text)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
value={title}
|
||||
onChange={e => {
|
||||
onFiltersChange({ title: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
<DropdownList
|
||||
label="Formats"
|
||||
placeholder="Any"
|
||||
options={formatOptions}
|
||||
value={formats}
|
||||
onChange={handleFormatsChange}
|
||||
multiple
|
||||
showCheckboxes
|
||||
keepOpenOnSelect
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
const wrappedForm = renderWrapper ? (
|
||||
renderWrapper(form)
|
||||
) : (
|
||||
<div className="w-full border-b pt-6 pb-4 mb-4" style={{ borderColor: 'var(--border-muted)' }}>
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8">{form}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return wrappedForm;
|
||||
};
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useEffect, useState, CSSProperties } from 'react';
|
||||
import { ButtonStateInfo } from '../types';
|
||||
|
||||
interface CircularProgressProps {
|
||||
progress?: number;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
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={svgClassName}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
opacity="0.3"
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
style={{ transition: 'stroke-dashoffset 0.3s ease' }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
type ButtonSize = 'sm' | 'md';
|
||||
type ButtonVariant = 'primary' | 'icon';
|
||||
|
||||
interface BookDownloadButtonProps {
|
||||
buttonState: ButtonStateInfo;
|
||||
onDownload: () => Promise<void>;
|
||||
size?: ButtonSize;
|
||||
fullWidth?: boolean;
|
||||
className?: string;
|
||||
showIcon?: boolean;
|
||||
style?: CSSProperties;
|
||||
variant?: ButtonVariant;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'px-2.5 py-1.5 text-xs',
|
||||
md: 'px-4 py-2.5 text-sm',
|
||||
};
|
||||
|
||||
const iconVariantSizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'p-1 sm:p-1.5',
|
||||
md: 'p-1.5 sm:p-2',
|
||||
};
|
||||
|
||||
const primaryIconSizes: Record<ButtonSize, string> = {
|
||||
sm: 'w-3.5 h-3.5',
|
||||
md: 'w-4 h-4',
|
||||
};
|
||||
|
||||
const iconVariantIconSizes: Record<ButtonSize, { mobile: string; desktop: string }> = {
|
||||
sm: { mobile: 'w-3.5 h-3.5', desktop: 'w-4 h-4' },
|
||||
md: { mobile: 'w-4 h-4', desktop: 'w-5 h-5' },
|
||||
};
|
||||
|
||||
const iconVariantProgressSizes: Record<ButtonSize, { mobile: number; desktop: number }> = {
|
||||
sm: { mobile: 14, desktop: 16 },
|
||||
md: { mobile: 16, desktop: 20 },
|
||||
};
|
||||
|
||||
export const BookDownloadButton = ({
|
||||
buttonState,
|
||||
onDownload,
|
||||
size = 'md',
|
||||
fullWidth = false,
|
||||
className = '',
|
||||
showIcon = false,
|
||||
style,
|
||||
variant = 'primary',
|
||||
ariaLabel,
|
||||
}: 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 primaryStateClasses =
|
||||
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 iconStateClasses =
|
||||
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-action';
|
||||
|
||||
const stateClasses = variant === 'icon' ? iconStateClasses : primaryStateClasses;
|
||||
const widthClasses = variant === 'primary' && fullWidth ? 'w-full' : '';
|
||||
|
||||
const baseClasses =
|
||||
variant === 'icon'
|
||||
? 'flex items-center justify-center rounded-full 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'
|
||||
: '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 sizeClass = variant === 'icon' ? iconVariantSizeClasses[size] : sizeClasses[size];
|
||||
const iconSizes = variant === 'icon' ? iconVariantIconSizes[size] : undefined;
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (isDisabled) return;
|
||||
setIsQueuing(true);
|
||||
try {
|
||||
await onDownload();
|
||||
} catch (error) {
|
||||
setIsQueuing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStatusIcon = () => {
|
||||
if (isCompleted) {
|
||||
if (variant === 'icon' && iconSizes) {
|
||||
return (
|
||||
<>
|
||||
<svg className={`${iconSizes.mobile} sm:hidden`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<svg className={`${iconSizes.desktop} hidden sm:block`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className={primaryIconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
if (variant === 'icon' && iconSizes) {
|
||||
return (
|
||||
<>
|
||||
<svg className={`${iconSizes.mobile} sm:hidden`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<svg className={`${iconSizes.desktop} hidden sm:block`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className={primaryIconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (showCircularProgress) {
|
||||
if (variant === 'icon') {
|
||||
const sizes = iconVariantProgressSizes[size];
|
||||
return (
|
||||
<>
|
||||
<CircularProgress progress={buttonState.progress} size={sizes.mobile} className="block sm:hidden" />
|
||||
<CircularProgress progress={buttonState.progress} size={sizes.desktop} className="hidden sm:block" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <CircularProgress progress={buttonState.progress} size={size === 'sm' ? 12 : 16} />;
|
||||
}
|
||||
|
||||
if (showSpinner) {
|
||||
const spinnerClass =
|
||||
variant === 'icon'
|
||||
? size === 'sm'
|
||||
? 'w-3.5 h-3.5 sm:w-4 h-4'
|
||||
: 'w-4 h-4 sm:w-5 h-5'
|
||||
: size === 'sm'
|
||||
? 'w-3 h-3'
|
||||
: 'w-4 h-4';
|
||||
return <div className={`${spinnerClass} border-2 border-current border-t-transparent rounded-full animate-spin`} />;
|
||||
}
|
||||
|
||||
if (variant === 'icon' && iconSizes) {
|
||||
return (
|
||||
<>
|
||||
<svg className={`${iconSizes.mobile} sm:hidden`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
|
||||
<svg className={`${iconSizes.desktop} hidden sm:block`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseClasses} ${sizeClass} ${stateClasses} ${widthClasses} ${className}`.trim()}
|
||||
onClick={handleDownload}
|
||||
disabled={isDisabled || isInProgress}
|
||||
data-action="download"
|
||||
style={style}
|
||||
aria-label={ariaLabel ?? displayText}
|
||||
>
|
||||
{variant === 'primary' && showIcon && !isCompleted && !hasError && !showCircularProgress && !showSpinner && (
|
||||
<svg className={primaryIconSizes[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>
|
||||
)}
|
||||
|
||||
{variant === 'primary' && <span className="download-button-text">{displayText}</span>}
|
||||
{variant === 'icon' && <span className="sr-only">{ariaLabel ?? displayText}</span>}
|
||||
|
||||
{renderStatusIcon()}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Book, ButtonStateInfo } from '../types';
|
||||
import { BookDownloadButton } from './BookDownloadButton';
|
||||
|
||||
interface DetailsModalProps {
|
||||
book: Book | null;
|
||||
onClose: () => void;
|
||||
onDownload: (book: Book) => Promise<void>;
|
||||
buttonState: ButtonStateInfo;
|
||||
}
|
||||
|
||||
export const DetailsModal = ({ book, onClose, onDownload, buttonState }: DetailsModalProps) => {
|
||||
const [isQueuing, setIsQueuing] = useState(false);
|
||||
|
||||
// Clear queuing state and close modal once button state changes from download
|
||||
useEffect(() => {
|
||||
if (isQueuing && buttonState.state !== 'download') {
|
||||
setIsQueuing(false);
|
||||
// Close modal after status has updated
|
||||
const timer = setTimeout(onClose, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [buttonState.state, isQueuing, onClose]);
|
||||
|
||||
// Handle ESC key to close modal
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
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 handleDownload = async () => {
|
||||
setIsQueuing(true);
|
||||
try {
|
||||
await onDownload(book);
|
||||
// Don't close here - wait for button state to change
|
||||
} catch (error) {
|
||||
setIsQueuing(false);
|
||||
// Close on error
|
||||
setTimeout(onClose, 300);
|
||||
}
|
||||
};
|
||||
|
||||
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';
|
||||
})
|
||||
: [];
|
||||
const extendedInfoEntries = [[publisherInfo.label, publisherInfo.value], ...additionalInfo];
|
||||
const infoCardClass = 'rounded-2xl border border-[var(--border-muted)] px-4 py-3 text-sm';
|
||||
const infoCardStyle = { background: 'var(--bg)' };
|
||||
const infoLabelClass = 'text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400';
|
||||
const infoValueClass = 'text-gray-900 dark:text-gray-100';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay active px-4 py-6 sm:px-6"
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full p-2 text-gray-500 transition-colors hover-action hover:text-gray-900 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">
|
||||
{book.description && (
|
||||
<div className={`${infoCardClass} space-y-1`} style={infoCardStyle}>
|
||||
<p className={infoLabelClass}>Description</p>
|
||||
<p className={`${infoValueClass} whitespace-pre-line`}>{book.description}</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={`${infoCardClass} space-y-1`} style={infoCardStyle}>
|
||||
<p className={infoLabelClass}>{item.label}</p>
|
||||
<p className={infoValueClass}>{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{extendedInfoEntries.length > 0 && (
|
||||
<div className={`${infoCardClass} space-y-3`} style={infoCardStyle}>
|
||||
<ul className="space-y-3 list-none">
|
||||
{extendedInfoEntries.map(([key, value]) => (
|
||||
<li key={key} className="space-y-1">
|
||||
<p className={infoLabelClass}>{key}</p>
|
||||
<p className={infoValueClass}>{Array.isArray(value) ? value.join(', ') : value}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-[var(--border-muted)] bg-[var(--bg-soft)] px-5 py-4">
|
||||
<div className="flex justify-end">
|
||||
<BookDownloadButton
|
||||
buttonState={buttonState}
|
||||
onDownload={handleDownload}
|
||||
size="md"
|
||||
fullWidth
|
||||
className="rounded-full px-4 py-3 text-sm font-medium"
|
||||
ariaLabel={`Download ${book.title || 'book'}`}
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,381 @@
|
||||
import { useEffect } from 'react';
|
||||
import { StatusData, Book } from '../types';
|
||||
|
||||
interface DownloadsSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
status: StatusData;
|
||||
onRefresh: () => void;
|
||||
onClearCompleted: () => void;
|
||||
onCancel: (id: string) => void;
|
||||
activeCount: number;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||
queued: { bg: 'bg-amber-500/10', text: 'text-amber-600', label: 'Queued' },
|
||||
resolving: { bg: 'bg-indigo-500/10', text: 'text-indigo-600', label: 'Resolving' },
|
||||
bypassing: { bg: 'bg-purple-500/10', text: 'text-purple-600', label: 'Bypassing Cloudflare...' },
|
||||
downloading: { bg: 'bg-blue-500/10', text: 'text-blue-600', label: 'Downloading' },
|
||||
verifying: { bg: 'bg-cyan-500/10', text: 'text-cyan-600', label: 'Verifying' },
|
||||
ingesting: { bg: 'bg-teal-500/10', text: 'text-teal-600', label: 'Ingesting' },
|
||||
complete: { bg: 'bg-green-500/10', text: 'text-green-600', label: 'Complete' },
|
||||
completed: { bg: 'bg-green-500/10', text: 'text-green-600', label: 'Completed' },
|
||||
available: { bg: 'bg-green-500/10', text: 'text-green-600', label: 'Available' },
|
||||
done: { bg: 'bg-green-500/10', text: 'text-green-600', label: 'Done' },
|
||||
error: { bg: 'bg-red-500/10', text: 'text-red-600', label: 'Error' },
|
||||
cancelled: { bg: 'bg-gray-500/10', text: 'text-gray-600', label: 'Cancelled' },
|
||||
};
|
||||
|
||||
// Helper to format file size
|
||||
const formatSize = (sizeStr?: string): string => {
|
||||
if (!sizeStr) return '';
|
||||
return sizeStr;
|
||||
};
|
||||
|
||||
// Add keyframe animation for wave effect
|
||||
const styleSheet = document.createElement('style');
|
||||
styleSheet.textContent = `
|
||||
@keyframes wave {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
`;
|
||||
if (!document.head.querySelector('style[data-wave-animation]')) {
|
||||
styleSheet.setAttribute('data-wave-animation', 'true');
|
||||
document.head.appendChild(styleSheet);
|
||||
}
|
||||
|
||||
// Helper to get book preview image
|
||||
const getBookPreview = (book: Book): string => {
|
||||
return book.preview || '/placeholder-book.png';
|
||||
};
|
||||
|
||||
// Helper to get progress percentage based on status
|
||||
const getStatusProgress = (statusName: string, bookProgress?: number): number => {
|
||||
switch (statusName) {
|
||||
case 'queued':
|
||||
return 5;
|
||||
case 'resolving':
|
||||
return 10;
|
||||
case 'bypassing':
|
||||
return 15;
|
||||
case 'downloading':
|
||||
// Map actual progress (0-100) to 20-90 range
|
||||
if (typeof bookProgress === 'number') {
|
||||
return 20 + (bookProgress * 0.7);
|
||||
}
|
||||
return 20;
|
||||
case 'verifying':
|
||||
return 95;
|
||||
case 'ingesting':
|
||||
return 99;
|
||||
case 'completed':
|
||||
case 'complete':
|
||||
case 'available':
|
||||
case 'done':
|
||||
return 100;
|
||||
case 'error':
|
||||
return 100;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to get progress bar color based on status
|
||||
const getProgressBarColor = (statusName: string): string => {
|
||||
const isCompleted = ['completed', 'complete', 'available', 'done'].includes(statusName);
|
||||
if (isCompleted) return 'bg-green-600';
|
||||
if (statusName === 'error') return 'bg-red-600';
|
||||
if (statusName === 'queued') return 'bg-gray-600';
|
||||
if (statusName === 'resolving') return 'bg-purple-600';
|
||||
if (statusName === 'bypassing') return 'bg-violet-600';
|
||||
if (statusName === 'downloading') return 'bg-sky-600';
|
||||
if (statusName === 'verifying') return 'bg-cyan-600';
|
||||
if (statusName === 'ingesting') return 'bg-teal-600';
|
||||
return 'bg-sky-600';
|
||||
};
|
||||
|
||||
export const DownloadsSidebar = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
status,
|
||||
onRefresh,
|
||||
onClearCompleted,
|
||||
onCancel,
|
||||
activeCount,
|
||||
}: DownloadsSidebarProps) => {
|
||||
// Handle ESC key to close sidebar
|
||||
useEffect(() => {
|
||||
if (!isOpen) return; // Only listen when sidebar is open
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Collect all download items from different status sections
|
||||
const allDownloadItems: Array<{ book: Book; status: string; order: number }> = [];
|
||||
|
||||
// Priority order for display
|
||||
const statusOrder = ['downloading', 'bypassing', 'resolving', 'queued', 'verifying', 'ingesting', 'error', 'completed', 'complete', 'available', 'done', 'cancelled'];
|
||||
|
||||
statusOrder.forEach((statusName, index) => {
|
||||
const items = (status as any)[statusName];
|
||||
if (items && Object.keys(items).length > 0) {
|
||||
Object.values(items).forEach((book: any) => {
|
||||
allDownloadItems.push({ book, status: statusName, order: index });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by status priority
|
||||
allDownloadItems.sort((a, b) => a.order - b.order);
|
||||
|
||||
const renderDownloadItem = (item: { book: Book; status: string }) => {
|
||||
const { book, status: statusName } = item;
|
||||
const statusStyle = STATUS_STYLES[statusName] || {
|
||||
bg: 'bg-gray-500/10',
|
||||
text: 'text-gray-600',
|
||||
label: statusName.charAt(0).toUpperCase() + statusName.slice(1),
|
||||
};
|
||||
|
||||
const isInProgress = ['queued', 'resolving', 'bypassing', 'downloading', 'verifying', 'ingesting'].includes(statusName);
|
||||
const isCompleted = ['completed', 'complete', 'available', 'done'].includes(statusName);
|
||||
const hasError = statusName === 'error';
|
||||
|
||||
// Get progress information
|
||||
const progress = getStatusProgress(statusName, book.progress);
|
||||
const progressBarColor = getProgressBarColor(statusName);
|
||||
|
||||
// Format progress text
|
||||
let progressText = statusStyle.label;
|
||||
if (statusName === 'downloading' && book.progress && book.size) {
|
||||
const downloadedMB = (book.progress / 100) * parseFloat(book.size.replace(/[^\d.]/g, ''));
|
||||
progressText = `${downloadedMB.toFixed(1)}mb / ${book.size}`;
|
||||
} else if (isCompleted) {
|
||||
progressText = 'Complete';
|
||||
} else if (hasError) {
|
||||
progressText = 'Failed';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={book.id}
|
||||
className="relative rounded-lg border hover:shadow-md transition-shadow overflow-hidden"
|
||||
style={{ borderColor: 'var(--border-muted)', background: 'var(--bg-soft)' }}
|
||||
>
|
||||
{/* Main content area */}
|
||||
<div className="flex gap-2">
|
||||
{/* Book Thumbnail - left side */}
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src={getBookPreview(book)}
|
||||
alt={book.title || 'Book cover'}
|
||||
className="w-16 h-24 object-cover rounded shadow-sm"
|
||||
style={{ aspectRatio: '2/3' }}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = '/placeholder-book.png';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Book Info - right side */}
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-between px-3 pt-2 pb-3">
|
||||
{/* Title & Author */}
|
||||
<div className="mb-1">
|
||||
<h3 className="font-semibold text-sm truncate" title={book.title}>
|
||||
{isCompleted && book.download_path ? (
|
||||
<a
|
||||
href={`/api/localdownload?id=${encodeURIComponent(book.id)}`}
|
||||
className="text-sky-600 hover:underline"
|
||||
>
|
||||
{book.title || 'Unknown Title'}
|
||||
</a>
|
||||
) : (
|
||||
book.title || 'Unknown Title'
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-xs opacity-70 truncate" title={book.author}>
|
||||
{book.author || 'Unknown Author'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Badge and Details Row */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${statusStyle.bg} ${statusStyle.text}`}
|
||||
>
|
||||
{statusStyle.label}
|
||||
</span>
|
||||
|
||||
{/* Cancel Button for in-progress items */}
|
||||
{isInProgress && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCancel(book.id)}
|
||||
className="text-xs px-2 py-1 rounded border hover-action transition-colors"
|
||||
style={{ borderColor: 'var(--border-muted)' }}
|
||||
title="Cancel download"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Format and Size */}
|
||||
<div className="text-xs opacity-70">
|
||||
{book.format && <span className="uppercase">{book.format}</span>}
|
||||
{book.format && book.size && <span> • </span>}
|
||||
{book.size && <span>{formatSize(book.size)}</span>}
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{hasError && (
|
||||
<p className="text-xs text-red-600">Download failed</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar - absolute positioned at bottom - always visible */}
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<p className="text-xs opacity-70 mt-0.5 text-right p-2">{progressText}</p>
|
||||
<div className="h-1.5 bg-gray-200 dark:bg-gray-700 overflow-hidden relative">
|
||||
<div
|
||||
className={`h-full ${progressBarColor} transition-all duration-300 relative overflow-hidden`}
|
||||
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
|
||||
>
|
||||
{/* Animated wave effect for in-progress states */}
|
||||
{isInProgress && progress < 100 && (
|
||||
<div
|
||||
className="absolute inset-0 opacity-30"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.5) 50%, transparent 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'wave 2s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/50 z-40 transition-opacity duration-300 ${
|
||||
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-full w-full sm:w-96 z-50 flex flex-col shadow-2xl transition-transform duration-300 ${
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
style={{ background: 'var(--bg)' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between p-4"
|
||||
style={{ paddingTop: 'calc(1rem + env(safe-area-inset-top))' }}
|
||||
>
|
||||
<h2 className="text-lg font-semibold">Downloads</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full hover-action transition-colors"
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div
|
||||
className="flex items-center gap-2 p-4 border-b"
|
||||
style={{ borderColor: 'var(--border-muted)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearCompleted}
|
||||
className="flex-1 flex items-center justify-center px-3 py-2 h-10 rounded border text-sm hover-action transition-colors"
|
||||
style={{ borderColor: 'var(--border-muted)' }}
|
||||
>
|
||||
Clear Completed
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
className="flex items-center justify-center h-10 w-10 rounded-full text-sm hover-action transition-colors ml-auto"
|
||||
aria-label="Refresh"
|
||||
title="Refresh"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Queue Items */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto p-4 space-y-3"
|
||||
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
|
||||
>
|
||||
{allDownloadItems.length > 0 ? (
|
||||
allDownloadItems.map((item) => renderDownloadItem(item))
|
||||
) : (
|
||||
<div className="text-center text-sm opacity-70 mt-8">
|
||||
No downloads in queue
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with active count */}
|
||||
{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))',
|
||||
}}
|
||||
>
|
||||
{activeCount} active {activeCount === 1 ? 'download' : 'downloads'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import { ReactNode, useEffect, useLayoutEffect, 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 panelRef = useRef<HTMLDivElement>(null);
|
||||
const [panelDirection, setPanelDirection] = useState<'down' | 'up'>('down');
|
||||
|
||||
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]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const updatePanelDirection = () => {
|
||||
if (!containerRef.current || !panelRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const panelHeight = panelRef.current.offsetHeight || panelRef.current.scrollHeight;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - 8;
|
||||
const spaceAbove = rect.top - 8;
|
||||
const shouldOpenUp = spaceBelow < panelHeight && spaceAbove >= panelHeight;
|
||||
|
||||
setPanelDirection(shouldOpenUp ? 'up' : 'down');
|
||||
};
|
||||
|
||||
updatePanelDirection();
|
||||
window.addEventListener('resize', updatePanelDirection);
|
||||
window.addEventListener('scroll', updatePanelDirection, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePanelDirection);
|
||||
window.removeEventListener('scroll', updatePanelDirection, true);
|
||||
};
|
||||
}, [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
|
||||
ref={panelRef}
|
||||
className={`absolute ${align === 'right' ? 'right-0' : 'left-0'} ${
|
||||
panelDirection === 'down' ? 'mt-2' : 'bottom-full mb-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-surface ${
|
||||
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 [];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
interface FooterProps {
|
||||
buildVersion?: string;
|
||||
releaseVersion?: string;
|
||||
appEnv?: string;
|
||||
}
|
||||
|
||||
export const Footer = ({ buildVersion, releaseVersion, appEnv }: FooterProps) => {
|
||||
return (
|
||||
<footer
|
||||
className="mt-8 border-t py-6"
|
||||
style={{
|
||||
borderColor: 'var(--border-muted)',
|
||||
paddingBottom: 'calc(1.5rem + env(safe-area-inset-bottom))',
|
||||
}}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0 text-left">
|
||||
<p className="text-sm opacity-80">Calibre Web Book Downloader</p>
|
||||
<p className="text-xs opacity-60 mt-1">
|
||||
Build: {buildVersion || 'dev'} • Release: {releaseVersion || 'dev'} • Env:{' '}
|
||||
{appEnv || 'development'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,410 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { SearchBar } from './SearchBar';
|
||||
|
||||
interface StatusCounts {
|
||||
ongoing: number;
|
||||
completed: number;
|
||||
errored: number;
|
||||
}
|
||||
|
||||
interface HeaderProps {
|
||||
calibreWebUrl?: string;
|
||||
debug?: boolean;
|
||||
logoUrl?: string;
|
||||
showSearch?: boolean;
|
||||
searchInput?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
onSearch?: () => void;
|
||||
onAdvancedToggle?: () => void;
|
||||
isLoading?: boolean;
|
||||
onDownloadsClick?: () => void;
|
||||
statusCounts?: StatusCounts;
|
||||
onLogoClick?: () => void;
|
||||
authRequired?: boolean;
|
||||
isAuthenticated?: boolean;
|
||||
onLogout?: () => void;
|
||||
}
|
||||
|
||||
export const Header = ({
|
||||
calibreWebUrl,
|
||||
debug,
|
||||
logoUrl,
|
||||
showSearch = false,
|
||||
searchInput = '',
|
||||
onSearchChange,
|
||||
onSearch,
|
||||
onAdvancedToggle,
|
||||
isLoading = false,
|
||||
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';
|
||||
setTheme(saved);
|
||||
applyTheme(saved);
|
||||
|
||||
// Remove preload class after initial theme is applied to enable transitions
|
||||
requestAnimationFrame(() => {
|
||||
document.documentElement.classList.remove('preload');
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
if (localStorage.getItem('preferred-theme') === 'auto') {
|
||||
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
||||
}
|
||||
};
|
||||
mq.addEventListener('change', handler);
|
||||
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;
|
||||
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', pref);
|
||||
}
|
||||
};
|
||||
|
||||
const handleThemeChange = (newTheme: string) => {
|
||||
localStorage.setItem('preferred-theme', newTheme);
|
||||
setTheme(newTheme);
|
||||
applyTheme(newTheme);
|
||||
};
|
||||
|
||||
const cycleTheme = () => {
|
||||
const themeOrder = ['light', 'dark', 'auto'];
|
||||
const currentIndex = themeOrder.indexOf(theme);
|
||||
const nextIndex = (currentIndex + 1) % themeOrder.length;
|
||||
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 handleHeaderSearch = () => {
|
||||
onSearch?.();
|
||||
};
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
onSearchChange?.(value);
|
||||
};
|
||||
|
||||
// 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-action 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 flex items-center gap-2 px-3 py-2 rounded-full hover-action 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-[0.55rem] font-bold rounded-full w-3.5 h-3.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-action 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"
|
||||
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.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
</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-surface 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>
|
||||
|
||||
<a
|
||||
href="https://github.com/calibrain/calibre-web-automated-book-downloader/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3 text-slate-700 dark:text-slate-200"
|
||||
title="Submit a bug report"
|
||||
>
|
||||
<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 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5"
|
||||
/>
|
||||
</svg>
|
||||
<span>Report a Bug</span>
|
||||
</a>
|
||||
|
||||
{/* Debug Buttons */}
|
||||
{debug && (
|
||||
<>
|
||||
<form action="/debug" method="get" className="w-full">
|
||||
<button
|
||||
className="w-full text-left px-4 py-2 hover-surface 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="/api/restart" method="get" className="w-full">
|
||||
<button
|
||||
className="w-full text-left px-4 py-2 hover-surface 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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Logout Button */}
|
||||
{authRequired && isAuthenticated && onLogout && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="w-full text-left px-4 py-2 hover-surface 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)' }}
|
||||
>
|
||||
<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'
|
||||
}`}>
|
||||
{/* When search is active: stack on mobile, side-by-side on desktop */}
|
||||
{showSearch && (
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3">
|
||||
{/* Logo + Icon buttons - appear first on mobile (above search), last on desktop (right side) */}
|
||||
<div className="flex items-center justify-between w-full lg:w-auto lg:justify-end lg:order-2">
|
||||
{/* Logo - visible on mobile only, aligned left */}
|
||||
{logoUrl && (
|
||||
<img
|
||||
src={logoUrl}
|
||||
onClick={onLogoClick}
|
||||
alt="Logo"
|
||||
className="h-10 w-10 flex-shrink-0 cursor-pointer lg:hidden"
|
||||
/>
|
||||
)}
|
||||
|
||||
<IconButtons />
|
||||
</div>
|
||||
|
||||
{/* Search bar - appear second on mobile (below logo+icons), first on desktop (left side) */}
|
||||
<div className="flex items-center gap-4 lg:order-1 flex-1">
|
||||
{/* Logo - visible on desktop only, aligned with search */}
|
||||
{logoUrl && (
|
||||
<img
|
||||
src={logoUrl}
|
||||
onClick={onLogoClick}
|
||||
alt="Logo"
|
||||
className="hidden lg:block h-12 w-12 flex-shrink-0 cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
<SearchBar
|
||||
className="flex-1 lg:flex-initial"
|
||||
inputClassName="lg:w-[50vw]"
|
||||
value={searchInput}
|
||||
onChange={handleSearchChange}
|
||||
onSubmit={handleHeaderSearch}
|
||||
onAdvancedToggle={onAdvancedToggle}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* When search is NOT active: show icon buttons only on the right */}
|
||||
{!showSearch && (
|
||||
<div className="flex items-center justify-end h-full">
|
||||
<IconButtons />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -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-action 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Book, ButtonStateInfo } from '../types';
|
||||
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[];
|
||||
visible: boolean;
|
||||
onDetails: (id: string) => Promise<void>;
|
||||
onDownload: (book: Book) => Promise<void>;
|
||||
getButtonState: (bookId: string) => ButtonStateInfo;
|
||||
sortValue: string;
|
||||
onSortChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export const ResultsSection = ({
|
||||
books,
|
||||
visible,
|
||||
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-4 sm:mb-8 w-full">
|
||||
<div className="flex items-center justify-between mb-2 sm:mb-3 relative z-10">
|
||||
<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-action 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-action text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
title="Compact view"
|
||||
aria-label="Compact view"
|
||||
aria-pressed={viewMode === 'compact'}
|
||||
>
|
||||
<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-action 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-action ${
|
||||
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-surface ${
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
import { KeyboardEvent, InputHTMLAttributes, useRef } from 'react';
|
||||
|
||||
interface SearchBarProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
isLoading?: boolean;
|
||||
onAdvancedToggle?: () => void;
|
||||
placeholder?: string;
|
||||
inputAriaLabel?: string;
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
controlsClassName?: string;
|
||||
clearButtonLabel?: string;
|
||||
clearButtonTitle?: string;
|
||||
advancedButtonLabel?: string;
|
||||
advancedButtonTitle?: string;
|
||||
searchButtonLabel?: string;
|
||||
searchButtonTitle?: string;
|
||||
autoComplete?: string;
|
||||
enterKeyHint?: InputHTMLAttributes<HTMLInputElement>['enterKeyHint'];
|
||||
}
|
||||
|
||||
export const SearchBar = ({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
onAdvancedToggle,
|
||||
placeholder = 'Search by ISBN, title, author...',
|
||||
inputAriaLabel = 'Search books',
|
||||
className = '',
|
||||
inputClassName = '',
|
||||
controlsClassName = '',
|
||||
clearButtonLabel = 'Clear search input',
|
||||
clearButtonTitle = 'Clear search',
|
||||
advancedButtonLabel = 'Advanced Search',
|
||||
advancedButtonTitle = 'Advanced Search',
|
||||
searchButtonLabel = 'Search books',
|
||||
searchButtonTitle = 'Search',
|
||||
autoComplete = 'off',
|
||||
enterKeyHint = 'search',
|
||||
}: SearchBarProps) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const hasSearchQuery = value.trim().length > 0;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
onSubmit();
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
onChange('');
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const wrapperClasses = ['relative', className].filter(Boolean).join(' ').trim();
|
||||
const inputClasses = [
|
||||
'w-full pl-4 pr-40 py-3 rounded-full border outline-none search-input',
|
||||
inputClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim();
|
||||
const controlsClasses = [
|
||||
'absolute inset-y-0 right-0 flex items-center gap-1 pr-2',
|
||||
controlsClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim();
|
||||
|
||||
return (
|
||||
<div className={wrapperClasses}>
|
||||
<input
|
||||
type="search"
|
||||
placeholder={placeholder}
|
||||
aria-label={inputAriaLabel}
|
||||
autoComplete={autoComplete}
|
||||
enterKeyHint={enterKeyHint}
|
||||
className={inputClasses}
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
color: 'var(--text)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<div className={controlsClasses}>
|
||||
{hasSearchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearSearch}
|
||||
className="p-2 rounded-full hover-action flex items-center justify-center transition-colors"
|
||||
aria-label={clearButtonLabel}
|
||||
title={clearButtonTitle}
|
||||
>
|
||||
<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"
|
||||
style={{ color: 'var(--text)' }}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onAdvancedToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdvancedToggle}
|
||||
className="p-2 rounded-full hover-action flex items-center justify-center transition-colors"
|
||||
aria-label={advancedButtonLabel}
|
||||
title={advancedButtonTitle}
|
||||
>
|
||||
<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"
|
||||
style={{ color: 'var(--text)' }}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
className="p-2 rounded-full text-white bg-sky-700 hover:bg-sky-800 disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center transition-colors search-bar-button"
|
||||
aria-label={searchButtonLabel}
|
||||
title={searchButtonTitle}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{!isLoading && (
|
||||
<svg
|
||||
className="w-5 h-5 search-bar-icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="spinner w-3 h-3 border-2 border-white border-t-transparent search-bar-spinner" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { AdvancedFilterState, Language } from '../types';
|
||||
import { buildSearchQuery } from '../utils/buildSearchQuery';
|
||||
import { AdvancedFilters } from './AdvancedFilters';
|
||||
import { SearchBar } from './SearchBar';
|
||||
|
||||
interface SearchSectionProps {
|
||||
onSearch: (query: string) => void;
|
||||
isLoading: boolean;
|
||||
isInitialState: boolean;
|
||||
bookLanguages: Language[];
|
||||
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 = ({
|
||||
onSearch,
|
||||
isLoading,
|
||||
isInitialState,
|
||||
bookLanguages,
|
||||
defaultLanguage,
|
||||
supportedFormats,
|
||||
logoUrl,
|
||||
searchInput,
|
||||
onSearchInputChange,
|
||||
showAdvanced,
|
||||
onAdvancedToggle,
|
||||
advancedFilters,
|
||||
onAdvancedFiltersChange,
|
||||
}: SearchSectionProps) => {
|
||||
const handleSearch = () => {
|
||||
const query = buildSearchQuery({
|
||||
searchInput,
|
||||
showAdvanced,
|
||||
advancedFilters,
|
||||
bookLanguages,
|
||||
defaultLanguage,
|
||||
});
|
||||
onSearch(query);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id="search-section"
|
||||
className={`transition-all duration-500 ease-in-out ${
|
||||
isInitialState
|
||||
? 'search-initial-state mb-6'
|
||||
: 'mb-3 sm:mb-4'
|
||||
} ${showAdvanced ? 'search-advanced-visible' : ''}`}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<div className={`flex flex-col gap-3 search-wrapper transition-all duration-500 ${
|
||||
isInitialState ? '' : 'hidden'
|
||||
}`}>
|
||||
<SearchBar
|
||||
value={searchInput}
|
||||
onChange={onSearchInputChange}
|
||||
onSubmit={handleSearch}
|
||||
isLoading={isLoading}
|
||||
onAdvancedToggle={onAdvancedToggle}
|
||||
/>
|
||||
<AdvancedFilters
|
||||
visible={showAdvanced}
|
||||
bookLanguages={bookLanguages}
|
||||
defaultLanguage={defaultLanguage}
|
||||
supportedFormats={supportedFormats}
|
||||
filters={advancedFilters}
|
||||
onFiltersChange={onAdvancedFiltersChange}
|
||||
formClassName="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2"
|
||||
renderWrapper={form => form}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Toast } from '../types';
|
||||
|
||||
interface ToastContainerProps {
|
||||
toasts: Toast[];
|
||||
}
|
||||
|
||||
export const ToastContainer = ({ toasts }: ToastContainerProps) => {
|
||||
const [visibleToasts, setVisibleToasts] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
toasts.forEach(toast => {
|
||||
if (!visibleToasts.has(toast.id)) {
|
||||
setTimeout(() => {
|
||||
setVisibleToasts(prev => new Set([...prev, toast.id]));
|
||||
}, 10);
|
||||
}
|
||||
});
|
||||
}, [toasts]);
|
||||
|
||||
const toastTypeClasses: Record<Toast['type'], string> = {
|
||||
success: 'bg-green-600 text-white',
|
||||
error: 'bg-red-600 text-white',
|
||||
info: 'bg-blue-600 text-white',
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="toast-container" className="fixed bottom-4 right-4 z-50 space-y-2">
|
||||
{toasts.map(toast => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`toast-notification px-4 py-3 rounded-md shadow-lg text-sm font-medium transition-all duration-300 ${
|
||||
toastTypeClasses[toast.type]
|
||||
} ${visibleToasts.has(toast.id) ? 'toast-visible' : ''}`}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
export { Header } from './Header';
|
||||
export { SearchSection } from './SearchSection';
|
||||
export { SearchBar } from './SearchBar';
|
||||
export { ResultsSection } from './ResultsSection';
|
||||
export { DetailsModal } from './DetailsModal';
|
||||
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 will-change-transform"
|
||||
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 will-change-transform"
|
||||
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,215 @@
|
||||
import { useState } from 'react';
|
||||
import { Book, ButtonStateInfo } from '../../types';
|
||||
import { BookDownloadButton } from '../BookDownloadButton';
|
||||
|
||||
interface ListViewProps {
|
||||
books: Book[];
|
||||
onDetails: (id: string) => Promise<void>;
|
||||
onDownload: (book: Book) => Promise<void>;
|
||||
getButtonState: (bookId: string) => ButtonStateInfo;
|
||||
}
|
||||
|
||||
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-row w-full animate-slide-up will-change-transform"
|
||||
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-action 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>
|
||||
<BookDownloadButton
|
||||
buttonState={buttonState}
|
||||
onDownload={() => onDownload(book)}
|
||||
variant="icon"
|
||||
size="md"
|
||||
ariaLabel={buttonState.text}
|
||||
/>
|
||||
</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' },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// This data is loaded from the backend in production
|
||||
// For now, provide a default set
|
||||
export const DEFAULT_LANGUAGES = [
|
||||
{ code: 'en', language: 'English' },
|
||||
{ code: 'es', language: 'Spanish' },
|
||||
{ code: 'fr', language: 'French' },
|
||||
{ code: 'de', language: 'German' },
|
||||
{ code: 'it', language: 'Italian' },
|
||||
{ code: 'pt', language: 'Portuguese' },
|
||||
{ code: 'ru', language: 'Russian' },
|
||||
{ code: 'zh', language: 'Chinese' },
|
||||
{ code: 'ja', language: 'Japanese' },
|
||||
{ code: 'ko', language: 'Korean' },
|
||||
];
|
||||
|
||||
export const DEFAULT_SUPPORTED_FORMATS = ['epub', 'mobi', 'azw3', 'fb2', 'djvu', 'cbz', 'cbr'];
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { StatusData } from '../types';
|
||||
import { getStatus } from '../services/api';
|
||||
|
||||
interface UseRealtimeStatusOptions {
|
||||
wsUrl: string;
|
||||
pollInterval?: number;
|
||||
reconnectAttempts?: number;
|
||||
}
|
||||
|
||||
interface UseRealtimeStatusReturn {
|
||||
status: StatusData;
|
||||
connected: boolean;
|
||||
isUsingWebSocket: boolean;
|
||||
error: string | null;
|
||||
forceRefresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for real-time status updates with WebSocket and polling fallback
|
||||
*
|
||||
* This hook attempts to connect via WebSocket first. If WebSocket connection
|
||||
* fails or disconnects, it automatically falls back to polling. It will
|
||||
* periodically retry WebSocket connections.
|
||||
*/
|
||||
export const useRealtimeStatus = ({
|
||||
wsUrl,
|
||||
pollInterval = 5000,
|
||||
reconnectAttempts = 3,
|
||||
}: UseRealtimeStatusOptions): UseRealtimeStatusReturn => {
|
||||
const [status, setStatus] = useState<StatusData>({});
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [isUsingWebSocket, setIsUsingWebSocket] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isConnectingRef = useRef(false);
|
||||
|
||||
// Polling function
|
||||
const pollStatus = useCallback(async () => {
|
||||
try {
|
||||
const data = await getStatus();
|
||||
setStatus(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error polling status:', err);
|
||||
setError('Failed to fetch status');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Start polling
|
||||
const startPolling = useCallback(() => {
|
||||
if (pollIntervalRef.current) return;
|
||||
|
||||
console.log('Starting polling fallback');
|
||||
setIsUsingWebSocket(false);
|
||||
|
||||
// Poll immediately
|
||||
pollStatus();
|
||||
|
||||
// Then poll at intervals
|
||||
pollIntervalRef.current = setInterval(pollStatus, pollInterval);
|
||||
}, [pollStatus, pollInterval]);
|
||||
|
||||
// Stop polling
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
console.log('Stopped polling');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Attempt to reconnect WebSocket
|
||||
const attemptReconnect = useCallback(() => {
|
||||
if (reconnectAttemptsRef.current >= reconnectAttempts) {
|
||||
console.log('Max reconnect attempts reached, using polling permanently');
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectAttemptsRef.current += 1;
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
|
||||
|
||||
console.log(`Attempting WebSocket reconnect ${reconnectAttemptsRef.current}/${reconnectAttempts} in ${delay}ms`);
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
if (!isConnectingRef.current && !socketRef.current?.connected) {
|
||||
initializeWebSocket();
|
||||
}
|
||||
}, delay);
|
||||
}, [reconnectAttempts]);
|
||||
|
||||
// Initialize WebSocket connection
|
||||
const initializeWebSocket = useCallback(() => {
|
||||
if (isConnectingRef.current || socketRef.current?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
isConnectingRef.current = true;
|
||||
console.log('Initializing WebSocket connection to:', wsUrl);
|
||||
|
||||
try {
|
||||
const socket = io(wsUrl, {
|
||||
// Try websocket first, fall back to polling if needed
|
||||
transports: ['websocket', 'polling'],
|
||||
// Explicitly set the path to match backend
|
||||
path: '/socket.io',
|
||||
// Connection timeout
|
||||
timeout: 10000,
|
||||
// Reconnection settings
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
// Upgrade settings for reverse proxies
|
||||
upgrade: true,
|
||||
rememberUpgrade: true,
|
||||
// Force new connection instead of reusing
|
||||
forceNew: false,
|
||||
// Enable multiplexing
|
||||
multiplex: true,
|
||||
// Auto-connect
|
||||
autoConnect: true,
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('WebSocket connected successfully');
|
||||
setConnected(true);
|
||||
setIsUsingWebSocket(true);
|
||||
setError(null);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
isConnectingRef.current = false;
|
||||
|
||||
// Stop polling when WebSocket connects
|
||||
stopPolling();
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason: string) => {
|
||||
console.log('WebSocket disconnected. Reason:', reason);
|
||||
setConnected(false);
|
||||
setIsUsingWebSocket(false);
|
||||
isConnectingRef.current = false;
|
||||
|
||||
// Start polling as fallback
|
||||
startPolling();
|
||||
|
||||
// Attempt to reconnect WebSocket for most disconnect reasons
|
||||
// 'io server disconnect' = server initiated disconnect
|
||||
// 'transport close' = network error or server unreachable
|
||||
// 'transport error' = transport failed (like websocket failed to connect)
|
||||
if (reason !== 'io client disconnect') {
|
||||
console.log('Attempting to reconnect WebSocket after disconnect:', reason);
|
||||
attemptReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('connect_error', (err: Error) => {
|
||||
console.error('WebSocket connection error:', err.message);
|
||||
setError(`WebSocket error: ${err.message}`);
|
||||
setConnected(false);
|
||||
setIsUsingWebSocket(false);
|
||||
isConnectingRef.current = false;
|
||||
|
||||
// Start polling immediately on connection error
|
||||
startPolling();
|
||||
|
||||
// Attempt to reconnect WebSocket
|
||||
attemptReconnect();
|
||||
});
|
||||
|
||||
// Listen for status updates
|
||||
socket.on('status_update', (data: StatusData) => {
|
||||
setStatus(data);
|
||||
setError(null);
|
||||
});
|
||||
|
||||
// Listen for real-time progress updates
|
||||
socket.on('download_progress', (data: { book_id: string; progress: number; status: string }) => {
|
||||
setStatus(prev => {
|
||||
const newStatus = { ...prev };
|
||||
if (newStatus.downloading?.[data.book_id]) {
|
||||
newStatus.downloading[data.book_id] = {
|
||||
...newStatus.downloading[data.book_id],
|
||||
progress: data.progress,
|
||||
};
|
||||
}
|
||||
return newStatus;
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('error', (err: Error) => {
|
||||
console.error('WebSocket error:', err);
|
||||
setError('WebSocket error occurred');
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize WebSocket:', err);
|
||||
setError('Failed to initialize WebSocket');
|
||||
isConnectingRef.current = false;
|
||||
startPolling();
|
||||
}
|
||||
}, [wsUrl, stopPolling, startPolling, attemptReconnect]);
|
||||
|
||||
// Force refresh function
|
||||
const forceRefresh = useCallback(async () => {
|
||||
if (socketRef.current?.connected) {
|
||||
// Request update via WebSocket
|
||||
socketRef.current.emit('request_status');
|
||||
} else {
|
||||
// Poll immediately
|
||||
await pollStatus();
|
||||
}
|
||||
}, [pollStatus]);
|
||||
|
||||
// Initialize on mount
|
||||
useEffect(() => {
|
||||
// Try WebSocket first
|
||||
initializeWebSocket();
|
||||
|
||||
// If WebSocket doesn't connect within 3 seconds, start polling
|
||||
const fallbackTimeout = setTimeout(() => {
|
||||
if (!socketRef.current?.connected) {
|
||||
console.log('WebSocket connection timeout, starting polling');
|
||||
startPolling();
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
clearTimeout(fallbackTimeout);
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
|
||||
stopPolling();
|
||||
|
||||
if (socketRef.current) {
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [initializeWebSocket, startPolling, stopPolling]);
|
||||
|
||||
return {
|
||||
status,
|
||||
connected,
|
||||
isUsingWebSocket,
|
||||
error,
|
||||
forceRefresh,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Toast } from '../types';
|
||||
|
||||
export const useToast = () => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: 'info' | 'success' | 'error' = 'info') => {
|
||||
const id = Date.now().toString();
|
||||
setToasts(prev => [...prev, { id, message, type }]);
|
||||
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, 4000);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return { toasts, showToast, removeToast };
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
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');
|
||||
if (!root) throw new Error('Root element not found');
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Book, StatusData, AppConfig, LoginCredentials, AuthResponse } from '../types';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
// API endpoints
|
||||
const API = {
|
||||
search: `${API_BASE}/search`,
|
||||
info: `${API_BASE}/info`,
|
||||
download: `${API_BASE}/download`,
|
||||
status: `${API_BASE}/status`,
|
||||
cancelDownload: `${API_BASE}/download`,
|
||||
setPriority: `${API_BASE}/queue`,
|
||||
clearCompleted: `${API_BASE}/queue/clear`,
|
||||
config: `${API_BASE}/config`,
|
||||
login: `${API_BASE}/auth/login`,
|
||||
logout: `${API_BASE}/auth/logout`,
|
||||
authCheck: `${API_BASE}/auth/check`
|
||||
};
|
||||
|
||||
// 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,
|
||||
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();
|
||||
}
|
||||
|
||||
// API functions
|
||||
export const searchBooks = async (query: string): Promise<Book[]> => {
|
||||
if (!query) return [];
|
||||
return fetchJSON<Book[]>(`${API.search}?${query}`);
|
||||
};
|
||||
|
||||
export const getBookInfo = async (id: string): Promise<Book> => {
|
||||
return fetchJSON<Book>(`${API.info}?id=${encodeURIComponent(id)}`);
|
||||
};
|
||||
|
||||
export const downloadBook = async (id: string): Promise<void> => {
|
||||
await fetchJSON(`${API.download}?id=${encodeURIComponent(id)}`);
|
||||
};
|
||||
|
||||
export const getStatus = async (): Promise<StatusData> => {
|
||||
return fetchJSON<StatusData>(API.status);
|
||||
};
|
||||
|
||||
export const cancelDownload = async (id: string): Promise<void> => {
|
||||
await fetchJSON(`${API.cancelDownload}/${encodeURIComponent(id)}/cancel`, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
export const clearCompleted = async (): Promise<void> => {
|
||||
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);
|
||||
};
|
||||
@@ -0,0 +1,547 @@
|
||||
/* Base styles and CSS reset */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Disable transitions on initial load to prevent flash */
|
||||
html.preload *,
|
||||
html.preload *::before,
|
||||
html.preload *::after {
|
||||
transition: none !important;
|
||||
animation-duration: 0s !important;
|
||||
}
|
||||
|
||||
/* Smooth theme transitions for all elements using CSS variables */
|
||||
header, footer, section,
|
||||
input, select, textarea, button,
|
||||
.details-container, .modal-overlay {
|
||||
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Light theme variables */
|
||||
--primary-color: oklch(44.3% 0.11 240.79);
|
||||
--primary-dark: oklch(39.1% 0.09 240.876);
|
||||
--text-color: #333;
|
||||
--background-color: #f8f8f8;
|
||||
--border-color: #e5e5e5;
|
||||
--loading-overlay: rgba(0, 0, 0, 0.5);
|
||||
--card-background: #fff;
|
||||
--input-background: #fff;
|
||||
--heading-color: #333;
|
||||
|
||||
/* Modern UI alias tokens */
|
||||
--bg: var(--background-color);
|
||||
--text: var(--text-color);
|
||||
--border-muted: var(--border-color);
|
||||
--bg-soft: var(--card-background);
|
||||
--hover-surface: rgba(15, 23, 42, 0.05);
|
||||
--hover-action: rgba(15, 23, 42, 0.08);
|
||||
--hover-row: rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
/* Dark theme variables with improved contrast */
|
||||
--background-color: #121212;
|
||||
--text-color: #ffffff;
|
||||
--heading-color: #ffffff;
|
||||
--card-background: #1e1e1e;
|
||||
--border-color: #404040;
|
||||
--input-background: #2d2d2d;
|
||||
--hover-surface: rgba(255, 255, 255, 0.05);
|
||||
--hover-action: rgba(255, 255, 255, 0.12);
|
||||
--hover-row: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
html, body {
|
||||
min-height: 100vh;
|
||||
/* Support for iOS notch/dynamic island - extend to full viewport */
|
||||
min-height: 100dvh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
/* Transition on root element ensures smooth theme changes */
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
/* Extend background color to safe areas (status bar area) */
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
background: var(--background-color);
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Safe areas handled by individual components (Header, Footer, Sidebar) */
|
||||
/* Left/right safe areas still needed for notched devices in landscape */
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
transition: background-color 0.2s ease;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
min-height: 100%;
|
||||
background: var(--background-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: white;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
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-bar-spinner {
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.search-bar-icon {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.search-bar-button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Download button spinner styles */
|
||||
.download-spinner {
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
transition: opacity 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.details-spinner {
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
transition: opacity 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
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;
|
||||
}
|
||||
|
||||
.details-container {
|
||||
width: 100%;
|
||||
max-width: 64rem;
|
||||
max-height: 90vh;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.modal-overlay {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.details-container {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Toast Notification Styles */
|
||||
#toast-container {
|
||||
max-width: 400px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast-notification {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toast-notification.toast-visible {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Disabled button styles for queued/downloading states */
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.hover-surface,
|
||||
.hover-action,
|
||||
.hover-row {
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.hover-surface:hover {
|
||||
background-color: var(--hover-surface);
|
||||
}
|
||||
|
||||
.hover-action:hover {
|
||||
background-color: var(--hover-action);
|
||||
}
|
||||
|
||||
.hover-row:hover {
|
||||
background-color: var(--hover-row);
|
||||
}
|
||||
|
||||
/* Search wrapper and input - base styles */
|
||||
.search-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
min-width: 0; /* Allow input to shrink on mobile flex containers */
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.search-input::-webkit-search-cancel-button,
|
||||
.search-input::-webkit-search-decoration,
|
||||
.search-input::-webkit-search-results-button,
|
||||
.search-input::-webkit-search-results-decoration {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-input::-ms-clear {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
MOBILE: Full viewport width, no fixed widths
|
||||
============================================ */
|
||||
@media (max-width: 639px) {
|
||||
/* Break search section out to full viewport width, escaping the centered main container */
|
||||
#search-section {
|
||||
position: relative;
|
||||
left: 50%;
|
||||
right: 50%;
|
||||
margin-left: -50vw;
|
||||
margin-right: -50vw;
|
||||
width: 100vw;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Remove any width constraints on search wrapper */
|
||||
.search-wrapper {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
/* Ensure flex containers are full width */
|
||||
.search-wrapper > .flex,
|
||||
#search-filters {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DESKTOP: Fixed px widths for consistency
|
||||
============================================ */
|
||||
/* Medium screens (small tablets/large phones): 640px - 1023px */
|
||||
@media (min-width: 640px) and (max-width: 1023px) {
|
||||
.search-wrapper {
|
||||
width: 600px; /* Fixed width for medium screens */
|
||||
max-width: 600px; /* Prevent expansion */
|
||||
}
|
||||
}
|
||||
|
||||
/* Large screens (desktop): 1024px+ */
|
||||
@media (min-width: 1024px) {
|
||||
.search-wrapper {
|
||||
width: 800px; /* Fixed width for large screens */
|
||||
max-width: 800px; /* Prevent expansion */
|
||||
}
|
||||
}
|
||||
|
||||
/* Search section base styles - always center horizontally on desktop */
|
||||
#search-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center; /* Keep search box centered horizontally */
|
||||
}
|
||||
|
||||
/* Center search section vertically when in initial state (no results, no queue) */
|
||||
/* Responsive to different window sizes */
|
||||
.search-initial-state {
|
||||
flex: 1;
|
||||
justify-content: center; /* Only add vertical centering in initial state */
|
||||
}
|
||||
|
||||
/* Mobile-friendly book card layout */
|
||||
/* On mobile (below 640px), use horizontal layout: image left, text right, buttons below */
|
||||
/* Desktop remains unchanged - full width, full height artwork via Tailwind classes */
|
||||
@media (max-width: 639px) {
|
||||
.search-initial-state {
|
||||
justify-content: flex-start;
|
||||
padding-top: 18vh; /* push the search UI toward the upper third */
|
||||
}
|
||||
|
||||
/* When advanced filters are visible, pull the search bar to the very top */
|
||||
.search-initial-state.search-advanced-visible {
|
||||
padding-top: calc(env(safe-area-inset-top) + 1rem);
|
||||
}
|
||||
|
||||
.book-card-content {
|
||||
flex-direction: row !important;
|
||||
gap: 0.75rem !important;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.book-card-cover {
|
||||
width: 50% !important;
|
||||
height: auto !important;
|
||||
flex-shrink: 0;
|
||||
aspect-ratio: 2/3; /* Maintain book cover proportions */
|
||||
}
|
||||
|
||||
.book-card-text {
|
||||
width: 50% !important;
|
||||
flex-shrink: 0;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.book-card-buttons {
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop: align details and buttons to bottom, artwork stays at top */
|
||||
@media (min-width: 640px) {
|
||||
.book-card {
|
||||
/* Ensure card can grow to accommodate content */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%; /* Ensure card fills grid cell height */
|
||||
}
|
||||
|
||||
.book-card-content {
|
||||
/* Grow to fill available space, pushing buttons to bottom */
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0; /* Allow flex shrinking */
|
||||
}
|
||||
|
||||
.book-card-cover {
|
||||
/* Artwork stays at top, doesn't grow */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.book-card-text {
|
||||
/* Push text to bottom of content area, right above buttons */
|
||||
margin-top: auto;
|
||||
flex: 0 0 auto !important; /* Override flex-1 from HTML, don't grow or shrink */
|
||||
}
|
||||
}
|
||||
|
||||
/* Fix download button text clipping on mobile */
|
||||
.download-button-text {
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Ensure download buttons can accommodate their text content */
|
||||
[data-action="download"],
|
||||
#download-button {
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* On mobile, ensure button text is fully visible */
|
||||
@media (max-width: 639px) {
|
||||
/* Ensure button container allows overflow */
|
||||
.book-card-buttons {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Make text span flexible to use available space */
|
||||
.download-button-text {
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Ensure spinner doesn't interfere with text layout */
|
||||
.download-spinner {
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sticky Header with Gradient Fade Effect */
|
||||
.header-with-fade {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.header-with-fade::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 20px;
|
||||
/* Use background-color instead of gradient with CSS variables for smooth transitions */
|
||||
background: var(--bg);
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
/* Create fade effect using mask instead of gradient */
|
||||
mask-image: linear-gradient(to bottom, black 0%, black 20%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 20%, transparent 100%);
|
||||
transition: background-color 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* Skeleton Loader Animation */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Book data types
|
||||
export interface Book {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year?: string;
|
||||
language?: string;
|
||||
format?: string;
|
||||
size?: string;
|
||||
preview?: string;
|
||||
publisher?: string;
|
||||
info?: Record<string, string | string[]>;
|
||||
description?: string;
|
||||
download_path?: string;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
// Status response types
|
||||
export interface StatusData {
|
||||
queued?: Record<string, Book>;
|
||||
resolving?: Record<string, Book>;
|
||||
bypassing?: Record<string, Book>;
|
||||
downloading?: Record<string, Book>;
|
||||
verifying?: Record<string, Book>;
|
||||
ingesting?: Record<string, Book>;
|
||||
complete?: Record<string, Book>;
|
||||
available?: Record<string, Book>;
|
||||
done?: Record<string, Book>;
|
||||
completed?: Record<string, Book>;
|
||||
error?: Record<string, Book>;
|
||||
cancelled?: Record<string, Book>;
|
||||
}
|
||||
|
||||
export interface ActiveDownloadsResponse {
|
||||
active_downloads: Book[];
|
||||
}
|
||||
|
||||
// Button states
|
||||
export type ButtonState = 'download' | 'queued' | 'resolving' | 'bypassing' | 'downloading' | 'verifying' | 'ingesting' | 'completed' | 'error';
|
||||
|
||||
export interface ButtonStateInfo {
|
||||
text: string;
|
||||
state: ButtonState;
|
||||
progress?: number; // Download progress 0-100
|
||||
}
|
||||
|
||||
// Language option
|
||||
export interface Language {
|
||||
code: string;
|
||||
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;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
}
|
||||
|
||||
// App configuration
|
||||
export interface AppConfig {
|
||||
calibre_web_url: string;
|
||||
debug: boolean;
|
||||
app_env: string;
|
||||
build_version: string;
|
||||
release_version: string;
|
||||
book_languages: Language[];
|
||||
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,46 @@
|
||||
import { AdvancedFilterState, Language } from '../types';
|
||||
import { getLanguageFilterValues } from './languageFilters';
|
||||
|
||||
interface BuildSearchQueryOptions {
|
||||
searchInput: string;
|
||||
showAdvanced: boolean;
|
||||
advancedFilters: AdvancedFilterState;
|
||||
bookLanguages: Language[];
|
||||
defaultLanguage: string[];
|
||||
}
|
||||
|
||||
export const buildSearchQuery = ({
|
||||
searchInput,
|
||||
showAdvanced,
|
||||
advancedFilters,
|
||||
bookLanguages,
|
||||
defaultLanguage,
|
||||
}: BuildSearchQueryOptions): string => {
|
||||
const queryParts: string[] = [];
|
||||
|
||||
const basic = searchInput.trim();
|
||||
if (basic) {
|
||||
queryParts.push(`query=${encodeURIComponent(basic)}`);
|
||||
}
|
||||
|
||||
if (showAdvanced) {
|
||||
const { isbn, author, title, content, formats, lang } = advancedFilters;
|
||||
|
||||
if (isbn) queryParts.push(`isbn=${encodeURIComponent(isbn)}`);
|
||||
if (author) queryParts.push(`author=${encodeURIComponent(author)}`);
|
||||
if (title) queryParts.push(`title=${encodeURIComponent(title)}`);
|
||||
|
||||
const selectedLanguages = getLanguageFilterValues(lang, bookLanguages, defaultLanguage);
|
||||
selectedLanguages?.forEach(code => queryParts.push(`lang=${encodeURIComponent(code)}`));
|
||||
|
||||
if (content) queryParts.push(`content=${encodeURIComponent(content)}`);
|
||||
formats.forEach(format => queryParts.push(`format=${encodeURIComponent(format)}`));
|
||||
}
|
||||
|
||||
if (advancedFilters.sort) {
|
||||
queryParts.push(`sort=${encodeURIComponent(advancedFilters.sort)}`);
|
||||
}
|
||||
|
||||
return queryParts.join('&');
|
||||
};
|
||||
|
||||
@@ -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})`;
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: ['selector', '[data-theme="dark"]'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: '0.0.0.0',
|
||||
strictPort: true,
|
||||
cors: true,
|
||||
proxy: {
|
||||
// Proxy API requests to the Docker backend
|
||||
'/api': {
|
||||
target: 'http://localhost:8084',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
@@ -1,551 +0,0 @@
|
||||
/* Base styles and CSS reset */
|
||||
:root {
|
||||
/* Light theme variables */
|
||||
--primary-color: #0073e6;
|
||||
--primary-dark: #005bb5;
|
||||
--text-color: #333;
|
||||
--background-color: #f8f8f8;
|
||||
--border-color: #e5e5e5;
|
||||
--header-bg: #333;
|
||||
--header-text: #fff;
|
||||
--loading-overlay: rgba(0, 0, 0, 0.5);
|
||||
--card-background: #fff;
|
||||
--table-background: #fff;
|
||||
--table-hover-background: #f8f8f8;
|
||||
--table-border-color: #e5e5e5;
|
||||
--input-background: #fff;
|
||||
--heading-color: #333;
|
||||
|
||||
/* Modern UI alias tokens */
|
||||
--bg: var(--background-color);
|
||||
--text: var(--text-color);
|
||||
--border-muted: var(--border-color);
|
||||
--bg-soft: var(--card-background);
|
||||
--footer-bg: var(--header-bg);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
/* Dark theme variables with improved contrast */
|
||||
--background-color: #121212;
|
||||
--text-color: #ffffff;
|
||||
--heading-color: #ffffff;
|
||||
--card-background: #1e1e1e;
|
||||
--border-color: #404040;
|
||||
--table-background: #1e1e1e;
|
||||
--table-hover-background: #2d2d2d;
|
||||
--table-border-color: #404040;
|
||||
--input-background: #2d2d2d;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
html, body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
background: var(--background-color);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
min-height: 100%;
|
||||
background: var(--background-color);
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
header {
|
||||
background: var(--header-bg);
|
||||
color: var(--header-text);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background: var(--header-bg);
|
||||
color: var(--header-text);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* Search Section */
|
||||
.search-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.search-container input {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.search-container input:focus {
|
||||
border-color: var(--primary-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-filter {
|
||||
max-width: 210px;
|
||||
}
|
||||
|
||||
/* Table Styles */
|
||||
.table-responsive {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
text-align: left;
|
||||
justify-content:center;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f5f5f5;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.details-button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.3s ease;
|
||||
width: 100%;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.details-button:hover {
|
||||
background: #1f894b;
|
||||
}
|
||||
|
||||
/* Results Section */
|
||||
.results-section {
|
||||
margin-bottom: 2rem;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.results-heading {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.collapsed .toggle-icon {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.results-content {
|
||||
transition: max-height 0.3s ease;
|
||||
}
|
||||
|
||||
.collapsed .results-content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
th[data-sort="index"] {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
/* Loading Indicator */
|
||||
.loading-indicator {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--primary-color);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--loading-overlay);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.details-container {
|
||||
background: white;
|
||||
padding: 0.25rem 0.25rem 2rem 3rem;
|
||||
border-radius: 4px;
|
||||
max-width: 800px;
|
||||
width: 90%;
|
||||
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;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.search-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.details-header {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.details-header img {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.details-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Status indicators */
|
||||
.status-queued {
|
||||
color: #f39c12;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-downloading {
|
||||
color: #3498db;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-available {
|
||||
color: #27ae60;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: #e74c3c;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-done {
|
||||
color: black;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Status table specific styles */
|
||||
#status-table img {
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
#status-table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #e74c3c;
|
||||
text-align: center;
|
||||
padding: 1rem !important;
|
||||
}
|
||||
|
||||
/* Dark mode specific styles */
|
||||
[data-theme="dark"] .uk-card,
|
||||
[data-theme="dark"] .uk-modal-dialog {
|
||||
background-color: var(--card-background);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .uk-table {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .uk-table-hover tbody tr:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .uk-button-default {
|
||||
background-color: var(--card-background);
|
||||
color: var(--text-color);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .uk-search-input {
|
||||
background-color: var(--card-background);
|
||||
color: var(--text-color);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .uk-select {
|
||||
background-color: var(--card-background);
|
||||
color: var(--text-color);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Table styles */
|
||||
.uk-table {
|
||||
background-color: var(--table-background);
|
||||
color: var(--text-color);
|
||||
border-color: var(--table-border-color);
|
||||
}
|
||||
|
||||
.uk-table th {
|
||||
color: var(--text-color);
|
||||
background-color: var(--table-background);
|
||||
border-bottom-color: var(--table-border-color);
|
||||
}
|
||||
|
||||
.uk-table td {
|
||||
color: var(--text-color);
|
||||
background-color: var(--table-background);
|
||||
border-bottom-color: var(--table-border-color);
|
||||
}
|
||||
|
||||
.uk-table-hover tbody tr:hover,
|
||||
.uk-table-hover tbody tr:hover td {
|
||||
background-color: var(--table-hover-background);
|
||||
}
|
||||
|
||||
/* Input styles */
|
||||
.uk-search-input,
|
||||
.uk-input,
|
||||
.uk-select,
|
||||
.uk-textarea {
|
||||
background-color: var(--input-background);
|
||||
color: var(--text-color);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.uk-button-default {
|
||||
background-color: var(--card-background);
|
||||
color: var(--text-color);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.uk-button-default:hover {
|
||||
background-color: var(--table-hover-background);
|
||||
color: var(--text-color);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Dropdown styles */
|
||||
.uk-dropdown {
|
||||
background-color: var(--card-background);
|
||||
color: var(--text-color);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.uk-dropdown-nav > li > a {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.uk-dropdown-nav > li > a:hover {
|
||||
background-color: var(--table-hover-background);
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.modal-overlay {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.details-container {
|
||||
background-color: var(--card-background);
|
||||
color: var(--text-color);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Status colors - ensure they remain visible in dark mode */
|
||||
.status-downloading {
|
||||
color: #4CAF50 !important;
|
||||
}
|
||||
|
||||
.status-available {
|
||||
color: #2196F3 !important;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: #f44336 !important;
|
||||
}
|
||||
|
||||
/* Header specific button styles */
|
||||
header .uk-button-default {
|
||||
color: var(--header-text) !important;
|
||||
background-color: transparent;
|
||||
border-color: var(--header-text);
|
||||
}
|
||||
|
||||
header .uk-button-default:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-color: var(--header-text);
|
||||
color: var(--header-text) !important;
|
||||
}
|
||||
|
||||
/* Ensure dropdown text is visible when opened */
|
||||
header .uk-dropdown {
|
||||
background-color: var(--card-background);
|
||||
}
|
||||
|
||||
header .uk-dropdown-nav > li > a {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
header .uk-dropdown-nav > li > a:hover {
|
||||
color: var(--text-color);
|
||||
background-color: var(--table-hover-background);
|
||||
}
|
||||
|
||||
/* Update headings to use heading color */
|
||||
h1, h2, h3, h4, h5, h6,
|
||||
.uk-heading-small,
|
||||
.uk-heading-medium,
|
||||
.uk-heading-large,
|
||||
.uk-heading-xlarge,
|
||||
.uk-heading-2xlarge {
|
||||
color: var(--heading-color) !important;
|
||||
}
|
||||
|
||||
/* Ensure accordion titles are visible */
|
||||
.uk-accordion-title {
|
||||
color: var(--heading-color) !important;
|
||||
}
|
||||
|
||||
/* Ensure search results heading is visible */
|
||||
#results-section-accordion .uk-accordion-title h1 {
|
||||
color: var(--heading-color) !important;
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
// Modern UI script: search, cards, details, downloads, status, theme
|
||||
// Reuses existing API endpoints. Keeps logic minimal and accessible.
|
||||
|
||||
(function () {
|
||||
// ---- DOM ----
|
||||
const el = {
|
||||
searchInput: document.getElementById('search-input'),
|
||||
searchBtn: document.getElementById('search-button'),
|
||||
advToggle: document.getElementById('toggle-advanced'),
|
||||
filtersForm: document.getElementById('search-filters'),
|
||||
isbn: document.getElementById('isbn-input'),
|
||||
author: document.getElementById('author-input'),
|
||||
title: document.getElementById('title-input'),
|
||||
lang: document.getElementById('lang-input'),
|
||||
sort: document.getElementById('sort-input'),
|
||||
content: document.getElementById('content-input'),
|
||||
resultsGrid: document.getElementById('results-grid'),
|
||||
noResults: document.getElementById('no-results'),
|
||||
searchLoading: document.getElementById('search-loading'),
|
||||
modalOverlay: document.getElementById('modal-overlay'),
|
||||
detailsContainer: document.getElementById('details-container'),
|
||||
refreshStatusBtn: document.getElementById('refresh-status-button'),
|
||||
clearCompletedBtn: document.getElementById('clear-completed-button'),
|
||||
statusLoading: document.getElementById('status-loading'),
|
||||
statusList: document.getElementById('status-list'),
|
||||
activeDownloadsCount: document.getElementById('active-downloads-count'),
|
||||
// Active downloads (top section under search)
|
||||
activeTopSec: document.getElementById('active-downloads-top'),
|
||||
activeTopList: document.getElementById('active-downloads-list'),
|
||||
activeTopRefreshBtn: document.getElementById('active-refresh-button'),
|
||||
themeToggle: document.getElementById('theme-toggle'),
|
||||
themeText: document.getElementById('theme-text'),
|
||||
themeMenu: document.getElementById('theme-menu')
|
||||
};
|
||||
|
||||
// ---- Constants ----
|
||||
const API = {
|
||||
search: '/request/api/search',
|
||||
info: '/request/api/info',
|
||||
download: '/request/api/download',
|
||||
status: '/request/api/status',
|
||||
cancelDownload: '/request/api/download',
|
||||
setPriority: '/request/api/queue',
|
||||
clearCompleted: '/request/api/queue/clear',
|
||||
activeDownloads: '/request/api/downloads/active'
|
||||
};
|
||||
const FILTERS = ['isbn', 'author', 'title', 'lang', 'sort', 'content', 'format'];
|
||||
|
||||
// ---- Utils ----
|
||||
const utils = {
|
||||
show(node) { node && node.classList.remove('hidden'); },
|
||||
hide(node) { node && node.classList.add('hidden'); },
|
||||
async j(url, opts = {}) {
|
||||
const res = await fetch(url, opts);
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return res.json();
|
||||
},
|
||||
// Build query string from basic + advanced filters
|
||||
buildQuery() {
|
||||
const q = [];
|
||||
const basic = el.searchInput?.value?.trim();
|
||||
if (basic) q.push(`query=${encodeURIComponent(basic)}`);
|
||||
|
||||
if (!el.filtersForm || el.filtersForm.classList.contains('hidden')) {
|
||||
return q.join('&');
|
||||
}
|
||||
|
||||
FILTERS.forEach((name) => {
|
||||
if (name === 'format') {
|
||||
const checked = Array.from(document.querySelectorAll('[id^="format-"]:checked'));
|
||||
checked.forEach((cb) => q.push(`format=${encodeURIComponent(cb.value)}`));
|
||||
} else {
|
||||
const input = document.querySelectorAll(`[id^="${name}-input"]`);
|
||||
input.forEach((node) => {
|
||||
const val = node.value?.trim();
|
||||
if (val) q.push(`${name}=${encodeURIComponent(val)}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return q.join('&');
|
||||
},
|
||||
// Simple notification via alert fallback
|
||||
toast(msg) { try { console.info(msg); } catch (_) {} },
|
||||
// Escapes text for safe HTML injection
|
||||
e(text) { return (text ?? '').toString(); }
|
||||
};
|
||||
|
||||
// ---- Modal ----
|
||||
const modal = {
|
||||
open() { el.modalOverlay?.classList.add('active'); },
|
||||
close() { el.modalOverlay?.classList.remove('active'); el.detailsContainer.innerHTML = ''; }
|
||||
};
|
||||
|
||||
// ---- Cards ----
|
||||
function renderCard(book) {
|
||||
const cover = book.preview ? `<img src="${utils.e(book.preview)}" alt="Cover" class="w-full h-88 object-cover rounded">` :
|
||||
`<div class="w-full h-88 rounded flex items-center justify-center opacity-70" style="background: var(--bg-soft)">No Cover</div>`;
|
||||
|
||||
const html = `
|
||||
<article class="rounded border p-3 flex flex-col gap-3" style="border-color: var(--border-muted); background: var(--bg-soft)">
|
||||
${cover}
|
||||
<div class="flex-1 space-y-1">
|
||||
<h3 class="font-semibold leading-tight">${utils.e(book.title) || 'Untitled'}</h3>
|
||||
<p class="text-sm opacity-80">${utils.e(book.author) || 'Unknown author'}</p>
|
||||
<div class="text-xs opacity-70 flex flex-wrap gap-2">
|
||||
<span>${utils.e(book.year) || '-'}</span>
|
||||
<span>•</span>
|
||||
<span>${utils.e(book.language) || '-'}</span>
|
||||
<span>•</span>
|
||||
<span>${utils.e(book.format) || '-'}</span>
|
||||
${book.size ? `<span>•</span><span>${utils.e(book.size)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="px-3 py-2 rounded border text-sm flex-1" data-action="details" data-id="${utils.e(book.id)}" style="border-color: var(--border-muted);">Details</button>
|
||||
<button class="px-3 py-2 rounded bg-blue-600 hover:bg-blue-700 text-white text-sm flex-1" data-action="download" data-id="${utils.e(book.id)}">Download</button>
|
||||
</div>
|
||||
</article>`;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = html;
|
||||
// Bind actions
|
||||
const detailsBtn = wrapper.querySelector('[data-action="details"]');
|
||||
const downloadBtn = wrapper.querySelector('[data-action="download"]');
|
||||
detailsBtn?.addEventListener('click', () => bookDetails.show(book.id));
|
||||
downloadBtn?.addEventListener('click', () => bookDetails.download(book));
|
||||
return wrapper.firstElementChild;
|
||||
}
|
||||
|
||||
function renderCards(books) {
|
||||
el.resultsGrid.innerHTML = '';
|
||||
if (!books || books.length === 0) {
|
||||
utils.show(el.noResults);
|
||||
return;
|
||||
}
|
||||
utils.hide(el.noResults);
|
||||
const frag = document.createDocumentFragment();
|
||||
books.forEach((b) => frag.appendChild(renderCard(b)));
|
||||
el.resultsGrid.appendChild(frag);
|
||||
}
|
||||
|
||||
// ---- Search ----
|
||||
const search = {
|
||||
async run() {
|
||||
const qs = utils.buildQuery();
|
||||
if (!qs) { renderCards([]); return; }
|
||||
utils.show(el.searchLoading);
|
||||
try {
|
||||
const data = await utils.j(`${API.search}?${qs}`);
|
||||
renderCards(data);
|
||||
} catch (e) {
|
||||
renderCards([]);
|
||||
} finally {
|
||||
utils.hide(el.searchLoading);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Details ----
|
||||
const bookDetails = {
|
||||
async show(id) {
|
||||
try {
|
||||
modal.open();
|
||||
el.detailsContainer.innerHTML = '<div class="p-4">Loading…</div>';
|
||||
const book = await utils.j(`${API.info}?id=${encodeURIComponent(id)}`);
|
||||
el.detailsContainer.innerHTML = this.tpl(book);
|
||||
document.getElementById('close-details')?.addEventListener('click', modal.close);
|
||||
document.getElementById('download-button')?.addEventListener('click', () => this.download(book));
|
||||
} catch (e) {
|
||||
el.detailsContainer.innerHTML = '<div class="p-4">Failed to load details.</div>';
|
||||
}
|
||||
},
|
||||
tpl(book) {
|
||||
const cover = book.preview ? `<img src="${utils.e(book.preview)}" alt="Cover" class="w-full h-88 object-cover rounded">` : '';
|
||||
const infoList = book.info ? Object.entries(book.info).map(([k, v]) => `<li><strong>${utils.e(k)}:</strong> ${utils.e((v||[]).join
|
||||
? v.join(', ') : v)}</li>`).join('') : '';
|
||||
return `
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>${cover}</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-1">${utils.e(book.title) || 'Untitled'}</h3>
|
||||
<p class="text-sm opacity-80">${utils.e(book.author) || 'Unknown author'}</p>
|
||||
<div class="text-sm mt-2 space-y-1">
|
||||
<p><strong>Publisher:</strong> ${utils.e(book.publisher) || '-'}</p>
|
||||
<p><strong>Year:</strong> ${utils.e(book.year) || '-'}</p>
|
||||
<p><strong>Language:</strong> ${utils.e(book.language) || '-'}</p>
|
||||
<p><strong>Format:</strong> ${utils.e(book.format) || '-'}</p>
|
||||
<p><strong>Size:</strong> ${utils.e(book.size) || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${infoList ? `<div><h4 class="font-semibold mb-2">Further Information</h4><ul class="list-disc pl-6 space-y-1 text-sm">${infoList}</ul></div>` : ''}
|
||||
<div class="flex gap-2">
|
||||
<button id="download-button" class="px-3 py-2 rounded bg-blue-600 hover:bg-blue-700 text-white text-sm">Download</button>
|
||||
<button id="close-details" class="px-3 py-2 rounded border text-sm" style="border-color: var(--border-muted);">Close</button>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
async download(book) {
|
||||
if (!book) return;
|
||||
try {
|
||||
await utils.j(`${API.download}?id=${encodeURIComponent(book.id)}`);
|
||||
utils.toast('Queued for download');
|
||||
modal.close();
|
||||
status.fetch();
|
||||
} catch (_){}
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Status ----
|
||||
const status = {
|
||||
async fetch() {
|
||||
try {
|
||||
utils.show(el.statusLoading);
|
||||
const data = await utils.j(API.status);
|
||||
this.render(data);
|
||||
// Also reflect active downloads in the top section
|
||||
this.renderTop(data);
|
||||
this.updateActive();
|
||||
} catch (e) {
|
||||
el.statusList.innerHTML = '<div class="text-sm opacity-80">Error loading status.</div>';
|
||||
} finally { utils.hide(el.statusLoading); }
|
||||
},
|
||||
render(data) {
|
||||
// data shape: {queued: {...}, downloading: {...}, completed: {...}, error: {...}}
|
||||
const sections = [];
|
||||
for (const [name, items] of Object.entries(data || {})) {
|
||||
if (!items || Object.keys(items).length === 0) continue;
|
||||
const rows = Object.values(items).map((b) => {
|
||||
const titleText = utils.e(b.title) || '-';
|
||||
const maybeLinkedTitle = b.download_path
|
||||
? `<a href="/request/api/localdownload?id=${encodeURIComponent(b.id)}" class="text-blue-600 hover:underline">${titleText}</a>`
|
||||
: titleText;
|
||||
const actions = (name === 'queued' || name === 'downloading')
|
||||
? `<button class="px-2 py-1 rounded border text-xs" data-cancel="${utils.e(b.id)}" style="border-color: var(--border-muted);">Cancel</button>`
|
||||
: '';
|
||||
const progress = (name === 'downloading' && typeof b.progress === 'number')
|
||||
? `<div class="h-2 bg-black/10 rounded overflow-hidden"><div class="h-2 bg-blue-600" style="width:${Math.round(b.progress)}%"></div></div>`
|
||||
: '';
|
||||
return `<li class="p-3 rounded border flex flex-col gap-2" style="border-color: var(--border-muted); background: var(--bg-soft)">
|
||||
<div class="text-sm"><span class="opacity-70">${utils.e(name)}</span> • <strong>${maybeLinkedTitle}</strong></div>
|
||||
${progress}
|
||||
<div class="flex items-center gap-2">${actions}</div>
|
||||
</li>`;
|
||||
}).join('');
|
||||
sections.push(`
|
||||
<div>
|
||||
<h4 class="font-semibold mb-2">${name.charAt(0).toUpperCase() + name.slice(1)}</h4>
|
||||
<ul class="space-y-2">${rows}</ul>
|
||||
</div>`);
|
||||
}
|
||||
el.statusList.innerHTML = sections.join('') || '<div class="text-sm opacity-80">No items.</div>';
|
||||
// Bind cancel buttons
|
||||
el.statusList.querySelectorAll('[data-cancel]')?.forEach((btn) => {
|
||||
btn.addEventListener('click', () => queue.cancel(btn.getAttribute('data-cancel')));
|
||||
});
|
||||
},
|
||||
// Render compact active downloads list near the search bar
|
||||
renderTop(data) {
|
||||
try {
|
||||
const downloading = (data && data.downloading) ? Object.values(data.downloading) : [];
|
||||
if (!el.activeTopSec || !el.activeTopList) return;
|
||||
if (!downloading.length) {
|
||||
el.activeTopList.innerHTML = '';
|
||||
el.activeTopSec.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
// Build compact rows with title and progress bar + cancel
|
||||
const rows = downloading.map((b) => {
|
||||
const prog = (typeof b.progress === 'number')
|
||||
? `<div class="h-1.5 bg-black/10 rounded overflow-hidden"><div class="h-1.5 bg-blue-600" style="width:${Math.round(b.progress)}%"></div></div>`
|
||||
: '';
|
||||
const cancel = `<button class="px-2 py-0.5 rounded border text-xs" data-cancel="${utils.e(b.id)}" style="border-color: var(--border-muted);">Cancel</button>`;
|
||||
return `<div class="p-3 rounded border" style="border-color: var(--border-muted); background: var(--bg-soft)">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-sm truncate"><strong>${utils.e(b.title || '-') }</strong></div>
|
||||
<div class="shrink-0">${cancel}</div>
|
||||
</div>
|
||||
${prog}
|
||||
</div>`;
|
||||
}).join('');
|
||||
el.activeTopList.innerHTML = rows;
|
||||
el.activeTopSec.classList.remove('hidden');
|
||||
// Bind cancel handlers for the top section
|
||||
el.activeTopList.querySelectorAll('[data-cancel]')?.forEach((btn) => {
|
||||
btn.addEventListener('click', () => queue.cancel(btn.getAttribute('data-cancel')));
|
||||
});
|
||||
} catch (_) {}
|
||||
},
|
||||
async updateActive() {
|
||||
try {
|
||||
const d = await utils.j(API.activeDownloads);
|
||||
const n = Array.isArray(d.active_downloads) ? d.active_downloads.length : 0;
|
||||
if (el.activeDownloadsCount) el.activeDownloadsCount.textContent = `Active: ${n}`;
|
||||
} catch (_) {}
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Queue ----
|
||||
const queue = {
|
||||
async cancel(id) {
|
||||
try {
|
||||
await fetch(`${API.cancelDownload}/${encodeURIComponent(id)}/cancel`, { method: 'DELETE' });
|
||||
status.fetch();
|
||||
} catch (_){}
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Theme ----
|
||||
const theme = {
|
||||
KEY: 'preferred-theme',
|
||||
init() {
|
||||
const saved = localStorage.getItem(this.KEY) || 'auto';
|
||||
this.apply(saved);
|
||||
this.updateLabel(saved);
|
||||
// toggle dropdown
|
||||
el.themeToggle?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
if (!el.themeMenu) return;
|
||||
el.themeMenu.classList.toggle('hidden');
|
||||
});
|
||||
// outside click to close
|
||||
document.addEventListener('click', (ev) => {
|
||||
if (!el.themeMenu || !el.themeToggle) return;
|
||||
if (el.themeMenu.contains(ev.target) || el.themeToggle.contains(ev.target)) return;
|
||||
el.themeMenu.classList.add('hidden');
|
||||
});
|
||||
// selection
|
||||
el.themeMenu?.querySelectorAll('a[data-theme]')?.forEach((a) => {
|
||||
a.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
const pref = a.getAttribute('data-theme');
|
||||
localStorage.setItem(theme.KEY, pref);
|
||||
theme.apply(pref);
|
||||
theme.updateLabel(pref);
|
||||
el.themeMenu.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
// react to system change if auto
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mq.addEventListener('change', (e) => {
|
||||
if ((localStorage.getItem(theme.KEY) || 'auto') === 'auto') {
|
||||
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
},
|
||||
apply(pref) {
|
||||
if (pref === 'auto') {
|
||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', pref);
|
||||
}
|
||||
},
|
||||
updateLabel(pref) { if (el.themeText) el.themeText.textContent = `Theme (${pref})`; }
|
||||
};
|
||||
|
||||
// ---- Wire up ----
|
||||
function initEvents() {
|
||||
el.searchBtn?.addEventListener('click', () => search.run());
|
||||
el.searchInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') search.run(); });
|
||||
document.getElementById('adv-search-button')?.addEventListener('click', () => search.run());
|
||||
|
||||
if (el.advToggle && el.filtersForm) {
|
||||
el.advToggle.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
el.filtersForm.classList.toggle('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
el.refreshStatusBtn?.addEventListener('click', () => status.fetch());
|
||||
el.activeTopRefreshBtn?.addEventListener('click', () => status.fetch());
|
||||
el.clearCompletedBtn?.addEventListener('click', async () => {
|
||||
try { await fetch(API.clearCompleted, { method: 'DELETE' }); status.fetch(); } catch (_) {}
|
||||
});
|
||||
|
||||
// Close modal on overlay click
|
||||
el.modalOverlay?.addEventListener('click', (e) => { if (e.target === el.modalOverlay) modal.close(); });
|
||||
}
|
||||
|
||||
// ---- Init ----
|
||||
theme.init();
|
||||
initEvents();
|
||||
status.fetch();
|
||||
})();
|
||||
|
Before Width: | Height: | Size: 16 KiB |
@@ -1,241 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Calibre Web Book Downloader - Modern UI">
|
||||
<meta name="theme-color" content="#333333">
|
||||
<title>Book Downloader • Modern</title>
|
||||
|
||||
<!-- Base styles and theme variables -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='media/favicon.ico') }}">
|
||||
|
||||
<!-- Tailwind (no-build) for rapid iteration) -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="min-h-screen" style="background: var(--bg); color: var(--text);">
|
||||
<!-- Header -->
|
||||
<header class="w-full border-b border-[color:var(--border-muted)]" style="background: var(--header-bg);">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="{{ url_for('static', filename='media/logo.png') }}" alt="Logo" class="h-8 w-8">
|
||||
<h1 class="text-lg font-semibold">Book Search & Download</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{% if debug %}
|
||||
<form action="/request/api/restart" method="get" id="restart-form">
|
||||
<button class="px-3 py-1 rounded bg-red-600 text-white text-sm" id="restart-button" type="submit">
|
||||
RESTART
|
||||
</button>
|
||||
</form>
|
||||
<form action="/request/debug" method="get" id="debug-form">
|
||||
<button class="px-3 py-1 rounded bg-red-600/80 text-white text-sm" id="debug-button" type="submit">
|
||||
DEBUG
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<!-- Theme Dropdown -->
|
||||
<div class="relative">
|
||||
<button id="theme-toggle" class="px-3 py-1 rounded border text-sm" style="border-color: var(--border-muted);">
|
||||
<span id="theme-text">Theme</span>
|
||||
</button>
|
||||
<div id="theme-menu" class="absolute right-0 mt-2 w-36 rounded-md shadow-lg ring-1 ring-black/5 hidden" style="background: var(--bg-soft);">
|
||||
<ul class="py-1 text-sm">
|
||||
<li><a href="#" data-theme="light" class="block px-3 py-1 hover:bg-black/10">Light</a></li>
|
||||
<li><a href="#" data-theme="dark" class="block px-3 py-1 hover:bg-black/10">Dark</a></li>
|
||||
<li><a href="#" data-theme="auto" class="block px-3 py-1 hover:bg-black/10">Auto (System)</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<!-- Hero / Search -->
|
||||
<section class="mb-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex gap-2">
|
||||
<input id="search-input" type="search" placeholder="Search by ISBN, title, author..." aria-label="Search books"
|
||||
class="flex-1 px-4 py-3 rounded-md border outline-none"
|
||||
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
|
||||
<button id="search-button" class="px-4 py-3 rounded-md text-white bg-blue-600 hover:bg-blue-700">
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button id="toggle-advanced" class="text-sm underline opacity-80 hover:opacity-100">Advanced Search</button>
|
||||
</div>
|
||||
<!-- Advanced Filters -->
|
||||
<form id="search-filters" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 hidden">
|
||||
<div>
|
||||
<label for="isbn-input" class="block text-sm mb-1 opacity-80">ISBN</label>
|
||||
<input id="isbn-input" type="search" placeholder="ISBN"
|
||||
class="w-full px-3 py-2 rounded-md border"
|
||||
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
|
||||
</div>
|
||||
<div>
|
||||
<label for="author-input" class="block text-sm mb-1 opacity-80">Author</label>
|
||||
<input id="author-input" type="search" placeholder="Author"
|
||||
class="w-full px-3 py-2 rounded-md border"
|
||||
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
|
||||
</div>
|
||||
<div>
|
||||
<label for="title-input" class="block text-sm mb-1 opacity-80">Title</label>
|
||||
<input id="title-input" type="search" placeholder="Title"
|
||||
class="w-full px-3 py-2 rounded-md border"
|
||||
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
|
||||
</div>
|
||||
<div>
|
||||
<label for="lang-input" class="block text-sm mb-1 opacity-80">Language</label>
|
||||
<select id="lang-input" class="w-full px-3 py-2 rounded-md border"
|
||||
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
|
||||
<option value="all">All</option>
|
||||
{% for lang in book_languages %}
|
||||
<option value="{{ lang.code }}" {% if lang.code == default_language[0] %}selected{% endif %}>
|
||||
{{ lang.language }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="sort-input" class="block text-sm mb-1 opacity-80">Sort</label>
|
||||
<select id="sort-input" class="w-full px-3 py-2 rounded-md border"
|
||||
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
|
||||
<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 for="content-input" class="block text sm mb-1 opacity-80">Content</label>
|
||||
<select id="content-input" class="w-full px-3 py-2 rounded-md border"
|
||||
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
|
||||
<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>
|
||||
<div class="md:col-span-2 lg:col-span-3">
|
||||
<label class="block text-sm mb-1 opacity-80">Formats</label>
|
||||
<div class="flex flex-wrap gap-3 text-sm">
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'pdf' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-pdf" value="pdf" {% if 'pdf' not in supported_formats %}disabled{% endif %}>
|
||||
PDF
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'epub' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-epub" value="epub" {% if 'epub' in supported_formats %}checked{% endif %} {% if 'epub' not in supported_formats %}disabled{% endif %}>
|
||||
EPUB
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'mobi' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-mobi" value="mobi" {% if 'mobi' in supported_formats %}checked{% endif %} {% if 'mobi' not in supported_formats %}disabled{% endif %}>
|
||||
MOBI
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'azw3' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-azw3" value="azw3" {% if 'azw3' in supported_formats %}checked{% endif %} {% if 'azw3' not in supported_formats %}disabled{% endif %}>
|
||||
AZW3
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'fb2' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-fb2" value="fb2" {% if 'fb2' in supported_formats %}checked{% endif %} {% if 'fb2' not in supported_formats %}disabled{% endif %}>
|
||||
FB2
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'djvu' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-djvu" value="djvu" {% if 'djvu' in supported_formats %}checked{% endif %} {% if 'djvu' not in supported_formats %}disabled{% endif %}>
|
||||
DJVU
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'cbz' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-cbz" value="cbz" {% if 'cbz' in supported_formats %}checked{% endif %} {% if 'cbz' not in supported_formats %}disabled{% endif %}>
|
||||
CBZ
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'cbr' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-cbr" value="cbr" {% if 'cbr' in supported_formats %}checked{% endif %} {% if 'cbr' not in supported_formats %}disabled{% endif %}>
|
||||
CBR
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:col-span-2 lg:col-span-3 flex justify-end">
|
||||
<button id="adv-search-button" type="button" class="px-4 py-2 rounded-md border"
|
||||
style="border-color: var(--border-muted);">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Active Downloads (Top) -->
|
||||
<section id="active-downloads-top" class="mb-6 hidden">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-lg font-semibold">Active Downloads</h2>
|
||||
<button id="active-refresh-button" class="px-3 py-1 rounded border text-sm" style="border-color: var(--border-muted);">Refresh</button>
|
||||
</div>
|
||||
<div id="active-downloads-list" class="space-y-2"></div>
|
||||
</section>
|
||||
|
||||
<!-- Results -->
|
||||
<section class="mb-8">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-xl font-semibold">Search Results</h2>
|
||||
<div id="search-loading" class="text-sm opacity-80 hidden">Loading…</div>
|
||||
</div>
|
||||
<div id="results-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<!-- Cards will be injected here -->
|
||||
</div>
|
||||
<div id="no-results" class="mt-4 text-sm opacity-80 hidden">No results found.</div>
|
||||
</section>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal-overlay" id="modal-overlay" role="dialog" aria-modal="true">
|
||||
<div class="details-container" id="details-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<section>
|
||||
<div class="flex items-center flex-wrap mb-3">
|
||||
<h2 class="text-xl font-semibold mr-4 sm:mr-6">Download Queue & Status</h2>
|
||||
<div class="flex items-center gap-3 ml-4 sm:ml-auto">
|
||||
<button id="refresh-status-button" class="px-3 py-1 rounded border text-sm" style="border-color: var(--border-muted);">Refresh</button>
|
||||
<button id="clear-completed-button" class="px-3 py-1 rounded border text-sm" style="border-color: var(--border-muted);">Clear Completed</button>
|
||||
<span id="active-downloads-count" class="text-sm opacity-80">Active: 0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="status-loading" class="text-sm opacity-80 hidden">Loading…</div>
|
||||
<div id="status-list" class="space-y-2"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="mt-10 border-t pt-6 pb-10" style="border-color: var(--border-muted); background: var(--footer-bg);">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm opacity-80">Calibre Web Book Downloader</p>
|
||||
<p class="text-xs opacity-60 mt-1">
|
||||
Build: {{ build_version }} • Release: {{ release_version }} • Env: {{ app_env }}
|
||||
</p>
|
||||
</div>
|
||||
<a href="https://github.com/calibrain/calibre-web-automated-book-downloader" class="opacity-80 hover:opacity-100" aria-label="GitHub">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 005.47 7.59c.4.07.55-.17.55-.38
|
||||
0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52
|
||||
-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95
|
||||
0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.54 7.54 0 012 0c1.53-1.03 2.2-.82 2.2-.82
|
||||
.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2
|
||||
0 .21.15.46.55.38A8 8 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -13,6 +13,7 @@ port = SERVER_ENV.FLASK_PORT
|
||||
server_url = f"http://localhost:{port}"
|
||||
book_title = "077484a10743e5dd5d151013e8c732f4" # "Moby Dick"
|
||||
# Directory where downloads should appear
|
||||
download_paths = SERVER_ENV.DOWNLOAD_PATHS
|
||||
download_dir = SERVER_ENV.INGEST_DIR
|
||||
# Timeout for waiting for download
|
||||
download_timeout_seconds = 60 * 5
|
||||
@@ -38,7 +39,7 @@ def check_download_status(book_id):
|
||||
continue
|
||||
|
||||
# Check success conditions based on download_path
|
||||
for status_key in ["available", "done"]:
|
||||
for status_key in ["available", "done", "complete"]:
|
||||
if status_key in status_data and book_id in status_data[status_key]:
|
||||
book_status_info = status_data[status_key].get(book_id)
|
||||
# Check if the status info is a dictionary and has a non-empty download_path
|
||||
@@ -117,6 +118,12 @@ if SERVER_ENV.USE_BOOK_TITLE:
|
||||
else:
|
||||
expected_filename = f"{book_id}.epub"
|
||||
|
||||
if book_details.get("content"):
|
||||
content = book_details.get("content")
|
||||
for key, path in SERVER_ENV.DOWNLOAD_PATHS.items():
|
||||
if key in content:
|
||||
download_dir = path
|
||||
break
|
||||
expected_filepath = os.path.join(download_dir, expected_filename)
|
||||
|
||||
assert os.path.exists(expected_filepath), f"Expected downloaded file not found at: {expected_filepath}"
|
||||
@@ -124,7 +131,7 @@ print(f"Verified file exists: {expected_filepath}")
|
||||
|
||||
# Step 6 : Download the book
|
||||
print(f"Step 6: Downloading book {book_id}...")
|
||||
download_response = requests.get(f"{server_url}/request/api/localdownload?id={book_id}")
|
||||
download_response = requests.get(f"{server_url}/api/localdownload?id={book_id}")
|
||||
download_response.raise_for_status()
|
||||
# Write book to temp file :
|
||||
temp_file_path = os.path.join("/tmp", f"{book_id}.epub")
|
||||
|
||||
@@ -11,6 +11,36 @@ echo "Log file: $LOG_FILE"
|
||||
set +x
|
||||
set -e
|
||||
|
||||
#!/bin/bash
|
||||
|
||||
# Check if EXT_BYPASSER_URL is defined
|
||||
if [ -n "$EXT_BYPASSER_URL" ]; then
|
||||
echo "Extracting hostname and ip from bypasser into /etc/hosts"
|
||||
|
||||
# Extract hostname
|
||||
hostname=$(echo "$EXT_BYPASSER_URL" | cut -d'/' -f3 | cut -d':' -f1)
|
||||
|
||||
# Resolve to IP (using current DNS before switching to TOR)
|
||||
ip=$(getent hosts "$hostname" 2>/dev/null | awk '{print $1}')
|
||||
|
||||
# If getent fails, try dig
|
||||
if [ -z "$ip" ]; then
|
||||
ip=$(dig +short "$hostname" 2>/dev/null | head -n1)
|
||||
fi
|
||||
|
||||
# Only proceed if we got an IP and hostname is not already an IP
|
||||
if [ -n "$ip" ] && [ "$ip" != "$hostname" ]; then
|
||||
# Add to /etc/hosts (remove existing entry first to avoid duplicates)
|
||||
sudo sed -i "/[[:space:]]$hostname$/d" /etc/hosts
|
||||
echo "$ip $hostname" | sudo tee -a /etc/hosts > /dev/null
|
||||
echo "Added to /etc/hosts: $ip $hostname"
|
||||
else
|
||||
echo "Skipping: $hostname is already an IP or could not be resolved"
|
||||
fi
|
||||
else
|
||||
echo "EXT_BYPASSER_URL not defined, skipping /etc/hosts update"
|
||||
fi
|
||||
|
||||
echo "[*] Running tor script..."
|
||||
|
||||
echo "Build version: $BUILD_VERSION"
|
||||
@@ -71,7 +101,7 @@ echo "[✓] Transparent Tor routing enabled."
|
||||
sleep 5
|
||||
# Check if outgoing IP is using Tor
|
||||
echo "[*] Verifying Tor connectivity..."
|
||||
RESULT=$(pyrequests https://check.torproject.org/api/ip)
|
||||
RESULT=$(curl -s https://check.torproject.org/api/ip)
|
||||
echo "RESULT: $RESULT"
|
||||
IS_TOR=$(echo "$RESULT" | grep -oP '"IsTor":\s*\K(true|false)')
|
||||
IP=$(echo "$RESULT" | grep -oP '"IP":\s*"\K[^"]+')
|
||||
@@ -88,10 +118,10 @@ fi
|
||||
|
||||
# Get timezone from IP
|
||||
sleep 1
|
||||
TIMEZONE=$(pyrequests https://ipapi.co/timezone) || \
|
||||
TIMEZONE=$(pyrequests http://ip-api.com/line?fields=timezone) || \
|
||||
TIMEZONE=$(pyrequests http://worldtimeapi.org/api/ip | grep -oP '"timezone":"\K[^"]+') || \
|
||||
TIMEZONE=$(pyrequests https://ip2tz.isthe.link/v2 | grep -oP '"timezone": *"\K[^"]+') || \
|
||||
TIMEZONE=$(curl -s https://ipapi.co/timezone) || \
|
||||
TIMEZONE=$(curl -s http://ip-api.com/line?fields=timezone) || \
|
||||
TIMEZONE=$(curl -s http://worldtimeapi.org/api/ip | grep -oP '"timezone":"\K[^"]+') || \
|
||||
TIMEZONE=$(curl -s https://ip2tz.isthe.link/v2 | grep -oP '"timezone": *"\K[^"]+') || \
|
||||
true
|
||||
|
||||
# If TIMEZONE is not set, use the default timezone
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""WebSocket manager for real-time status updates."""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from flask_socketio import SocketIO, emit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class WebSocketManager:
|
||||
"""Manages WebSocket connections and broadcasts."""
|
||||
|
||||
def __init__(self):
|
||||
self.socketio: Optional[SocketIO] = None
|
||||
self._enabled = False
|
||||
|
||||
def init_app(self, app, socketio: SocketIO):
|
||||
"""Initialize the WebSocket manager with Flask-SocketIO instance."""
|
||||
self.socketio = socketio
|
||||
self._enabled = True
|
||||
logger.info("WebSocket manager initialized")
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Check if WebSocket is enabled and ready."""
|
||||
return self._enabled and self.socketio is not None
|
||||
|
||||
def broadcast_status_update(self, status_data: Dict[str, Any]):
|
||||
"""Broadcast status update to all connected clients."""
|
||||
if not self.is_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
# When calling socketio.emit() outside event handlers, it broadcasts by default
|
||||
self.socketio.emit('status_update', status_data)
|
||||
logger.debug(f"Broadcasted status update to all clients")
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting status update: {e}")
|
||||
|
||||
def broadcast_download_progress(self, book_id: str, progress: float, status: str):
|
||||
"""Broadcast download progress update for a specific book."""
|
||||
if not self.is_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
data = {
|
||||
'book_id': book_id,
|
||||
'progress': progress,
|
||||
'status': status
|
||||
}
|
||||
# When calling socketio.emit() outside event handlers, it broadcasts by default
|
||||
self.socketio.emit('download_progress', data)
|
||||
logger.debug(f"Broadcasted progress for book {book_id}: {progress}%")
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting download progress: {e}")
|
||||
|
||||
def broadcast_notification(self, message: str, notification_type: str = 'info'):
|
||||
"""Broadcast a notification message to all clients."""
|
||||
if not self.is_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
data = {
|
||||
'message': message,
|
||||
'type': notification_type
|
||||
}
|
||||
# When calling socketio.emit() outside event handlers, it broadcasts by default
|
||||
self.socketio.emit('notification', data)
|
||||
logger.debug(f"Broadcasted notification: {message}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting notification: {e}")
|
||||
|
||||
# Global WebSocket manager instance
|
||||
ws_manager = WebSocketManager()
|
||||