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>
@@ -37,3 +37,13 @@ dist/
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
|
||||
# Frontend build artifacts (built in separate stage)
|
||||
src/frontend/node_modules/
|
||||
src/frontend/dist/
|
||||
src/frontend/.vite/
|
||||
|
||||
# Old frontend code (replaced by src/frontend)
|
||||
templates/
|
||||
static/css/
|
||||
static/js/
|
||||
|
||||
@@ -1,3 +1,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.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
.PHONY: help install dev build preview typecheck clean up down docker-build refresh
|
||||
|
||||
# Frontend directory
|
||||
FRONTEND_DIR := src/frontend
|
||||
|
||||
# Docker compose file
|
||||
COMPOSE_FILE := docker-compose.dev.yml
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo ""
|
||||
@echo "Frontend:"
|
||||
@echo " install - Install frontend dependencies"
|
||||
@echo " dev - Start development server"
|
||||
@echo " build - Build frontend for production"
|
||||
@echo " preview - Preview production build"
|
||||
@echo " typecheck - Run TypeScript type checking"
|
||||
@echo " clean - Remove node_modules and build artifacts"
|
||||
@echo ""
|
||||
@echo "Backend (Docker):"
|
||||
@echo " up - Start backend services"
|
||||
@echo " down - Stop backend services"
|
||||
@echo " docker-build - Build Docker image"
|
||||
@echo " refresh - Rebuild and restart backend services"
|
||||
|
||||
# Install dependencies
|
||||
install:
|
||||
@echo "Installing frontend dependencies..."
|
||||
cd $(FRONTEND_DIR) && npm install
|
||||
|
||||
# Start development server
|
||||
dev:
|
||||
@echo "Starting development server..."
|
||||
cd $(FRONTEND_DIR) && npm run dev
|
||||
|
||||
# Build for production
|
||||
build:
|
||||
@echo "Building frontend for production..."
|
||||
cd $(FRONTEND_DIR) && npm run build
|
||||
|
||||
# Preview production build
|
||||
preview:
|
||||
@echo "Previewing production build..."
|
||||
cd $(FRONTEND_DIR) && npm run preview
|
||||
|
||||
# Type checking
|
||||
typecheck:
|
||||
@echo "Running TypeScript type checking..."
|
||||
cd $(FRONTEND_DIR) && npm run typecheck
|
||||
|
||||
# Clean build artifacts and dependencies
|
||||
clean:
|
||||
@echo "Cleaning build artifacts and dependencies..."
|
||||
rm -rf $(FRONTEND_DIR)/node_modules
|
||||
rm -rf $(FRONTEND_DIR)/dist
|
||||
|
||||
# Start backend services
|
||||
up:
|
||||
@echo "Starting backend services..."
|
||||
docker compose -f $(COMPOSE_FILE) up -d
|
||||
|
||||
# Stop backend services
|
||||
down:
|
||||
@echo "Stopping backend services..."
|
||||
docker compose -f $(COMPOSE_FILE) down
|
||||
|
||||
# Build Docker image
|
||||
docker-build:
|
||||
@echo "Building Docker image..."
|
||||
docker compose -f $(COMPOSE_FILE) build
|
||||
|
||||
# Rebuild and restart backend services
|
||||
refresh:
|
||||
@echo "Rebuilding and restarting backend services..."
|
||||
docker compose -f $(COMPOSE_FILE) down
|
||||
docker compose -f $(COMPOSE_FILE) build
|
||||
docker compose -f $(COMPOSE_FILE) up -d
|
||||
|
Before Width: | Height: | Size: 874 KiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 419 KiB After Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 371 KiB |
@@ -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
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -22,13 +22,14 @@ if USE_CF_BYPASS:
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def html_get_page(url: str, retry: int = MAX_RETRY, use_bypasser: bool = False) -> str:
|
||||
def html_get_page(url: str, retry: int = MAX_RETRY, use_bypasser: bool = False, status_callback: Optional[Callable[[str], None]] = None) -> str:
|
||||
"""Fetch HTML content from a URL with retry mechanism.
|
||||
|
||||
Args:
|
||||
url: Target URL
|
||||
retry: Number of retry attempts
|
||||
skip_404: Whether to skip 404 errors
|
||||
use_bypasser: Whether to use Cloudflare bypasser
|
||||
status_callback: Optional callback for status updates
|
||||
|
||||
Returns:
|
||||
str: HTML content if successful, None otherwise
|
||||
@@ -37,6 +38,8 @@ def html_get_page(url: str, retry: int = MAX_RETRY, use_bypasser: bool = False)
|
||||
try:
|
||||
logger.debug(f"html_get_page: {url}, retry: {retry}, use_bypasser: {use_bypasser}")
|
||||
if use_bypasser and USE_CF_BYPASS:
|
||||
if status_callback:
|
||||
status_callback("bypassing")
|
||||
logger.info(f"GET Using Cloudflare Bypasser for: {url}")
|
||||
return get_bypassed_page(url)
|
||||
else:
|
||||
@@ -61,14 +64,14 @@ def html_get_page(url: str, retry: int = MAX_RETRY, use_bypasser: bool = False)
|
||||
return ""
|
||||
elif response is not None and response.status_code == 403:
|
||||
logger.warning(f"403 detected for URL: {url}. Should retry using cloudflare bypass.")
|
||||
return html_get_page(url, retry - 1, True)
|
||||
return html_get_page(url, retry - 1, True, status_callback)
|
||||
|
||||
sleep_time = DEFAULT_SLEEP * (MAX_RETRY - retry + 1)
|
||||
logger.warning(
|
||||
f"Retrying GET {url} in {sleep_time} seconds due to error: {e}"
|
||||
)
|
||||
time.sleep(sleep_time)
|
||||
return html_get_page(url, retry - 1, use_bypasser)
|
||||
return html_get_page(url, retry - 1, use_bypasser, status_callback)
|
||||
|
||||
def download_url(link: str, size: str = "", progress_callback: Optional[Callable[[float], None]] = None, cancel_flag: Optional[Event] = None) -> Optional[BytesIO]:
|
||||
"""Download content from URL into a BytesIO buffer.
|
||||
|
||||
@@ -108,7 +108,11 @@ make_writable /cwa-book-ingest
|
||||
# Set the command to run based on the environment
|
||||
is_prod=$(echo "$APP_ENV" | tr '[:upper:]' '[:lower:]')
|
||||
if [ "$is_prod" = "prod" ]; then
|
||||
command="gunicorn -t 300 -b ${FLASK_HOST:-0.0.0.0}:${FLASK_PORT:-8084} app:app"
|
||||
# Use geventwebsocket worker for SocketIO + WebSocket compatibility
|
||||
# This special worker class handles WebSocket upgrades properly
|
||||
# --workers 1: SocketIO requires sticky sessions, use 1 worker or configure sticky sessions
|
||||
# -t 300: 300 second timeout for long-running requests
|
||||
command="gunicorn --worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker --workers 1 -t 300 -b ${FLASK_HOST:-0.0.0.0}:${FLASK_PORT:-8084} app:app"
|
||||
else
|
||||
command="python3 app.py"
|
||||
fi
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 📚 Calibre-Web-Automated-Book-Downloader
|
||||
|
||||

|
||||

|
||||
|
||||
An intuitive web interface for searching and requesting book downloads, designed to work seamlessly with [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated). This project streamlines the process of downloading books and preparing them for integration into your Calibre library.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
flask
|
||||
flask-cors
|
||||
flask-socketio
|
||||
python-socketio
|
||||
requests[socks]
|
||||
beautifulsoup4
|
||||
tqdm
|
||||
dnspython
|
||||
gunicorn
|
||||
gevent
|
||||
gevent-websocket
|
||||
psutil
|
||||
emoji
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Source Code Documentation
|
||||
|
||||
This directory contains the frontend application for Calibre-Web Automated Book Downloader.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/
|
||||
└── frontend/ # React + TypeScript frontend application
|
||||
├── public/ # Static assets (logo, favicon)
|
||||
├── src/ # Source code
|
||||
│ ├── components/ # React components
|
||||
│ ├── App.tsx # Main application component
|
||||
│ └── styles.css # Global styles
|
||||
├── package.json # Dependencies and scripts
|
||||
├── vite.config.ts # Vite configuration
|
||||
└── tsconfig.json # TypeScript configuration
|
||||
```
|
||||
|
||||
## Frontend Development
|
||||
|
||||
### Prerequisites
|
||||
- Node.js (v16 or higher)
|
||||
- npm or yarn
|
||||
|
||||
### Quick Start
|
||||
|
||||
From the project root:
|
||||
```bash
|
||||
# Install dependencies
|
||||
make install
|
||||
|
||||
# Start development server (http://localhost:5173)
|
||||
make dev
|
||||
|
||||
# Build for production
|
||||
make build
|
||||
|
||||
# Preview production build
|
||||
make preview
|
||||
|
||||
# Run type checking
|
||||
make typecheck
|
||||
```
|
||||
|
||||
Alternatively, from `src/frontend`:
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Technology Stack
|
||||
- **Framework**: React 18 with TypeScript
|
||||
- **Build Tool**: Vite 5
|
||||
- **Styling**: TailwindCSS 3
|
||||
- **Communication**: WebSocket for real-time updates
|
||||
|
||||
### Key Features
|
||||
- **Search Interface**: Real-time book search with filtering
|
||||
- **Download Queue**: Live status updates via WebSocket
|
||||
- **Details Modal**: Rich book information display
|
||||
- **Responsive Design**: Mobile-first approach
|
||||
|
||||
## Development Tips
|
||||
|
||||
### Hot Module Replacement (HMR)
|
||||
The development server supports HMR for instant feedback during development.
|
||||
|
||||
### API Integration
|
||||
The frontend communicates with the Flask backend via:
|
||||
- REST API endpoints (`/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
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# Production
|
||||
/dist
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# IDE
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.production.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="description" content="Calibre Web Book Downloader - Modern UI" />
|
||||
|
||||
<!-- Theme color with media queries for light/dark mode -->
|
||||
<meta name="theme-color" content="#f8f8f8" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#121212" media="(prefers-color-scheme: dark)" />
|
||||
|
||||
<!-- iOS PWA Meta Tags -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Book Downloader" />
|
||||
|
||||
<!-- App Icons -->
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<title>Book Downloader</title>
|
||||
<script>
|
||||
// Apply theme immediately before first paint to prevent flash
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('preferred-theme') || 'auto';
|
||||
let theme = savedTheme;
|
||||
|
||||
if (savedTheme === 'auto') {
|
||||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
|
||||
// Add class to prevent transitions on initial load
|
||||
document.documentElement.classList.add('preload');
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body style="background: var(--bg); color: var(--text);">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -0,0 +1,16 @@
|
||||
// This data is loaded from the backend in production
|
||||
// For now, provide a default set
|
||||
export const DEFAULT_LANGUAGES = [
|
||||
{ code: 'en', language: 'English' },
|
||||
{ code: 'es', language: 'Spanish' },
|
||||
{ code: 'fr', language: 'French' },
|
||||
{ code: 'de', language: 'German' },
|
||||
{ code: 'it', language: 'Italian' },
|
||||
{ code: 'pt', language: 'Portuguese' },
|
||||
{ code: 'ru', language: 'Russian' },
|
||||
{ code: 'zh', language: 'Chinese' },
|
||||
{ code: 'ja', language: 'Japanese' },
|
||||
{ code: 'ko', language: 'Korean' },
|
||||
];
|
||||
|
||||
export const DEFAULT_SUPPORTED_FORMATS = ['epub', 'mobi', 'azw3', 'fb2', 'djvu', 'cbz', 'cbr'];
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { StatusData } from '../types';
|
||||
import { getStatus } from '../services/api';
|
||||
|
||||
interface UseRealtimeStatusOptions {
|
||||
wsUrl: string;
|
||||
pollInterval?: number;
|
||||
reconnectAttempts?: number;
|
||||
}
|
||||
|
||||
interface UseRealtimeStatusReturn {
|
||||
status: StatusData;
|
||||
connected: boolean;
|
||||
isUsingWebSocket: boolean;
|
||||
error: string | null;
|
||||
forceRefresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for real-time status updates with WebSocket and polling fallback
|
||||
*
|
||||
* This hook attempts to connect via WebSocket first. If WebSocket connection
|
||||
* fails or disconnects, it automatically falls back to polling. It will
|
||||
* periodically retry WebSocket connections.
|
||||
*/
|
||||
export const useRealtimeStatus = ({
|
||||
wsUrl,
|
||||
pollInterval = 5000,
|
||||
reconnectAttempts = 3,
|
||||
}: UseRealtimeStatusOptions): UseRealtimeStatusReturn => {
|
||||
const [status, setStatus] = useState<StatusData>({});
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [isUsingWebSocket, setIsUsingWebSocket] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isConnectingRef = useRef(false);
|
||||
|
||||
// Polling function
|
||||
const pollStatus = useCallback(async () => {
|
||||
try {
|
||||
const data = await getStatus();
|
||||
setStatus(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error polling status:', err);
|
||||
setError('Failed to fetch status');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Start polling
|
||||
const startPolling = useCallback(() => {
|
||||
if (pollIntervalRef.current) return;
|
||||
|
||||
console.log('Starting polling fallback');
|
||||
setIsUsingWebSocket(false);
|
||||
|
||||
// Poll immediately
|
||||
pollStatus();
|
||||
|
||||
// Then poll at intervals
|
||||
pollIntervalRef.current = setInterval(pollStatus, pollInterval);
|
||||
}, [pollStatus, pollInterval]);
|
||||
|
||||
// Stop polling
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
console.log('Stopped polling');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Attempt to reconnect WebSocket
|
||||
const attemptReconnect = useCallback(() => {
|
||||
if (reconnectAttemptsRef.current >= reconnectAttempts) {
|
||||
console.log('Max reconnect attempts reached, using polling permanently');
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectAttemptsRef.current += 1;
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
|
||||
|
||||
console.log(`Attempting WebSocket reconnect ${reconnectAttemptsRef.current}/${reconnectAttempts} in ${delay}ms`);
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
if (!isConnectingRef.current && !socketRef.current?.connected) {
|
||||
initializeWebSocket();
|
||||
}
|
||||
}, delay);
|
||||
}, [reconnectAttempts]);
|
||||
|
||||
// Initialize WebSocket connection
|
||||
const initializeWebSocket = useCallback(() => {
|
||||
if (isConnectingRef.current || socketRef.current?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
isConnectingRef.current = true;
|
||||
console.log('Initializing WebSocket connection to:', wsUrl);
|
||||
|
||||
try {
|
||||
const socket = io(wsUrl, {
|
||||
// Try websocket first, fall back to polling if needed
|
||||
transports: ['websocket', 'polling'],
|
||||
// Explicitly set the path to match backend
|
||||
path: '/socket.io',
|
||||
// Connection timeout
|
||||
timeout: 10000,
|
||||
// Reconnection settings
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
// Upgrade settings for reverse proxies
|
||||
upgrade: true,
|
||||
rememberUpgrade: true,
|
||||
// Force new connection instead of reusing
|
||||
forceNew: false,
|
||||
// Enable multiplexing
|
||||
multiplex: true,
|
||||
// Auto-connect
|
||||
autoConnect: true,
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('WebSocket connected successfully');
|
||||
setConnected(true);
|
||||
setIsUsingWebSocket(true);
|
||||
setError(null);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
isConnectingRef.current = false;
|
||||
|
||||
// Stop polling when WebSocket connects
|
||||
stopPolling();
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason: string) => {
|
||||
console.log('WebSocket disconnected. Reason:', reason);
|
||||
setConnected(false);
|
||||
setIsUsingWebSocket(false);
|
||||
isConnectingRef.current = false;
|
||||
|
||||
// Start polling as fallback
|
||||
startPolling();
|
||||
|
||||
// Attempt to reconnect WebSocket for most disconnect reasons
|
||||
// 'io server disconnect' = server initiated disconnect
|
||||
// 'transport close' = network error or server unreachable
|
||||
// 'transport error' = transport failed (like websocket failed to connect)
|
||||
if (reason !== 'io client disconnect') {
|
||||
console.log('Attempting to reconnect WebSocket after disconnect:', reason);
|
||||
attemptReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('connect_error', (err: Error) => {
|
||||
console.error('WebSocket connection error:', err.message);
|
||||
setError(`WebSocket error: ${err.message}`);
|
||||
setConnected(false);
|
||||
setIsUsingWebSocket(false);
|
||||
isConnectingRef.current = false;
|
||||
|
||||
// Start polling immediately on connection error
|
||||
startPolling();
|
||||
|
||||
// Attempt to reconnect WebSocket
|
||||
attemptReconnect();
|
||||
});
|
||||
|
||||
// Listen for status updates
|
||||
socket.on('status_update', (data: StatusData) => {
|
||||
setStatus(data);
|
||||
setError(null);
|
||||
});
|
||||
|
||||
// Listen for real-time progress updates
|
||||
socket.on('download_progress', (data: { book_id: string; progress: number; status: string }) => {
|
||||
setStatus(prev => {
|
||||
const newStatus = { ...prev };
|
||||
if (newStatus.downloading?.[data.book_id]) {
|
||||
newStatus.downloading[data.book_id] = {
|
||||
...newStatus.downloading[data.book_id],
|
||||
progress: data.progress,
|
||||
};
|
||||
}
|
||||
return newStatus;
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('error', (err: Error) => {
|
||||
console.error('WebSocket error:', err);
|
||||
setError('WebSocket error occurred');
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize WebSocket:', err);
|
||||
setError('Failed to initialize WebSocket');
|
||||
isConnectingRef.current = false;
|
||||
startPolling();
|
||||
}
|
||||
}, [wsUrl, stopPolling, startPolling, attemptReconnect]);
|
||||
|
||||
// Force refresh function
|
||||
const forceRefresh = useCallback(async () => {
|
||||
if (socketRef.current?.connected) {
|
||||
// Request update via WebSocket
|
||||
socketRef.current.emit('request_status');
|
||||
} else {
|
||||
// Poll immediately
|
||||
await pollStatus();
|
||||
}
|
||||
}, [pollStatus]);
|
||||
|
||||
// Initialize on mount
|
||||
useEffect(() => {
|
||||
// Try WebSocket first
|
||||
initializeWebSocket();
|
||||
|
||||
// If WebSocket doesn't connect within 3 seconds, start polling
|
||||
const fallbackTimeout = setTimeout(() => {
|
||||
if (!socketRef.current?.connected) {
|
||||
console.log('WebSocket connection timeout, starting polling');
|
||||
startPolling();
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
clearTimeout(fallbackTimeout);
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
|
||||
stopPolling();
|
||||
|
||||
if (socketRef.current) {
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [initializeWebSocket, startPolling, stopPolling]);
|
||||
|
||||
return {
|
||||
status,
|
||||
connected,
|
||||
isUsingWebSocket,
|
||||
error,
|
||||
forceRefresh,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Toast } from '../types';
|
||||
|
||||
export const useToast = () => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: 'info' | 'success' | 'error' = 'info') => {
|
||||
const id = Date.now().toString();
|
||||
setToasts(prev => [...prev, { id, message, type }]);
|
||||
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, 4000);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return { toasts, showToast, removeToast };
|
||||
};
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
},
|
||||
});
|
||||
@@ -1,551 +0,0 @@
|
||||
/* Base styles and CSS reset */
|
||||
:root {
|
||||
/* Light theme variables */
|
||||
--primary-color: #0073e6;
|
||||
--primary-dark: #005bb5;
|
||||
--text-color: #333;
|
||||
--background-color: #f8f8f8;
|
||||
--border-color: #e5e5e5;
|
||||
--header-bg: #333;
|
||||
--header-text: #fff;
|
||||
--loading-overlay: rgba(0, 0, 0, 0.5);
|
||||
--card-background: #fff;
|
||||
--table-background: #fff;
|
||||
--table-hover-background: #f8f8f8;
|
||||
--table-border-color: #e5e5e5;
|
||||
--input-background: #fff;
|
||||
--heading-color: #333;
|
||||
|
||||
/* Modern UI alias tokens */
|
||||
--bg: var(--background-color);
|
||||
--text: var(--text-color);
|
||||
--border-muted: var(--border-color);
|
||||
--bg-soft: var(--card-background);
|
||||
--footer-bg: var(--header-bg);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
/* Dark theme variables with improved contrast */
|
||||
--background-color: #121212;
|
||||
--text-color: #ffffff;
|
||||
--heading-color: #ffffff;
|
||||
--card-background: #1e1e1e;
|
||||
--border-color: #404040;
|
||||
--table-background: #1e1e1e;
|
||||
--table-hover-background: #2d2d2d;
|
||||
--table-border-color: #404040;
|
||||
--input-background: #2d2d2d;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
html, body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
background: var(--background-color);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
min-height: 100%;
|
||||
background: var(--background-color);
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
header {
|
||||
background: var(--header-bg);
|
||||
color: var(--header-text);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background: var(--header-bg);
|
||||
color: var(--header-text);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* Search Section */
|
||||
.search-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.search-container input {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.search-container input:focus {
|
||||
border-color: var(--primary-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-filter {
|
||||
max-width: 210px;
|
||||
}
|
||||
|
||||
/* Table Styles */
|
||||
.table-responsive {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
text-align: left;
|
||||
justify-content:center;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f5f5f5;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.details-button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.3s ease;
|
||||
width: 100%;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.details-button:hover {
|
||||
background: #1f894b;
|
||||
}
|
||||
|
||||
/* Results Section */
|
||||
.results-section {
|
||||
margin-bottom: 2rem;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.results-heading {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.collapsed .toggle-icon {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.results-content {
|
||||
transition: max-height 0.3s ease;
|
||||
}
|
||||
|
||||
.collapsed .results-content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
th[data-sort="index"] {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
/* Loading Indicator */
|
||||
.loading-indicator {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--primary-color);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--loading-overlay);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.details-container {
|
||||
background: white;
|
||||
padding: 0.25rem 0.25rem 2rem 3rem;
|
||||
border-radius: 4px;
|
||||
max-width: 800px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.details-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.details-header img {
|
||||
max-width: 200px;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.details-info h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.details-info p {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.details-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.details-actions button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.details-actions button:first-child {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.details-actions button:last-child {
|
||||
background: #f5f5f5;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.details-actions button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.search-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.details-header {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.details-header img {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.details-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Status indicators */
|
||||
.status-queued {
|
||||
color: #f39c12;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-downloading {
|
||||
color: #3498db;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-available {
|
||||
color: #27ae60;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: #e74c3c;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-done {
|
||||
color: black;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Status table specific styles */
|
||||
#status-table img {
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
#status-table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #e74c3c;
|
||||
text-align: center;
|
||||
padding: 1rem !important;
|
||||
}
|
||||
|
||||
/* Dark mode specific styles */
|
||||
[data-theme="dark"] .uk-card,
|
||||
[data-theme="dark"] .uk-modal-dialog {
|
||||
background-color: var(--card-background);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .uk-table {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .uk-table-hover tbody tr:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .uk-button-default {
|
||||
background-color: var(--card-background);
|
||||
color: var(--text-color);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .uk-search-input {
|
||||
background-color: var(--card-background);
|
||||
color: var(--text-color);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .uk-select {
|
||||
background-color: var(--card-background);
|
||||
color: var(--text-color);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Table styles */
|
||||
.uk-table {
|
||||
background-color: var(--table-background);
|
||||
color: var(--text-color);
|
||||
border-color: var(--table-border-color);
|
||||
}
|
||||
|
||||
.uk-table th {
|
||||
color: var(--text-color);
|
||||
background-color: var(--table-background);
|
||||
border-bottom-color: var(--table-border-color);
|
||||
}
|
||||
|
||||
.uk-table td {
|
||||
color: var(--text-color);
|
||||
background-color: var(--table-background);
|
||||
border-bottom-color: var(--table-border-color);
|
||||
}
|
||||
|
||||
.uk-table-hover tbody tr:hover,
|
||||
.uk-table-hover tbody tr:hover td {
|
||||
background-color: var(--table-hover-background);
|
||||
}
|
||||
|
||||
/* Input styles */
|
||||
.uk-search-input,
|
||||
.uk-input,
|
||||
.uk-select,
|
||||
.uk-textarea {
|
||||
background-color: var(--input-background);
|
||||
color: var(--text-color);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.uk-button-default {
|
||||
background-color: var(--card-background);
|
||||
color: var(--text-color);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.uk-button-default:hover {
|
||||
background-color: var(--table-hover-background);
|
||||
color: var(--text-color);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Dropdown styles */
|
||||
.uk-dropdown {
|
||||
background-color: var(--card-background);
|
||||
color: var(--text-color);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.uk-dropdown-nav > li > a {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.uk-dropdown-nav > li > a:hover {
|
||||
background-color: var(--table-hover-background);
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.modal-overlay {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.details-container {
|
||||
background-color: var(--card-background);
|
||||
color: var(--text-color);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Status colors - ensure they remain visible in dark mode */
|
||||
.status-downloading {
|
||||
color: #4CAF50 !important;
|
||||
}
|
||||
|
||||
.status-available {
|
||||
color: #2196F3 !important;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: #f44336 !important;
|
||||
}
|
||||
|
||||
/* Header specific button styles */
|
||||
header .uk-button-default {
|
||||
color: var(--header-text) !important;
|
||||
background-color: transparent;
|
||||
border-color: var(--header-text);
|
||||
}
|
||||
|
||||
header .uk-button-default:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-color: var(--header-text);
|
||||
color: var(--header-text) !important;
|
||||
}
|
||||
|
||||
/* Ensure dropdown text is visible when opened */
|
||||
header .uk-dropdown {
|
||||
background-color: var(--card-background);
|
||||
}
|
||||
|
||||
header .uk-dropdown-nav > li > a {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
header .uk-dropdown-nav > li > a:hover {
|
||||
color: var(--text-color);
|
||||
background-color: var(--table-hover-background);
|
||||
}
|
||||
|
||||
/* Update headings to use heading color */
|
||||
h1, h2, h3, h4, h5, h6,
|
||||
.uk-heading-small,
|
||||
.uk-heading-medium,
|
||||
.uk-heading-large,
|
||||
.uk-heading-xlarge,
|
||||
.uk-heading-2xlarge {
|
||||
color: var(--heading-color) !important;
|
||||
}
|
||||
|
||||
/* Ensure accordion titles are visible */
|
||||
.uk-accordion-title {
|
||||
color: var(--heading-color) !important;
|
||||
}
|
||||
|
||||
/* Ensure search results heading is visible */
|
||||
#results-section-accordion .uk-accordion-title h1 {
|
||||
color: var(--heading-color) !important;
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
// Modern UI script: search, cards, details, downloads, status, theme
|
||||
// Reuses existing API endpoints. Keeps logic minimal and accessible.
|
||||
|
||||
(function () {
|
||||
// ---- DOM ----
|
||||
const el = {
|
||||
searchInput: document.getElementById('search-input'),
|
||||
searchBtn: document.getElementById('search-button'),
|
||||
advToggle: document.getElementById('toggle-advanced'),
|
||||
filtersForm: document.getElementById('search-filters'),
|
||||
isbn: document.getElementById('isbn-input'),
|
||||
author: document.getElementById('author-input'),
|
||||
title: document.getElementById('title-input'),
|
||||
lang: document.getElementById('lang-input'),
|
||||
sort: document.getElementById('sort-input'),
|
||||
content: document.getElementById('content-input'),
|
||||
resultsGrid: document.getElementById('results-grid'),
|
||||
noResults: document.getElementById('no-results'),
|
||||
searchLoading: document.getElementById('search-loading'),
|
||||
modalOverlay: document.getElementById('modal-overlay'),
|
||||
detailsContainer: document.getElementById('details-container'),
|
||||
refreshStatusBtn: document.getElementById('refresh-status-button'),
|
||||
clearCompletedBtn: document.getElementById('clear-completed-button'),
|
||||
statusLoading: document.getElementById('status-loading'),
|
||||
statusList: document.getElementById('status-list'),
|
||||
activeDownloadsCount: document.getElementById('active-downloads-count'),
|
||||
// Active downloads (top section under search)
|
||||
activeTopSec: document.getElementById('active-downloads-top'),
|
||||
activeTopList: document.getElementById('active-downloads-list'),
|
||||
activeTopRefreshBtn: document.getElementById('active-refresh-button'),
|
||||
themeToggle: document.getElementById('theme-toggle'),
|
||||
themeText: document.getElementById('theme-text'),
|
||||
themeMenu: document.getElementById('theme-menu')
|
||||
};
|
||||
|
||||
// ---- Constants ----
|
||||
const API = {
|
||||
search: '/request/api/search',
|
||||
info: '/request/api/info',
|
||||
download: '/request/api/download',
|
||||
status: '/request/api/status',
|
||||
cancelDownload: '/request/api/download',
|
||||
setPriority: '/request/api/queue',
|
||||
clearCompleted: '/request/api/queue/clear',
|
||||
activeDownloads: '/request/api/downloads/active'
|
||||
};
|
||||
const FILTERS = ['isbn', 'author', 'title', 'lang', 'sort', 'content', 'format'];
|
||||
|
||||
// ---- Utils ----
|
||||
const utils = {
|
||||
show(node) { node && node.classList.remove('hidden'); },
|
||||
hide(node) { node && node.classList.add('hidden'); },
|
||||
async j(url, opts = {}) {
|
||||
const res = await fetch(url, opts);
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return res.json();
|
||||
},
|
||||
// Build query string from basic + advanced filters
|
||||
buildQuery() {
|
||||
const q = [];
|
||||
const basic = el.searchInput?.value?.trim();
|
||||
if (basic) q.push(`query=${encodeURIComponent(basic)}`);
|
||||
|
||||
if (!el.filtersForm || el.filtersForm.classList.contains('hidden')) {
|
||||
return q.join('&');
|
||||
}
|
||||
|
||||
FILTERS.forEach((name) => {
|
||||
if (name === 'format') {
|
||||
const checked = Array.from(document.querySelectorAll('[id^="format-"]:checked'));
|
||||
checked.forEach((cb) => q.push(`format=${encodeURIComponent(cb.value)}`));
|
||||
} else {
|
||||
const input = document.querySelectorAll(`[id^="${name}-input"]`);
|
||||
input.forEach((node) => {
|
||||
const val = node.value?.trim();
|
||||
if (val) q.push(`${name}=${encodeURIComponent(val)}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return q.join('&');
|
||||
},
|
||||
// Simple notification via alert fallback
|
||||
toast(msg) { try { console.info(msg); } catch (_) {} },
|
||||
// Escapes text for safe HTML injection
|
||||
e(text) { return (text ?? '').toString(); }
|
||||
};
|
||||
|
||||
// ---- Modal ----
|
||||
const modal = {
|
||||
open() { el.modalOverlay?.classList.add('active'); },
|
||||
close() { el.modalOverlay?.classList.remove('active'); el.detailsContainer.innerHTML = ''; }
|
||||
};
|
||||
|
||||
// ---- Cards ----
|
||||
function renderCard(book) {
|
||||
const cover = book.preview ? `<img src="${utils.e(book.preview)}" alt="Cover" class="w-full h-88 object-cover rounded">` :
|
||||
`<div class="w-full h-88 rounded flex items-center justify-center opacity-70" style="background: var(--bg-soft)">No Cover</div>`;
|
||||
|
||||
const html = `
|
||||
<article class="rounded border p-3 flex flex-col gap-3" style="border-color: var(--border-muted); background: var(--bg-soft)">
|
||||
${cover}
|
||||
<div class="flex-1 space-y-1">
|
||||
<h3 class="font-semibold leading-tight">${utils.e(book.title) || 'Untitled'}</h3>
|
||||
<p class="text-sm opacity-80">${utils.e(book.author) || 'Unknown author'}</p>
|
||||
<div class="text-xs opacity-70 flex flex-wrap gap-2">
|
||||
<span>${utils.e(book.year) || '-'}</span>
|
||||
<span>•</span>
|
||||
<span>${utils.e(book.language) || '-'}</span>
|
||||
<span>•</span>
|
||||
<span>${utils.e(book.format) || '-'}</span>
|
||||
${book.size ? `<span>•</span><span>${utils.e(book.size)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="px-3 py-2 rounded border text-sm flex-1" data-action="details" data-id="${utils.e(book.id)}" style="border-color: var(--border-muted);">Details</button>
|
||||
<button class="px-3 py-2 rounded bg-blue-600 hover:bg-blue-700 text-white text-sm flex-1" data-action="download" data-id="${utils.e(book.id)}">Download</button>
|
||||
</div>
|
||||
</article>`;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = html;
|
||||
// Bind actions
|
||||
const detailsBtn = wrapper.querySelector('[data-action="details"]');
|
||||
const downloadBtn = wrapper.querySelector('[data-action="download"]');
|
||||
detailsBtn?.addEventListener('click', () => bookDetails.show(book.id));
|
||||
downloadBtn?.addEventListener('click', () => bookDetails.download(book));
|
||||
return wrapper.firstElementChild;
|
||||
}
|
||||
|
||||
function renderCards(books) {
|
||||
el.resultsGrid.innerHTML = '';
|
||||
if (!books || books.length === 0) {
|
||||
utils.show(el.noResults);
|
||||
return;
|
||||
}
|
||||
utils.hide(el.noResults);
|
||||
const frag = document.createDocumentFragment();
|
||||
books.forEach((b) => frag.appendChild(renderCard(b)));
|
||||
el.resultsGrid.appendChild(frag);
|
||||
}
|
||||
|
||||
// ---- Search ----
|
||||
const search = {
|
||||
async run() {
|
||||
const qs = utils.buildQuery();
|
||||
if (!qs) { renderCards([]); return; }
|
||||
utils.show(el.searchLoading);
|
||||
try {
|
||||
const data = await utils.j(`${API.search}?${qs}`);
|
||||
renderCards(data);
|
||||
} catch (e) {
|
||||
renderCards([]);
|
||||
} finally {
|
||||
utils.hide(el.searchLoading);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Details ----
|
||||
const bookDetails = {
|
||||
async show(id) {
|
||||
try {
|
||||
modal.open();
|
||||
el.detailsContainer.innerHTML = '<div class="p-4">Loading…</div>';
|
||||
const book = await utils.j(`${API.info}?id=${encodeURIComponent(id)}`);
|
||||
el.detailsContainer.innerHTML = this.tpl(book);
|
||||
document.getElementById('close-details')?.addEventListener('click', modal.close);
|
||||
document.getElementById('download-button')?.addEventListener('click', () => this.download(book));
|
||||
} catch (e) {
|
||||
el.detailsContainer.innerHTML = '<div class="p-4">Failed to load details.</div>';
|
||||
}
|
||||
},
|
||||
tpl(book) {
|
||||
const cover = book.preview ? `<img src="${utils.e(book.preview)}" alt="Cover" class="w-full h-88 object-cover rounded">` : '';
|
||||
const infoList = book.info ? Object.entries(book.info).map(([k, v]) => `<li><strong>${utils.e(k)}:</strong> ${utils.e((v||[]).join
|
||||
? v.join(', ') : v)}</li>`).join('') : '';
|
||||
return `
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>${cover}</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-1">${utils.e(book.title) || 'Untitled'}</h3>
|
||||
<p class="text-sm opacity-80">${utils.e(book.author) || 'Unknown author'}</p>
|
||||
<div class="text-sm mt-2 space-y-1">
|
||||
<p><strong>Publisher:</strong> ${utils.e(book.publisher) || '-'}</p>
|
||||
<p><strong>Year:</strong> ${utils.e(book.year) || '-'}</p>
|
||||
<p><strong>Language:</strong> ${utils.e(book.language) || '-'}</p>
|
||||
<p><strong>Format:</strong> ${utils.e(book.format) || '-'}</p>
|
||||
<p><strong>Size:</strong> ${utils.e(book.size) || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${infoList ? `<div><h4 class="font-semibold mb-2">Further Information</h4><ul class="list-disc pl-6 space-y-1 text-sm">${infoList}</ul></div>` : ''}
|
||||
<div class="flex gap-2">
|
||||
<button id="download-button" class="px-3 py-2 rounded bg-blue-600 hover:bg-blue-700 text-white text-sm">Download</button>
|
||||
<button id="close-details" class="px-3 py-2 rounded border text-sm" style="border-color: var(--border-muted);">Close</button>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
async download(book) {
|
||||
if (!book) return;
|
||||
try {
|
||||
await utils.j(`${API.download}?id=${encodeURIComponent(book.id)}`);
|
||||
utils.toast('Queued for download');
|
||||
modal.close();
|
||||
status.fetch();
|
||||
} catch (_){}
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Status ----
|
||||
const status = {
|
||||
async fetch() {
|
||||
try {
|
||||
utils.show(el.statusLoading);
|
||||
const data = await utils.j(API.status);
|
||||
this.render(data);
|
||||
// Also reflect active downloads in the top section
|
||||
this.renderTop(data);
|
||||
this.updateActive();
|
||||
} catch (e) {
|
||||
el.statusList.innerHTML = '<div class="text-sm opacity-80">Error loading status.</div>';
|
||||
} finally { utils.hide(el.statusLoading); }
|
||||
},
|
||||
render(data) {
|
||||
// data shape: {queued: {...}, downloading: {...}, completed: {...}, error: {...}}
|
||||
const sections = [];
|
||||
for (const [name, items] of Object.entries(data || {})) {
|
||||
if (!items || Object.keys(items).length === 0) continue;
|
||||
const rows = Object.values(items).map((b) => {
|
||||
const titleText = utils.e(b.title) || '-';
|
||||
const maybeLinkedTitle = b.download_path
|
||||
? `<a href="/request/api/localdownload?id=${encodeURIComponent(b.id)}" class="text-blue-600 hover:underline">${titleText}</a>`
|
||||
: titleText;
|
||||
const actions = (name === 'queued' || name === 'downloading')
|
||||
? `<button class="px-2 py-1 rounded border text-xs" data-cancel="${utils.e(b.id)}" style="border-color: var(--border-muted);">Cancel</button>`
|
||||
: '';
|
||||
const progress = (name === 'downloading' && typeof b.progress === 'number')
|
||||
? `<div class="h-2 bg-black/10 rounded overflow-hidden"><div class="h-2 bg-blue-600" style="width:${Math.round(b.progress)}%"></div></div>`
|
||||
: '';
|
||||
return `<li class="p-3 rounded border flex flex-col gap-2" style="border-color: var(--border-muted); background: var(--bg-soft)">
|
||||
<div class="text-sm"><span class="opacity-70">${utils.e(name)}</span> • <strong>${maybeLinkedTitle}</strong></div>
|
||||
${progress}
|
||||
<div class="flex items-center gap-2">${actions}</div>
|
||||
</li>`;
|
||||
}).join('');
|
||||
sections.push(`
|
||||
<div>
|
||||
<h4 class="font-semibold mb-2">${name.charAt(0).toUpperCase() + name.slice(1)}</h4>
|
||||
<ul class="space-y-2">${rows}</ul>
|
||||
</div>`);
|
||||
}
|
||||
el.statusList.innerHTML = sections.join('') || '<div class="text-sm opacity-80">No items.</div>';
|
||||
// Bind cancel buttons
|
||||
el.statusList.querySelectorAll('[data-cancel]')?.forEach((btn) => {
|
||||
btn.addEventListener('click', () => queue.cancel(btn.getAttribute('data-cancel')));
|
||||
});
|
||||
},
|
||||
// Render compact active downloads list near the search bar
|
||||
renderTop(data) {
|
||||
try {
|
||||
const downloading = (data && data.downloading) ? Object.values(data.downloading) : [];
|
||||
if (!el.activeTopSec || !el.activeTopList) return;
|
||||
if (!downloading.length) {
|
||||
el.activeTopList.innerHTML = '';
|
||||
el.activeTopSec.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
// Build compact rows with title and progress bar + cancel
|
||||
const rows = downloading.map((b) => {
|
||||
const prog = (typeof b.progress === 'number')
|
||||
? `<div class="h-1.5 bg-black/10 rounded overflow-hidden"><div class="h-1.5 bg-blue-600" style="width:${Math.round(b.progress)}%"></div></div>`
|
||||
: '';
|
||||
const cancel = `<button class="px-2 py-0.5 rounded border text-xs" data-cancel="${utils.e(b.id)}" style="border-color: var(--border-muted);">Cancel</button>`;
|
||||
return `<div class="p-3 rounded border" style="border-color: var(--border-muted); background: var(--bg-soft)">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-sm truncate"><strong>${utils.e(b.title || '-') }</strong></div>
|
||||
<div class="shrink-0">${cancel}</div>
|
||||
</div>
|
||||
${prog}
|
||||
</div>`;
|
||||
}).join('');
|
||||
el.activeTopList.innerHTML = rows;
|
||||
el.activeTopSec.classList.remove('hidden');
|
||||
// Bind cancel handlers for the top section
|
||||
el.activeTopList.querySelectorAll('[data-cancel]')?.forEach((btn) => {
|
||||
btn.addEventListener('click', () => queue.cancel(btn.getAttribute('data-cancel')));
|
||||
});
|
||||
} catch (_) {}
|
||||
},
|
||||
async updateActive() {
|
||||
try {
|
||||
const d = await utils.j(API.activeDownloads);
|
||||
const n = Array.isArray(d.active_downloads) ? d.active_downloads.length : 0;
|
||||
if (el.activeDownloadsCount) el.activeDownloadsCount.textContent = `Active: ${n}`;
|
||||
} catch (_) {}
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Queue ----
|
||||
const queue = {
|
||||
async cancel(id) {
|
||||
try {
|
||||
await fetch(`${API.cancelDownload}/${encodeURIComponent(id)}/cancel`, { method: 'DELETE' });
|
||||
status.fetch();
|
||||
} catch (_){}
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Theme ----
|
||||
const theme = {
|
||||
KEY: 'preferred-theme',
|
||||
init() {
|
||||
const saved = localStorage.getItem(this.KEY) || 'auto';
|
||||
this.apply(saved);
|
||||
this.updateLabel(saved);
|
||||
// toggle dropdown
|
||||
el.themeToggle?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
if (!el.themeMenu) return;
|
||||
el.themeMenu.classList.toggle('hidden');
|
||||
});
|
||||
// outside click to close
|
||||
document.addEventListener('click', (ev) => {
|
||||
if (!el.themeMenu || !el.themeToggle) return;
|
||||
if (el.themeMenu.contains(ev.target) || el.themeToggle.contains(ev.target)) return;
|
||||
el.themeMenu.classList.add('hidden');
|
||||
});
|
||||
// selection
|
||||
el.themeMenu?.querySelectorAll('a[data-theme]')?.forEach((a) => {
|
||||
a.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
const pref = a.getAttribute('data-theme');
|
||||
localStorage.setItem(theme.KEY, pref);
|
||||
theme.apply(pref);
|
||||
theme.updateLabel(pref);
|
||||
el.themeMenu.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
// react to system change if auto
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mq.addEventListener('change', (e) => {
|
||||
if ((localStorage.getItem(theme.KEY) || 'auto') === 'auto') {
|
||||
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
},
|
||||
apply(pref) {
|
||||
if (pref === 'auto') {
|
||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', pref);
|
||||
}
|
||||
},
|
||||
updateLabel(pref) { if (el.themeText) el.themeText.textContent = `Theme (${pref})`; }
|
||||
};
|
||||
|
||||
// ---- Wire up ----
|
||||
function initEvents() {
|
||||
el.searchBtn?.addEventListener('click', () => search.run());
|
||||
el.searchInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') search.run(); });
|
||||
document.getElementById('adv-search-button')?.addEventListener('click', () => search.run());
|
||||
|
||||
if (el.advToggle && el.filtersForm) {
|
||||
el.advToggle.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
el.filtersForm.classList.toggle('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
el.refreshStatusBtn?.addEventListener('click', () => status.fetch());
|
||||
el.activeTopRefreshBtn?.addEventListener('click', () => status.fetch());
|
||||
el.clearCompletedBtn?.addEventListener('click', async () => {
|
||||
try { await fetch(API.clearCompleted, { method: 'DELETE' }); status.fetch(); } catch (_) {}
|
||||
});
|
||||
|
||||
// Close modal on overlay click
|
||||
el.modalOverlay?.addEventListener('click', (e) => { if (e.target === el.modalOverlay) modal.close(); });
|
||||
}
|
||||
|
||||
// ---- Init ----
|
||||
theme.init();
|
||||
initEvents();
|
||||
status.fetch();
|
||||
})();
|
||||
|
Before Width: | Height: | Size: 16 KiB |
@@ -1,241 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Calibre Web Book Downloader - Modern UI">
|
||||
<meta name="theme-color" content="#333333">
|
||||
<title>Book Downloader • Modern</title>
|
||||
|
||||
<!-- Base styles and theme variables -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='media/favicon.ico') }}">
|
||||
|
||||
<!-- Tailwind (no-build) for rapid iteration) -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="min-h-screen" style="background: var(--bg); color: var(--text);">
|
||||
<!-- Header -->
|
||||
<header class="w-full border-b border-[color:var(--border-muted)]" style="background: var(--header-bg);">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="{{ url_for('static', filename='media/logo.png') }}" alt="Logo" class="h-8 w-8">
|
||||
<h1 class="text-lg font-semibold">Book Search & Download</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{% if debug %}
|
||||
<form action="/request/api/restart" method="get" id="restart-form">
|
||||
<button class="px-3 py-1 rounded bg-red-600 text-white text-sm" id="restart-button" type="submit">
|
||||
RESTART
|
||||
</button>
|
||||
</form>
|
||||
<form action="/request/debug" method="get" id="debug-form">
|
||||
<button class="px-3 py-1 rounded bg-red-600/80 text-white text-sm" id="debug-button" type="submit">
|
||||
DEBUG
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<!-- Theme Dropdown -->
|
||||
<div class="relative">
|
||||
<button id="theme-toggle" class="px-3 py-1 rounded border text-sm" style="border-color: var(--border-muted);">
|
||||
<span id="theme-text">Theme</span>
|
||||
</button>
|
||||
<div id="theme-menu" class="absolute right-0 mt-2 w-36 rounded-md shadow-lg ring-1 ring-black/5 hidden" style="background: var(--bg-soft);">
|
||||
<ul class="py-1 text-sm">
|
||||
<li><a href="#" data-theme="light" class="block px-3 py-1 hover:bg-black/10">Light</a></li>
|
||||
<li><a href="#" data-theme="dark" class="block px-3 py-1 hover:bg-black/10">Dark</a></li>
|
||||
<li><a href="#" data-theme="auto" class="block px-3 py-1 hover:bg-black/10">Auto (System)</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<!-- Hero / Search -->
|
||||
<section class="mb-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex gap-2">
|
||||
<input id="search-input" type="search" placeholder="Search by ISBN, title, author..." aria-label="Search books"
|
||||
class="flex-1 px-4 py-3 rounded-md border outline-none"
|
||||
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
|
||||
<button id="search-button" class="px-4 py-3 rounded-md text-white bg-blue-600 hover:bg-blue-700">
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button id="toggle-advanced" class="text-sm underline opacity-80 hover:opacity-100">Advanced Search</button>
|
||||
</div>
|
||||
<!-- Advanced Filters -->
|
||||
<form id="search-filters" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 hidden">
|
||||
<div>
|
||||
<label for="isbn-input" class="block text-sm mb-1 opacity-80">ISBN</label>
|
||||
<input id="isbn-input" type="search" placeholder="ISBN"
|
||||
class="w-full px-3 py-2 rounded-md border"
|
||||
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
|
||||
</div>
|
||||
<div>
|
||||
<label for="author-input" class="block text-sm mb-1 opacity-80">Author</label>
|
||||
<input id="author-input" type="search" placeholder="Author"
|
||||
class="w-full px-3 py-2 rounded-md border"
|
||||
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
|
||||
</div>
|
||||
<div>
|
||||
<label for="title-input" class="block text-sm mb-1 opacity-80">Title</label>
|
||||
<input id="title-input" type="search" placeholder="Title"
|
||||
class="w-full px-3 py-2 rounded-md border"
|
||||
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
|
||||
</div>
|
||||
<div>
|
||||
<label for="lang-input" class="block text-sm mb-1 opacity-80">Language</label>
|
||||
<select id="lang-input" class="w-full px-3 py-2 rounded-md border"
|
||||
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
|
||||
<option value="all">All</option>
|
||||
{% for lang in book_languages %}
|
||||
<option value="{{ lang.code }}" {% if lang.code == default_language[0] %}selected{% endif %}>
|
||||
{{ lang.language }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="sort-input" class="block text-sm mb-1 opacity-80">Sort</label>
|
||||
<select id="sort-input" class="w-full px-3 py-2 rounded-md border"
|
||||
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
|
||||
<option value="">Most relevant</option>
|
||||
<option value="newest">Newest (publication year)</option>
|
||||
<option value="oldest">Oldest (publication year)</option>
|
||||
<option value="largest">Largest (filesize)</option>
|
||||
<option value="smallest">Smallest (filesize)</option>
|
||||
<option value="newest_added">Newest (open sourced)</option>
|
||||
<option value="oldest_added">Oldest (open sourced)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="content-input" class="block text sm mb-1 opacity-80">Content</label>
|
||||
<select id="content-input" class="w-full px-3 py-2 rounded-md border"
|
||||
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
|
||||
<option value="">All</option>
|
||||
<option value="book_nonfiction">Book (non-fiction)</option>
|
||||
<option value="book_fiction">Book (fiction)</option>
|
||||
<option value="book_unknown">Book (unknown)</option>
|
||||
<option value="magazine">Magazine</option>
|
||||
<option value="book_comic">Comic Book</option>
|
||||
<option value="standards_document">Standards document</option>
|
||||
<option value="other">Other</option>
|
||||
<option value="musical_score">Musical score</option>
|
||||
<option value="audiobook">Audiobook</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-2 lg:col-span-3">
|
||||
<label class="block text-sm mb-1 opacity-80">Formats</label>
|
||||
<div class="flex flex-wrap gap-3 text-sm">
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'pdf' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-pdf" value="pdf" {% if 'pdf' not in supported_formats %}disabled{% endif %}>
|
||||
PDF
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'epub' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-epub" value="epub" {% if 'epub' in supported_formats %}checked{% endif %} {% if 'epub' not in supported_formats %}disabled{% endif %}>
|
||||
EPUB
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'mobi' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-mobi" value="mobi" {% if 'mobi' in supported_formats %}checked{% endif %} {% if 'mobi' not in supported_formats %}disabled{% endif %}>
|
||||
MOBI
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'azw3' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-azw3" value="azw3" {% if 'azw3' in supported_formats %}checked{% endif %} {% if 'azw3' not in supported_formats %}disabled{% endif %}>
|
||||
AZW3
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'fb2' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-fb2" value="fb2" {% if 'fb2' in supported_formats %}checked{% endif %} {% if 'fb2' not in supported_formats %}disabled{% endif %}>
|
||||
FB2
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'djvu' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-djvu" value="djvu" {% if 'djvu' in supported_formats %}checked{% endif %} {% if 'djvu' not in supported_formats %}disabled{% endif %}>
|
||||
DJVU
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'cbz' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-cbz" value="cbz" {% if 'cbz' in supported_formats %}checked{% endif %} {% if 'cbz' not in supported_formats %}disabled{% endif %}>
|
||||
CBZ
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'cbr' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-cbr" value="cbr" {% if 'cbr' in supported_formats %}checked{% endif %} {% if 'cbr' not in supported_formats %}disabled{% endif %}>
|
||||
CBR
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:col-span-2 lg:col-span-3 flex justify-end">
|
||||
<button id="adv-search-button" type="button" class="px-4 py-2 rounded-md border"
|
||||
style="border-color: var(--border-muted);">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Active Downloads (Top) -->
|
||||
<section id="active-downloads-top" class="mb-6 hidden">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-lg font-semibold">Active Downloads</h2>
|
||||
<button id="active-refresh-button" class="px-3 py-1 rounded border text-sm" style="border-color: var(--border-muted);">Refresh</button>
|
||||
</div>
|
||||
<div id="active-downloads-list" class="space-y-2"></div>
|
||||
</section>
|
||||
|
||||
<!-- Results -->
|
||||
<section class="mb-8">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-xl font-semibold">Search Results</h2>
|
||||
<div id="search-loading" class="text-sm opacity-80 hidden">Loading…</div>
|
||||
</div>
|
||||
<div id="results-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<!-- Cards will be injected here -->
|
||||
</div>
|
||||
<div id="no-results" class="mt-4 text-sm opacity-80 hidden">No results found.</div>
|
||||
</section>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal-overlay" id="modal-overlay" role="dialog" aria-modal="true">
|
||||
<div class="details-container" id="details-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<section>
|
||||
<div class="flex items-center flex-wrap mb-3">
|
||||
<h2 class="text-xl font-semibold mr-4 sm:mr-6">Download Queue & Status</h2>
|
||||
<div class="flex items-center gap-3 ml-4 sm:ml-auto">
|
||||
<button id="refresh-status-button" class="px-3 py-1 rounded border text-sm" style="border-color: var(--border-muted);">Refresh</button>
|
||||
<button id="clear-completed-button" class="px-3 py-1 rounded border text-sm" style="border-color: var(--border-muted);">Clear Completed</button>
|
||||
<span id="active-downloads-count" class="text-sm opacity-80">Active: 0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="status-loading" class="text-sm opacity-80 hidden">Loading…</div>
|
||||
<div id="status-list" class="space-y-2"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="mt-10 border-t pt-6 pb-10" style="border-color: var(--border-muted); background: var(--footer-bg);">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm opacity-80">Calibre Web Book Downloader</p>
|
||||
<p class="text-xs opacity-60 mt-1">
|
||||
Build: {{ build_version }} • Release: {{ release_version }} • Env: {{ app_env }}
|
||||
</p>
|
||||
</div>
|
||||
<a href="https://github.com/calibrain/calibre-web-automated-book-downloader" class="opacity-80 hover:opacity-100" aria-label="GitHub">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 005.47 7.59c.4.07.55-.17.55-.38
|
||||
0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52
|
||||
-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95
|
||||
0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.54 7.54 0 012 0c1.53-1.03 2.2-.82 2.2-.82
|
||||
.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2
|
||||
0 .21.15.46.55.38A8 8 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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()
|
||||