WebUI - Frontend Refactor (#302)

This PR was coauthored by alexhb1 and davidemarcoli. It builds on the FE
rework created by alex, but adds a myriad of additional tweaks and
optimizations to make the frontend feel modern, fast, and responsive.
The summary of the changes is as follows:

### Architecture Changes
React/TypeScript Migration: Refactored frontend from template/JS
structure to React/TypeScript application for better maintainability and
scalability
WebSocket Integration: Implemented real-time updates for download status
and progress with automatic fallback to polling
Gevent Worker: Configured production WebSocket support

### UI/UX Improvements
<img width="1502" height="890" alt="Screenshot 2025-11-10 at 10 02
59 AM"
src="https://github.com/user-attachments/assets/86bf8649-623f-413c-b8e5-656e687e55a8"
/>

Downloads Sidebar: Replaced bottom downloads section with sidebar
interface for better organization
<img width="201" height="450" alt="Screenshot 2025-11-10 at 10 07 52 AM"
src="https://github.com/user-attachments/assets/92b98e7c-c3bc-4b7e-80f1-252c3a760e33"
/>

Status Badges: Color-coded download status indicators instead of plain
text
Pinned Header: Fixed header position for consistent navigation
Enhanced Book Cards: Improved layout and hover states with info modal
button
<img width="1474" height="899" alt="Screenshot 2025-11-10 at 10 08
18 AM"
src="https://github.com/user-attachments/assets/9216d8a3-f662-434d-80e6-2a69b96abc31"
/>

Download Progress: Circular progress indicator on download buttons
Toast Notifications: Added user feedback for actions
Spinner Feedback: Loading indicators on search and download buttons
Animations: Smooth transitions and fluid progress updates

### Mobile & Responsive Design
Mobile-friendly Layouts: Optimized book cards and search interface for
mobile
<img width="225" height="450" alt="Screenshot 2025-11-10 at 10 05 49 AM"
src="https://github.com/user-attachments/assets/c8236c1c-5837-4309-9577-46db7292a54b"
/>

Keyboard Handling: Improved mobile keyboard behavior with proper input
types
PWA Improvements: Enhanced progressive web app functionality
Responsive Search: Better search box width and positioning across
devices

### Developer Experience
Development Mode: Separate frontend dev server that works with existing
backend container
Makefile: Added build automation and development commands
Documentation: Updated README with frontend architecture details

### Bug Fixes
Fixed "Clear completed" functionality
Fixed dark mode toggle text
Fixed sticky header behavior
Fixed mobile search box positioning
Removed active downloads requirement for initial state view

### Additional Features
ESC Key: Close downloads sidebar with ESC key
Calibre-Web Button: Direct link to Calibre-Web instance
<img width="282" height="83" alt="Screenshot 2025-11-11 at 9 38 05 AM"
src="https://github.com/user-attachments/assets/273075be-9743-4e13-9e48-5bf498f6c067"
/>
Granular Status Tracking: More detailed download progress information
obtained via websockets

---------

Co-authored-by: Alex <alex.bilbie1@gmail.com>
Co-authored-by: Zack Yancey <yanceyz@proton.me>
Co-authored-by: davidemarcoli <davide@marcoli.ch>
This commit is contained in:
Zack Yancey
2025-11-14 15:48:44 -05:00
committed by GitHub
co-authored by Alex Zack Yancey davidemarcoli
parent 8ea2fee0bb
commit 742da1c43a
55 changed files with 7473 additions and 1225 deletions
+10
View File
@@ -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/
+20
View File
@@ -1,3 +1,20 @@
# Frontend build stage
FROM node:20-alpine AS frontend-builder
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 +87,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.
+78
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 874 KiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 KiB

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 371 KiB

+146 -20
View File
@@ -4,7 +4,9 @@ import logging
import io, re, os
import sqlite3
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
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
@@ -13,10 +15,11 @@ 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 +27,48 @@ 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}'")
# 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"]
}
})
# Flask logger
app.logger.handlers = logger.handlers
app.logger.setLevel(logger.level)
@@ -91,35 +136,45 @@ def register_dual_routes(app : Flask) -> None:
def url_for_with_request(endpoint : str, **values : typing.Any) -> str:
"""Generate URLs with /request prefix by default."""
if endpoint == 'static':
if endpoint == 'static' or endpoint == 'serve_frontend_assets':
# 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.
"""
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 +322,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 +528,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}")
@@ -528,15 +610,59 @@ def authenticate() -> bool:
logger.info(f"Authentication successful for user {username}")
return True
# Catch-all route for React Router (must be last)
# This handles client-side routing by serving index.html for any unmatched routes
@app.route('/<path:path>')
@login_required
def catch_all(path: str) -> Response:
"""
Serve the React app for any route not matched by API endpoints.
This allows React Router to handle client-side routing.
"""
# 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')
# Register all routes with /request prefix
register_dual_routes(app)
# 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
)
+81 -7
View File
@@ -17,6 +17,13 @@ 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 +76,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 +90,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 +99,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()
}
@@ -152,7 +167,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 = 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
@@ -174,10 +190,21 @@ 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])
# 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())
intermediate_path = INGEST_DIR / f"{book_id}.crdownload"
final_path = INGEST_DIR / book_name
@@ -215,6 +242,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 +281,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 +327,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 +361,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."""
+18 -7
View File
@@ -331,15 +331,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) -> bool:
"""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
bool: True if successful, False otherwise
"""
if len(book_info.download_urls) == 0:
@@ -355,8 +358,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)
@@ -376,16 +387,16 @@ def download_book(book_info: BookInfo, book_path: Path, progress_callback: Optio
return False
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 ""
@@ -406,7 +417,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:
+7 -4
View File
@@ -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.
+5 -1
View File
@@ -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
+3
View File
@@ -64,4 +64,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()
+10 -4
View File
@@ -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"
@@ -116,7 +121,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 +189,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 +289,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 +324,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
+19 -3
View File
@@ -1,6 +1,6 @@
# 📚 Calibre-Web-Automated-Book-Downloader
![Calibre-Web Automated Book Downloader](static/media/logo.png 'Calibre-Web Automated Book Downloader')
![Calibre-Web Automated Book Downloader](src/frontend/public/logo.png '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.
@@ -243,9 +243,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
+5
View File
@@ -1,8 +1,13 @@
flask
flask-cors
flask-socketio
python-socketio
requests[socks]
beautifulsoup4
tqdm
dnspython
gunicorn
gevent
gevent-websocket
psutil
emoji
+99
View File
@@ -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 (`/request/api/*`)
- WebSocket connection (`ws://localhost:8084/request/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
+26
View File
@@ -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
+42
View File
@@ -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>
+3248
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"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",
"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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+387
View File
@@ -0,0 +1,387 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Book, StatusData, ButtonStateInfo, AppConfig } from './types';
import { searchBooks, getBookInfo, downloadBook, cancelDownload, clearCompleted, getConfig } from './services/api';
import { 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 { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages';
import './styles.css';
function App() {
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 [advancedFilters, setAdvancedFilters] = useState({
isbn: '',
author: '',
title: '',
lang: 'all',
sort: '',
content: '',
formats: [] as string[],
});
const { toasts, showToast } = useToast();
// 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>({});
// 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
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
}
};
loadConfig();
}, []);
// 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([]);
return;
}
setIsSearching(true);
try {
const results = await searchBooks(query);
setBooks(results);
} catch (error) {
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);
setAdvancedFilters({
isbn: '',
author: '',
title: '',
lang: 'all',
sort: '',
content: '',
formats: [],
});
};
// 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]);
return (
<>
<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}
onSearch={() => {
const q: string[] = [];
const basic = searchInput.trim();
if (basic) q.push(`query=${encodeURIComponent(basic)}`);
if (showAdvanced) {
if (advancedFilters.isbn) q.push(`isbn=${encodeURIComponent(advancedFilters.isbn)}`);
if (advancedFilters.author) q.push(`author=${encodeURIComponent(advancedFilters.author)}`);
if (advancedFilters.title) q.push(`title=${encodeURIComponent(advancedFilters.title)}`);
if (advancedFilters.lang && advancedFilters.lang !== 'all') q.push(`lang=${encodeURIComponent(advancedFilters.lang)}`);
if (advancedFilters.sort) q.push(`sort=${encodeURIComponent(advancedFilters.sort)}`);
if (advancedFilters.content) q.push(`content=${encodeURIComponent(advancedFilters.content)}`);
advancedFilters.formats.forEach(f => q.push(`format=${encodeURIComponent(f)}`));
}
handleSearch(q.join('&'));
}}
onAdvancedToggle={() => setShowAdvanced(!showAdvanced)}
isLoading={isSearching}
/>
<AdvancedFilters
visible={showAdvanced && !isInitialState}
bookLanguages={config?.book_languages || DEFAULT_LANGUAGES}
defaultLanguage={config?.default_language || 'en'}
supportedFormats={config?.supported_formats || DEFAULT_SUPPORTED_FORMATS}
onFiltersChange={setAdvancedFilters}
/>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<SearchSection
onSearch={handleSearch}
isLoading={isSearching}
isInitialState={isInitialState}
bookLanguages={config?.book_languages || DEFAULT_LANGUAGES}
defaultLanguage={config?.default_language || 'en'}
supportedFormats={config?.supported_formats || DEFAULT_SUPPORTED_FORMATS}
logoUrl="/logo.png"
searchInput={searchInput}
onSearchInputChange={setSearchInput}
showAdvanced={showAdvanced}
onAdvancedToggle={() => setShowAdvanced(!showAdvanced)}
/>
<ResultsSection
books={books}
visible={hasResults}
onDetails={handleShowDetails}
onDownload={handleDownload}
getButtonState={getButtonState}
/>
{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}
/>
</>
);
}
export default App;
@@ -0,0 +1,253 @@
import { useState } from 'react';
import { Language } from '../types';
interface AdvancedFiltersProps {
visible: boolean;
bookLanguages: Language[];
defaultLanguage: string;
supportedFormats: string[];
onFiltersChange: (filters: {
isbn: string;
author: string;
title: string;
lang: string;
sort: string;
content: string;
formats: string[];
}) => void;
}
export const AdvancedFilters = ({
visible,
bookLanguages,
defaultLanguage,
supportedFormats,
onFiltersChange,
}: AdvancedFiltersProps) => {
const [isbn, setIsbn] = useState('');
const [author, setAuthor] = useState('');
const [title, setTitle] = useState('');
const [lang, setLang] = useState(defaultLanguage || 'all');
const [sort, setSort] = useState('');
const [content, setContent] = useState('');
const [formats, setFormats] = useState<string[]>(
supportedFormats.filter(f => f !== 'pdf')
);
const notifyChange = (updates?: Partial<{
isbn: string;
author: string;
title: string;
lang: string;
sort: string;
content: string;
formats: string[];
}>) => {
onFiltersChange({
isbn,
author,
title,
lang,
sort,
content,
formats,
...updates,
});
};
const toggleFormat = (format: string) => {
const newFormats = formats.includes(format)
? formats.filter(f => f !== format)
: [...formats, format];
setFormats(newFormats);
notifyChange({ formats: newFormats });
};
if (!visible) return null;
return (
<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
id="search-filters"
className="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 => {
setIsbn(e.target.value);
notifyChange({ 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 => {
setAuthor(e.target.value);
notifyChange({ 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 => {
setTitle(e.target.value);
notifyChange({ title: e.target.value });
}}
/>
</div>
<div>
<label htmlFor="lang-input" className="block text-sm mb-1 opacity-80">
Language
</label>
<select
id="lang-input"
className="w-full px-3 py-2 rounded-md border"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={lang}
onChange={e => {
setLang(e.target.value);
notifyChange({ lang: e.target.value });
}}
>
<option value="all">All</option>
{bookLanguages.map(l => (
<option key={l.code} value={l.code}>
{l.language}
</option>
))}
</select>
</div>
<div>
<label htmlFor="sort-input" className="block text-sm mb-1 opacity-80">
Sort
</label>
<select
id="sort-input"
className="w-full px-3 py-2 rounded-md border"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={sort}
onChange={e => {
setSort(e.target.value);
notifyChange({ sort: e.target.value });
}}
>
<option value="">Most relevant</option>
<option value="newest">Newest (publication year)</option>
<option value="oldest">Oldest (publication year)</option>
<option value="largest">Largest (filesize)</option>
<option value="smallest">Smallest (filesize)</option>
<option value="newest_added">Newest (open sourced)</option>
<option value="oldest_added">Oldest (open sourced)</option>
</select>
</div>
<div>
<label htmlFor="content-input" className="block text-sm mb-1 opacity-80">
Content
</label>
<select
id="content-input"
className="w-full px-3 py-2 rounded-md border"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={content}
onChange={e => {
setContent(e.target.value);
notifyChange({ content: e.target.value });
}}
>
<option value="">All</option>
<option value="book_nonfiction">Book (non-fiction)</option>
<option value="book_fiction">Book (fiction)</option>
<option value="book_unknown">Book (unknown)</option>
<option value="magazine">Magazine</option>
<option value="book_comic">Comic Book</option>
<option value="standards_document">Standards document</option>
<option value="other">Other</option>
<option value="musical_score">Musical score</option>
<option value="audiobook">Audiobook</option>
</select>
</div>
<div className="md:col-span-2 lg:col-span-3">
<label className="block text-sm mb-1 opacity-80">Formats</label>
<div className="flex flex-wrap gap-3 text-sm">
{['pdf', 'epub', 'mobi', 'azw3', 'fb2', 'djvu', 'cbz', 'cbr'].map(format => {
const isSupported = supportedFormats.includes(format);
return (
<label
key={format}
className={`inline-flex items-center gap-2 ${
!isSupported ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
<input
type="checkbox"
value={format}
checked={formats.includes(format)}
onChange={() => toggleFormat(format)}
disabled={!isSupported}
/>
{format.toUpperCase()}
</label>
);
})}
</div>
</div>
</form>
</div>
</div>
);
};
+257
View File
@@ -0,0 +1,257 @@
import { useState, useEffect } from 'react';
import { Book, ButtonStateInfo } from '../types';
import { CircularProgress } from './CircularProgress';
const SkeletonLoader = () => (
<div className="w-full h-full bg-gradient-to-r from-gray-300 via-gray-200 to-gray-300 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
);
interface BookCardProps {
book: Book;
onDetails: (id: string) => Promise<void>;
onDownload: (book: Book) => Promise<void>;
buttonState: ButtonStateInfo;
}
export const BookCard = ({ book, onDetails, onDownload, buttonState }: BookCardProps) => {
const [isQueuing, setIsQueuing] = useState(false);
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const [isHovered, setIsHovered] = useState(false);
// Clear queuing state once button state changes from download
useEffect(() => {
if (isQueuing && buttonState.state !== 'download') {
setIsQueuing(false);
}
}, [buttonState.state, isQueuing]);
const isCompleted = buttonState.state === 'completed';
const hasError = buttonState.state === 'error';
const isInProgress = ['queued', 'resolving', 'bypassing', 'downloading', 'verifying', 'ingesting'].includes(buttonState.state);
const isDisabled = buttonState.state !== 'download' || isQueuing || isCompleted;
const displayText = isQueuing ? 'Queuing...' : buttonState.text;
// Show circular progress only for downloading state with progress data
const showCircularProgress = buttonState.state === 'downloading' && buttonState.progress !== undefined;
// Show spinner for other in-progress states or when queuing
const showSpinner = (isInProgress && !showCircularProgress) || isQueuing;
const handleDetails = async (id: string) => {
setIsLoadingDetails(true);
try {
await onDetails(id);
} finally {
setIsLoadingDetails(false);
}
};
const handleDownload = async () => {
setIsQueuing(true);
try {
await onDownload(book);
} catch (error) {
setIsQueuing(false);
}
};
return (
<article
className="book-card overflow-hidden flex flex-col sm:flex-col max-sm:flex-row space-between w-full sm:max-w-[292px] max-sm:h-[180px] h-full transition-shadow duration-300"
style={{
background: 'var(--bg-soft)',
borderRadius: '.75rem',
boxShadow: isHovered ? '0 10px 30px rgba(0, 0, 0, 0.15)' : 'none'
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Book Cover Image - 2:3 aspect ratio on desktop, fixed width on mobile */}
<div
className="relative w-full sm:w-full max-sm:w-[120px] max-sm:h-full max-sm:flex-shrink-0 group"
style={{ aspectRatio: '2/3' }}
>
{book.preview && !imageError ? (
<>
{!imageLoaded && (
<div className="absolute inset-0">
<SkeletonLoader />
</div>
)}
<img
src={book.preview}
alt={book.title || 'Book cover'}
className="w-full h-full"
style={{
opacity: imageLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
objectPosition: 'top'
}}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
</>
) : (
<div
className="w-full h-full flex items-center justify-center text-sm opacity-50"
style={{ background: 'var(--border-muted)' }}
>
No Cover
</div>
)}
{/* Hover overlay with 2% white opacity */}
<div
className="absolute inset-0 bg-white transition-opacity duration-300 pointer-events-none"
style={{ opacity: isHovered ? 0.02 : 0 }}
/>
{/* Info button - appears on hover, positioned bottom-right */}
<button
className="absolute bottom-2 right-2 w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110 max-sm:hidden"
style={{
opacity: (isHovered || isLoadingDetails) ? 1 : 0,
pointerEvents: (isHovered || isLoadingDetails) ? 'auto' : 'none'
}}
onClick={(e) => {
e.stopPropagation();
handleDetails(book.id);
}}
disabled={isLoadingDetails}
aria-label="Book details"
>
{isLoadingDetails ? (
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
)}
</button>
</div>
{/* Book Details Section */}
<div className="p-4 max-sm:p-3 max-sm:py-2 flex flex-col gap-3 max-sm:gap-2 max-sm:flex-1 max-sm:justify-between max-sm:min-w-0 sm:flex-1 sm:flex sm:flex-col sm:justify-end">
<div className="space-y-1 max-sm:space-y-0.5 max-sm:min-w-0">
<h3
className="font-semibold leading-tight line-clamp-2 text-base max-sm:line-clamp-3 max-sm:min-w-0"
title={book.title || 'Untitled'}
>
{book.title || 'Untitled'}
</h3>
<p className="text-sm max-sm:text-xs opacity-80 truncate max-sm:min-w-0">{book.author || 'Unknown author'}</p>
<div className="text-xs max-sm:text-[10px] opacity-70 flex flex-wrap gap-2 max-sm:gap-1">
<span>{book.year || '-'}</span>
<span></span>
<span>{book.language || '-'}</span>
<span></span>
<span>{book.format || '-'}</span>
{book.size && (
<>
<span></span>
<span>{book.size}</span>
</>
)}
</div>
</div>
{/* Mobile: Details and Download buttons side by side */}
<div className="flex gap-1.5 sm:hidden">
<button
className="px-2 py-1.5 rounded border text-xs flex-1 flex items-center justify-center gap-1"
onClick={() => handleDetails(book.id)}
style={{ borderColor: 'var(--border-muted)' }}
disabled={isLoadingDetails}
>
<span className="details-button-text">
{isLoadingDetails ? 'Loading' : 'Details'}
</span>
<div
className={`details-spinner w-3 h-3 border-2 border-current border-t-transparent rounded-full ${
isLoadingDetails ? '' : 'hidden'
}`}
/>
</button>
<button
className={`px-2 py-1.5 rounded text-white text-xs flex-1 flex items-center justify-center gap-1 ${
isCompleted
? 'bg-green-600 cursor-not-allowed'
: hasError
? 'bg-red-600 cursor-not-allowed opacity-75'
: isInProgress
? 'bg-gray-500 cursor-not-allowed opacity-75'
: 'bg-sky-700 hover:bg-sky-800'
}`}
onClick={handleDownload}
disabled={isDisabled || isInProgress}
data-action="download"
>
<span className="download-button-text">{displayText}</span>
{isCompleted && (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
{hasError && (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
{showCircularProgress && <CircularProgress progress={buttonState.progress} size={12} />}
{showSpinner && (
<div className="w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin" />
)}
</button>
</div>
</div>
{/* Desktop: Full-width Download button at bottom */}
<button
className={`hidden sm:flex w-full px-4 py-3 text-white text-sm items-center justify-center gap-2 ${
isCompleted
? 'bg-green-600 cursor-not-allowed'
: hasError
? 'bg-red-600 cursor-not-allowed opacity-75'
: isInProgress
? 'bg-gray-500 cursor-not-allowed opacity-75'
: 'bg-sky-700 hover:bg-sky-800'
}`}
onClick={handleDownload}
disabled={isDisabled || isInProgress}
data-action="download"
style={{
borderBottomLeftRadius: '.75rem',
borderBottomRightRadius: '.75rem'
}}
>
<span className="download-button-text">{displayText}</span>
{isCompleted && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
{hasError && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
{showCircularProgress && <CircularProgress progress={buttonState.progress} size={16} />}
{showSpinner && (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
)}
</button>
</article>
);
};
@@ -0,0 +1,43 @@
interface CircularProgressProps {
progress?: number;
size?: number;
}
/**
* Circular progress indicator component
* Displays a circular SVG progress ring that fills based on the progress percentage
*/
export const CircularProgress = ({ progress, size = 16 }: CircularProgressProps) => {
const radius = (size - 2) / 2;
const circumference = 2 * Math.PI * radius;
const progressValue = progress ?? 0;
const strokeDashoffset = circumference - (progressValue / 100) * circumference;
return (
<svg width={size} height={size} className="transform -rotate-90">
{/* Background circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="2"
opacity="0.3"
/>
{/* Progress circle */}
<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>
);
};
@@ -0,0 +1,161 @@
import { useState, useEffect } from 'react';
import { Book, ButtonStateInfo } from '../types';
import { CircularProgress } from './CircularProgress';
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]);
if (!book) return null;
const isCompleted = buttonState.state === 'completed';
const hasError = buttonState.state === 'error';
const isInProgress = ['queued', 'resolving', 'bypassing', 'downloading', 'verifying', 'ingesting'].includes(buttonState.state);
const isDisabled = buttonState.state !== 'download' || isQueuing || isCompleted;
const displayText = isQueuing ? 'Queuing...' : buttonState.text;
// Show circular progress only for downloading state with progress data
const showCircularProgress = buttonState.state === 'downloading' && buttonState.progress !== undefined;
// Show spinner for other in-progress states or when queuing
const showSpinner = (isInProgress && !showCircularProgress) || isQueuing;
const 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);
}
};
return (
<div
className="modal-overlay active"
onClick={e => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="details-container">
<div className="p-4 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
{book.preview && (
<img
src={book.preview}
alt="Cover"
className="w-full h-88 object-cover rounded"
/>
)}
</div>
<div>
<h3 className="text-lg font-semibold mb-1">{book.title || 'Untitled'}</h3>
<p className="text-sm opacity-80">{book.author || 'Unknown author'}</p>
<div className="text-sm mt-2 space-y-1">
<p>
<strong>Publisher:</strong> {book.publisher || '-'}
</p>
<p>
<strong>Year:</strong> {book.year || '-'}
</p>
<p>
<strong>Language:</strong> {book.language || '-'}
</p>
<p>
<strong>Format:</strong> {book.format || '-'}
</p>
<p>
<strong>Size:</strong> {book.size || '-'}
</p>
</div>
</div>
</div>
{book.info && Object.keys(book.info).length > 0 && (
<div>
<h4 className="font-semibold mb-2">Further Information</h4>
<ul className="list-disc pl-6 space-y-1 text-sm">
{Object.entries(book.info).map(([k, v]) => (
<li key={k}>
<strong>{k}:</strong>{' '}
{Array.isArray(v) ? v.join(', ') : v}
</li>
))}
</ul>
</div>
)}
<div className="flex gap-2">
<button
id="download-button"
data-id={book.id}
className={`px-3 py-2 rounded text-white text-sm flex items-center justify-center gap-2 ${
isCompleted
? 'bg-green-600 cursor-not-allowed'
: hasError
? 'bg-red-600 cursor-not-allowed opacity-75'
: isInProgress
? 'bg-gray-500 cursor-not-allowed opacity-75'
: 'bg-blue-600 hover:bg-blue-700'
}`}
onClick={handleDownload}
disabled={isDisabled || isInProgress}
>
<span className="download-button-text">{displayText}</span>
{isCompleted && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
{hasError && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
{showCircularProgress && <CircularProgress progress={buttonState.progress} size={16} />}
{showSpinner && (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
)}
</button>
<button
id="close-details"
className="px-3 py-2 rounded border text-sm"
style={{ borderColor: 'var(--border-muted)' }}
onClick={onClose}
>
Close
</button>
</div>
</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={`/request/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
onClick={() => onCancel(book.id)}
className="text-xs px-2 py-1 rounded border hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
style={{ borderColor: 'var(--border-muted)' }}
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 border-b"
style={{
borderColor: 'var(--border-muted)',
paddingTop: 'calc(1rem + env(safe-area-inset-top))'
}}
>
<h2 className="text-lg font-semibold">Downloads</h2>
<button
onClick={onClose}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 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
onClick={onClearCompleted}
className="flex-1 flex items-center justify-center px-3 py-2 rounded border text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
style={{ borderColor: 'var(--border-muted)' }}
>
Clear Completed
</button>
<button
onClick={onRefresh}
className="flex items-center justify-center px-3 py-2 rounded border text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
style={{ borderColor: 'var(--border-muted)' }}
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>
</>
);
};
+34
View File
@@ -0,0 +1,34 @@
interface FooterProps {
buildVersion?: string;
releaseVersion?: string;
appEnv?: string;
}
export const Footer = ({ buildVersion, releaseVersion, appEnv }: FooterProps) => {
return (
<footer className="mt-10 border-t pt-6 pb-10" style={{ borderColor: 'var(--border-muted)', paddingBottom: 'calc(2.5rem + env(safe-area-inset-bottom))' }}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-between">
<div>
<p className="text-sm opacity-80">Calibre Web Book Downloader</p>
<p className="text-xs opacity-60 mt-1">
Build: {buildVersion || 'dev'} Release: {releaseVersion || 'dev'} Env:{' '}
{appEnv || 'development'}
</p>
</div>
<a
href="https://github.com/calibrain/calibre-web-automated-book-downloader"
className="opacity-80 hover:opacity-100"
aria-label="GitHub"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
className="w-6 h-6"
>
<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>
</footer>
);
};
+323
View File
@@ -0,0 +1,323 @@
import { useState, useEffect } from 'react';
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;
}
export const Header = ({
calibreWebUrl,
debug,
logoUrl,
showSearch = false,
searchInput = '',
onSearchChange,
onSearch,
onAdvancedToggle,
isLoading = false,
onDownloadsClick,
statusCounts = { ongoing: 0, completed: 0, errored: 0 },
onLogoClick,
}: HeaderProps) => {
const [theme, setTheme] = useState<string>('auto');
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);
}, []);
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 handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && onSearch) {
onSearch();
(e.target as HTMLInputElement).blur();
}
};
// Icon buttons component - reused for both states
const IconButtons = () => (
<div className="flex items-center gap-2">
{/* Downloads Button */}
{onDownloadsClick && (
<button
onClick={onDownloadsClick}
className="relative p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label="View downloads"
title="Downloads"
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
{/* Show badge with appropriate color based on status */}
{(statusCounts.ongoing > 0 || statusCounts.completed > 0 || statusCounts.errored > 0) && (
<span
className={`absolute -top-1 -right-1 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center ${
statusCounts.errored > 0
? 'bg-red-500'
: statusCounts.ongoing > 0
? 'bg-blue-500'
: 'bg-green-500'
}`}
title={`${statusCounts.ongoing} ongoing, ${statusCounts.completed} completed, ${statusCounts.errored} failed`}
>
{statusCounts.ongoing + statusCounts.completed + statusCounts.errored}
</span>
)}
</button>
)}
{/* Theme Toggle Button */}
<button
onClick={cycleTheme}
className="relative p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label={`Current theme: ${theme}. Click to cycle`}
title={`Theme: ${theme.charAt(0).toUpperCase() + theme.slice(1)}`}
>
{theme === 'light' && (
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
)}
{theme === 'dark' && (
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
)}
{theme === 'auto' && (
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25" />
</svg>
)}
</button>
{/* Calibre-Web Button */}
{calibreWebUrl && (
<a
href={calibreWebUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label="Open Calibre-Web"
title="Go To Library"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
<span className="text-sm font-medium">Go To Library</span>
</a>
)}
{/* Debug Buttons */}
{debug && (
<>
<form action="/request/debug" method="get">
<button
className="p-2 rounded-full bg-red-600/80 hover:bg-red-600 text-white transition-colors"
type="submit"
title="Debug"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 12.75c1.148 0 2.278.08 3.383.237 1.037.146 1.866.966 1.866 2.013 0 3.728-2.35 6.75-5.25 6.75S6.75 18.728 6.75 15c0-1.046.83-1.867 1.866-2.013A24.204 24.204 0 0112 12.75zm0 0c2.883 0 5.647.508 8.207 1.44a23.91 23.91 0 01-1.152 6.06M12 12.75c-2.883 0-5.647.508-8.208 1.44.125 2.104.52 4.136 1.153 6.06M12 12.75a2.25 2.25 0 002.248-2.354M12 12.75a2.25 2.25 0 01-2.248-2.354M12 8.25c.995 0 1.971-.08 2.922-.236.403-.066.74-.358.795-.762a3.778 3.778 0 00-.399-2.25M12 8.25c-.995 0-1.97-.08-2.922-.236-.402-.066-.74-.358-.795-.762a3.734 3.734 0 01.4-2.253M12 8.25a2.25 2.25 0 00-2.248 2.146M12 8.25a2.25 2.25 0 012.248 2.146M8.683 5a6.032 6.032 0 01-1.155-1.002c.07-.63.27-1.222.574-1.747m.581 2.749A3.75 3.75 0 0115.318 5m0 0c.427-.283.815-.62 1.155-.999a4.471 4.471 0 00-.575-1.752M4.921 6a24.048 24.048 0 00-.392 3.314c1.668.546 3.416.914 5.223 1.082M19.08 6c.205 1.08.337 2.187.392 3.314a23.882 23.882 0 01-5.223 1.082" />
</svg>
</button>
</form>
<form action="/request/api/restart" method="get">
<button
className="p-2 rounded-full bg-red-600 hover:bg-red-700 text-white transition-colors"
type="submit"
title="Restart"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
</button>
</form>
</>
)}
</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"
/>
)}
<div className="relative flex-1 lg:flex-initial">
<input
type="search"
placeholder="Search by ISBN, title, author..."
aria-label="Search books"
autoComplete="off"
enterKeyHint="search"
className="w-full lg:w-[50vw] pl-4 pr-28 py-3 rounded-full border outline-none search-input"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={searchInput}
onChange={(e) => onSearchChange?.(e.target.value)}
onKeyDown={handleKeyDown}
/>
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-2">
<button
type="button"
onClick={onAdvancedToggle}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center justify-center transition-colors"
aria-label="Advanced Search"
title="Advanced Search"
>
<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={onSearch}
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"
aria-label="Search books"
title="Search"
disabled={isLoading}
>
{!isLoading && (
<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="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" />
)}
</button>
</div>
</div>
</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,53 @@
import { Book, ButtonStateInfo } from '../types';
import { BookCard } from './BookCard';
interface ResultsSectionProps {
books: Book[];
visible: boolean;
onDetails: (id: string) => Promise<void>;
onDownload: (book: Book) => Promise<void>;
getButtonState: (bookId: string) => ButtonStateInfo;
}
export const ResultsSection = ({
books,
visible,
onDetails,
onDownload,
getButtonState,
}: ResultsSectionProps) => {
if (!visible) return null;
return (
<section id="results-section" className="mb-8">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-semibold animate-fade-in-up">Search Results</h2>
</div>
<div
id="results-grid"
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8 items-stretch"
>
{books.map((book, index) => (
<div
key={book.id}
className="animate-slide-up"
style={{
animationDelay: `${index * 50}ms`,
animationFillMode: 'both',
}}
>
<BookCard
book={book}
onDetails={onDetails}
onDownload={onDownload}
buttonState={getButtonState(book.id)}
/>
</div>
))}
</div>
{books.length === 0 && (
<div className="mt-4 text-sm opacity-80">No results found.</div>
)}
</section>
);
};
@@ -0,0 +1,338 @@
import { useState } from 'react';
import { Language } from '../types';
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;
}
export const SearchSection = ({
onSearch,
isLoading,
isInitialState,
bookLanguages,
defaultLanguage,
supportedFormats,
logoUrl,
searchInput,
onSearchInputChange,
showAdvanced,
onAdvancedToggle,
}: SearchSectionProps) => {
const [isbn, setIsbn] = useState('');
const [author, setAuthor] = useState('');
const [title, setTitle] = useState('');
const [lang, setLang] = useState(defaultLanguage || 'all');
const [sort, setSort] = useState('');
const [content, setContent] = useState('');
const [formats, setFormats] = useState<string[]>(
supportedFormats.filter(f => f !== 'pdf')
);
const buildQuery = () => {
const q: string[] = [];
const basic = searchInput.trim();
if (basic) q.push(`query=${encodeURIComponent(basic)}`);
if (!showAdvanced) return q.join('&');
if (isbn) q.push(`isbn=${encodeURIComponent(isbn)}`);
if (author) q.push(`author=${encodeURIComponent(author)}`);
if (title) q.push(`title=${encodeURIComponent(title)}`);
if (lang && lang !== 'all') q.push(`lang=${encodeURIComponent(lang)}`);
if (sort) q.push(`sort=${encodeURIComponent(sort)}`);
if (content) q.push(`content=${encodeURIComponent(content)}`);
formats.forEach(f => q.push(`format=${encodeURIComponent(f)}`));
return q.join('&');
};
const handleSearch = () => {
const query = buildQuery();
onSearch(query);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
(e.target as HTMLInputElement).blur();
}
};
const toggleFormat = (format: string) => {
setFormats(prev =>
prev.includes(format) ? prev.filter(f => f !== format) : [...prev, format]
);
};
return (
<section
id="search-section"
className={`transition-all duration-500 ease-in-out ${
isInitialState
? 'search-initial-state mb-6'
: 'mb-0'
}`}
>
<div className={`flex items-center justify-center gap-3 mb-8 transition-all duration-300 ${
isInitialState ? 'opacity-100' : 'opacity-0 h-0 mb-0 overflow-hidden'
}`}>
<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'
}`}>
<div className="relative">
<input
type="search"
placeholder="Search by ISBN, title, author..."
aria-label="Search books"
autoComplete="off"
enterKeyHint="search"
className="w-full pl-4 pr-28 py-3 rounded-full border outline-none search-input"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={searchInput}
onChange={e => onSearchInputChange(e.target.value)}
onKeyDown={handleKeyDown}
/>
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-2">
<button
type="button"
onClick={onAdvancedToggle}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center justify-center transition-colors"
aria-label="Advanced Search"
title="Advanced Search"
>
<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={handleSearch}
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"
aria-label="Search books"
title="Search"
disabled={isLoading}
>
{!isLoading && (
<svg
id="search-icon"
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="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
id="search-spinner"
className="spinner w-3 h-3 border-2 border-white border-t-transparent"
/>
)}
</button>
</div>
</div>
{/* Advanced Filters */}
<form
id="search-filters"
className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 ${
showAdvanced ? '' : 'hidden'
}`}
>
<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 => setIsbn(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 => setAuthor(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 => setTitle(e.target.value)}
/>
</div>
<div>
<label htmlFor="lang-input" className="block text-sm mb-1 opacity-80">
Language
</label>
<select
id="lang-input"
className="w-full px-3 py-2 rounded-md border"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={lang}
onChange={e => setLang(e.target.value)}
>
<option value="all">All</option>
{bookLanguages.map(l => (
<option key={l.code} value={l.code}>
{l.language}
</option>
))}
</select>
</div>
<div>
<label htmlFor="sort-input" className="block text-sm mb-1 opacity-80">
Sort
</label>
<select
id="sort-input"
className="w-full px-3 py-2 rounded-md border"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={sort}
onChange={e => setSort(e.target.value)}
>
<option value="">Most relevant</option>
<option value="newest">Newest (publication year)</option>
<option value="oldest">Oldest (publication year)</option>
<option value="largest">Largest (filesize)</option>
<option value="smallest">Smallest (filesize)</option>
<option value="newest_added">Newest (open sourced)</option>
<option value="oldest_added">Oldest (open sourced)</option>
</select>
</div>
<div>
<label htmlFor="content-input" className="block text-sm mb-1 opacity-80">
Content
</label>
<select
id="content-input"
className="w-full px-3 py-2 rounded-md border"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={content}
onChange={e => setContent(e.target.value)}
>
<option value="">All</option>
<option value="book_nonfiction">Book (non-fiction)</option>
<option value="book_fiction">Book (fiction)</option>
<option value="book_unknown">Book (unknown)</option>
<option value="magazine">Magazine</option>
<option value="book_comic">Comic Book</option>
<option value="standards_document">Standards document</option>
<option value="other">Other</option>
<option value="musical_score">Musical score</option>
<option value="audiobook">Audiobook</option>
</select>
</div>
<div className="md:col-span-2 lg:col-span-3">
<label className="block text-sm mb-1 opacity-80">Formats</label>
<div className="flex flex-wrap gap-3 text-sm">
{['pdf', 'epub', 'mobi', 'azw3', 'fb2', 'djvu', 'cbz', 'cbr'].map(format => {
const isSupported = supportedFormats.includes(format);
return (
<label
key={format}
className={`inline-flex items-center gap-2 ${
!isSupported ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
<input
type="checkbox"
value={format}
checked={formats.includes(format)}
onChange={() => toggleFormat(format)}
disabled={!isSupported}
/>
{format.toUpperCase()}
</label>
);
})}
</div>
</div>
</form>
</div>
</section>
);
};
@@ -0,0 +1,202 @@
import { StatusData } from '../types';
interface StatusBadgeProps {
status: string;
}
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',
},
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',
},
};
const StatusBadge = ({ status }: StatusBadgeProps) => {
const style = STATUS_STYLES[status.toLowerCase()] || {
bg: 'bg-gray-500/10',
text: 'text-gray-600',
label: status.charAt(0).toUpperCase() + status.slice(1),
};
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${style.bg} ${style.text}`}
>
{style.label}
</span>
);
};
interface StatusSectionProps {
status: StatusData;
visible: boolean;
activeCount: number;
onRefresh: () => void;
onClearCompleted: () => void;
onCancel: (id: string) => void;
}
export const StatusSection = ({
status,
visible,
activeCount,
onRefresh,
onClearCompleted,
onCancel,
}: StatusSectionProps) => {
if (!visible) return null;
const renderSection = (name: string, items: Record<string, any>) => {
if (!items || Object.keys(items).length === 0) return null;
return (
<div key={name}>
<h4 className="font-semibold mb-2">
{name.charAt(0).toUpperCase() + name.slice(1)}
</h4>
<ul className="space-y-2">
{Object.values(items).map((book: any) => {
const maybeLinkedTitle = book.download_path ? (
<a
href={`/request/api/localdownload?id=${encodeURIComponent(book.id)}`}
className="text-blue-600 hover:underline"
>
{book.title || '-'}
</a>
) : (
book.title || '-'
);
const actions =
name === 'queued' || name === 'resolving' || name === 'bypassing' || name === 'downloading' || name === 'verifying' || name === 'ingesting' ? (
<button
className="px-2 py-1 rounded border text-xs"
style={{ borderColor: 'var(--border-muted)' }}
onClick={() => onCancel(book.id)}
>
Cancel
</button>
) : null;
const progress =
name === 'downloading' && typeof book.progress === 'number' ? (
<div className="h-2 bg-black/10 rounded overflow-hidden">
<div
className="h-2 bg-blue-600"
style={{ width: `${Math.round(book.progress)}%` }}
/>
</div>
) : null;
return (
<li
key={book.id}
className="p-3 rounded border flex flex-col gap-2"
style={{ borderColor: 'var(--border-muted)', background: 'var(--bg-soft)' }}
>
<div className="text-sm flex items-center gap-2">
<StatusBadge status={name} /> <strong>{maybeLinkedTitle}</strong>
</div>
{progress}
{actions && <div className="flex items-center gap-2">{actions}</div>}
</li>
);
})}
</ul>
</div>
);
};
const sections = ['queued', 'resolving', 'bypassing', 'downloading', 'verifying', 'ingesting', 'complete', 'completed', 'available', 'done', 'error', 'cancelled'];
const renderedSections = sections
.map(name => renderSection(name, (status as any)[name] || {}))
.filter(Boolean);
return (
<section id="status-section">
<div className="flex items-center flex-wrap mb-3">
<h2 className="text-xl font-semibold mr-4 sm:mr-6">Downloads</h2>
<div className="flex items-center gap-3 ml-4 sm:ml-auto">
<button
onClick={onRefresh}
className="px-3 py-1 rounded border text-sm"
style={{ borderColor: 'var(--border-muted)' }}
>
Refresh
</button>
<button
onClick={onClearCompleted}
className="px-3 py-1 rounded border text-sm"
style={{ borderColor: 'var(--border-muted)' }}
>
Clear Completed
</button>
<span className="text-sm opacity-80">Active: {activeCount}</span>
</div>
</div>
<div id="status-list" className="space-y-2">
{renderedSections.length > 0 ? (
renderedSections
) : (
<div className="text-sm opacity-80">No items.</div>
)}
</div>
</section>
);
};
@@ -0,0 +1,35 @@
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]);
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 ${
toast.type === 'success' ? 'bg-green-600 text-white' : 'bg-blue-600 text-white'
} ${visibleToasts.has(toast.id) ? 'toast-visible' : ''}`}
>
{toast.message}
</div>
))}
</div>
);
};
+9
View File
@@ -0,0 +1,9 @@
export { Header } from './Header';
export { SearchSection } from './SearchSection';
export { BookCard } from './BookCard';
export { ResultsSection } from './ResultsSection';
export { DetailsModal } from './DetailsModal';
export { StatusSection } from './StatusSection';
export { DownloadsSidebar } from './DownloadsSidebar';
export { ToastContainer } from './ToastContainer';
export { Footer } from './Footer';
+16
View File
@@ -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'];
+258
View File
@@ -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,
};
};
+21
View File
@@ -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 };
};
+12
View File
@@ -0,0 +1,12 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
const root = document.getElementById('root');
if (!root) throw new Error('Root element not found');
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>
);
+55
View File
@@ -0,0 +1,55 @@
import { Book, StatusData, AppConfig } from '../types';
const API_BASE = '/request/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`
};
// Utility function for JSON fetch
async function fetchJSON<T>(url: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
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 fetch(`${API.cancelDownload}/${encodeURIComponent(id)}/cancel`, { method: 'DELETE' });
};
export const clearCompleted = async (): Promise<void> => {
const response = await fetch(`${API_BASE}/queue/clear`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to clear completed');
};
export const getConfig = async (): Promise<AppConfig> => {
return fetchJSON<AppConfig>(API.config);
};
+559
View File
@@ -0,0 +1,559 @@
/* 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);
}
[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;
}
* {
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);
}
}
.animate-slide-up {
animation: slide-up 0.5s ease-out;
}
.animate-fade-in-up {
animation: fade-in-up 0.4s ease-out;
}
/* Button spinner styles */
#search-spinner {
border-radius: 50%;
animation: spin 0.8s linear infinite;
transition: opacity 0.2s ease;
}
#search-icon {
transition: opacity 0.2s ease;
}
#search-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;
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: var(--card-background);
color: var(--text-color);
padding: 1.5rem;
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) {
.details-header {
grid-template-columns: 1fr;
text-align: center;
}
.details-header img {
margin: 0 auto;
}
.details-actions {
flex-direction: column;
}
/* Minimal equal padding for details container on mobile */
.details-container {
padding: 1rem;
}
}
/* 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;
}
/* Search wrapper and input - base styles */
.search-wrapper {
width: 100%;
}
.search-input {
min-width: 0; /* Allow input to shrink on mobile flex containers */
}
/* ============================================
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) {
.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 sure download buttons can expand to fit their content */
[data-action="download"],
#download-button {
min-width: 0;
flex: 1 1 auto;
overflow: visible;
box-sizing: border-box;
}
/* Make text span flexible to use available space */
.download-button-text {
white-space: nowrap;
overflow: visible;
/* Take up all available space in the button, allow shrinking if needed */
flex: 1 1 0%;
min-width: 0;
display: block;
/* Center text within its available space */
text-align: center;
box-sizing: border-box;
/* Force layout recalculation by ensuring the element is treated as a flex item */
position: relative;
}
/* Ensure button's flex container properly distributes space on mobile */
[data-action="download"].flex,
#download-button.flex {
/* Ensure gap doesn't cause issues */
gap: 0.5rem;
}
/* Ensure spinner doesn't interfere with text layout */
.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;
}
+69
View File
@@ -0,0 +1,69 @@
// 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[]>;
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;
}
// 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[];
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
+25
View File
@@ -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" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+36
View File
@@ -0,0 +1,36 @@
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
'/request/api': {
target: 'http://localhost:8084',
changeOrigin: true,
secure: false,
},
// Also proxy direct API calls (without /request prefix)
'/api': {
target: 'http://localhost:8084',
changeOrigin: true,
secure: false,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
});
-551
View File
@@ -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;
}
-387
View File
@@ -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();
})();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

-241
View File
@@ -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>
+72
View File
@@ -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()