Compare commits

...
26 Commits
Author SHA1 Message Date
Alex 122a3633c2 APP_ENV removal and secure cookie handling (#333)
Hey, made the tweaks we discussed, plus a couple related fixes :)

- Removed APP_ENV entirely. All dev-specific functionality is enabled
via `DEBUG: true` env var
- Set secure cookie handling to false by default, added to the readme to
enable if exclusively using HTTPS connection
- Fixed healthcheck potentially not working with auth enabled
- Removed APP_ENV from docker compose files and made sure app.db lines
are included in all versions.

APP_ENV in people's existing composes should get ignored entirely and
will be put on the default env, so no issues when updating.
2025-12-11 17:13:41 -05:00
CaliBrain 0e2580030b Change APP_ENV from 'prod' to 'dev' default (#331)
Fix for #330
2025-12-08 14:48:33 -05:00
AlexandCaliBrain 17057ecfbe WebUI - Mobile view tweaks (#329)
One set of changes I forgot to commit yesterday - a few minor
adjustments to improve experience on mobile, especially PWAs. Adjusted
search bar positioning and added explicit safe views for individual
components such as the header and footer.

Before / After  - test PWA on my iPhone: 
<img width="300" alt="IMG_1004"
src="https://github.com/user-attachments/assets/690b567e-a1a1-44c7-8e57-52ee8d896476"
/> - <img width="300" alt="IMG_1003"
src="https://github.com/user-attachments/assets/3f2b51cb-f408-47c8-9726-4b5d7d5a840c"
/>

@calibrain I think you can also close #31 #178 and #270 , should all be
covered off by the various WebUI PRs in the last week or so :)

---------

Co-authored-by: CaliBrain <calibrain@l4n.xyz>
2025-11-23 17:12:48 -05:00
2b831dcfa5 [FEATURE] Separate download folders #122 (#297)
Re: Issue #122
Fetches content type from search results - displays it on thumbnails in
results grid;
Fetches content type from book id detail page (dfaults to "Other") and
uses it to construct the `final_path`.

---------

Co-authored-by: Patricia Ritter <pritter@events.com>
Co-authored-by: CaliBrain <calibrain@l4n.xyz>
2025-11-23 17:11:43 -05:00
CaliBrain 78c61e88b3 Fix kwargs bug in tracing log (#327) 2025-11-23 01:06:00 -05:00
CaliBrain 57d85d0748 fix format (#326)
- Fix function signature error
2025-11-23 00:10:00 -05:00
CaliBrain 6492bd6a3c Fix rare case where special character might break parsing (#325)
Actual fix for #322
2025-11-23 00:05:15 -05:00
Alex ed88aac5d5 WebUI - UI fixes, additional features and refactoring (#324)
One more on the frontend with some code cleanup, additional features and
bug fixes after testing this last week or so :)

* Various refactoring - removing reused code where possible and creating
new shared components (AdvancedFilters, DownloadButton, SearchBar,
buildSearchQuery, BookCard etc). Will hopefully help with further
features and improvements within the frontend. (cc @ZYancey)
* Added book descriptions to the details pane, grabbed alongside all
other info.
* Moved format selection into a dropdown list
* Added a toast notification if no results are found when searching
* Added “Clear search” button
* Added “Report bug” link in the header menu, linking to the issues page
* Fixed various UI bugs (Mouse hover colors, download badge)
2025-11-23 00:01:43 -05:00
Alex 5751910426 Fix for #322 - File extension fallback (#323)
Updated the fallback for file extension and size to work when
size/format details are missing but `_details` does still include some
information. If this was the case previously, the code wouldn't run and
files could be saved with no extension.

Fix for #322
2025-11-22 10:09:39 -05:00
CaliBrain b02ad7452c Remove deprecated /request route prefix support (#318)
This commit removes all references to the deprecated /request route
prefix
that was previously used for dual routing. The following changes were
made:

- Removed register_dual_routes() function that registered routes with
/request prefix
- Removed url_for_with_request() helper function for generating /request
URLs
- Removed call to register_dual_routes(app) at application startup
- Removed /request/ prefixed favicon routes
- Updated StatusEndpointFilter to remove /request/api/status log
filtering
- Removed unused flask_url_for import

All routes now only use the standard paths without the /request prefix.
2025-11-16 15:41:29 -05:00
CaliBrain 289666aeef Enhance tor.sh for hostname extraction and IP resolution (#317)
Updated the script to extract hostname and IP from EXT_BYPASSER_URL and
modify /etc/hosts accordingly. Replaced pyrequests with curl for network
requests.
2025-11-16 14:02:01 -05:00
Alex cc30d24144 HTTPS cookie handling (#315)
This is the one conflict from the other merge :)
2025-11-16 13:33:44 -05:00
CaliBrain 50e53a13b0 Fix Dockerfile for arm64 qemu crashes (#316)
Added build arguments for platform-specific builds and debug output.
2025-11-16 12:50:12 -05:00
CaliBrain a46d302ba8 Add iptables rules to bypass TOR for local networks (#314)
Added iptables rules to bypass TOR for local and private networks.

Tentative fix for #306
2025-11-16 00:15:36 -05:00
Alex c5d22e0f91 WebUI - Additional Search Features (#310)
### Main Points / To Do List

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

---

### Card sizes: 

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

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


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

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

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

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

--- 
### Multi-select languages
<img width="245" height="345" alt="Screenshot 2025-11-15 at 15 41 29"
src="https://github.com/user-attachments/assets/49ce7b96-06a7-4473-857a-ccfb52c2676f"
/>
2025-11-16 00:10:06 -05:00
CaliBrain 03321a5435 Improve book metadata handling in book_manager.py (#313)
Refactor book metadata extraction and add helper function.
Fix #300 and #307
2025-11-15 03:42:40 -05:00
CaliBrain 6aed906dfe Skip ad rows in search result parsing (#312)
AA started injection an ad banner into their code, for now we start
skipping this.
I am expecting later we will need to revisit this code for when they
actually start injecting the add
2025-11-15 01:21:44 -05:00
742da1c43a 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>
2025-11-14 15:48:44 -05:00
CaliBrain 8ea2fee0bb Fixing the title and book details from AA (#289)
Should fix #288
2025-10-04 14:44:10 -04:00
John Cocula 1c24312eb0 Update book_manager.py to fix #286 (#287)
Implement the fix mentioned in
https://github.com/calibrain/calibre-web-automated-book-downloader/issues/286

Note however that I have 0% success with downloads with 0.2.2 even with
this change.
2025-10-02 19:26:29 -04:00
CaliBrain 98e3a2f114 Add all supported format as default (#283) 2025-09-16 11:22:19 -04:00
CaliBrain cd16f09f2e Fix local download (#282) 2025-09-16 11:19:09 -04:00
CaliBrain 527c5d495d Fix formats in the HTML (read from config) (#279)
Fix #277
2025-09-09 08:15:44 -04:00
CaliBrain f5de2ab143 Fix AA extension parsing (#275)
Fix #274
2025-09-07 13:57:43 -04:00
RHDevandRyan Hults 4e5c9b788f Display book covers at full height (#266)
# Why
Book covers in the UI are currently cut off on the top and bottom,
making it hard to see.

# How
doubled the height of the book cover image div so the covers are not cut
off. I found that setting it to a specific size (rather than `h-full`)
resulted in better handling of small images and made for a more
consistent look.

# Before
<img width="488" height="520" alt="Screenshot from 2025-09-02 11-44-49"
src="https://github.com/user-attachments/assets/cf94e5f7-3981-40b6-a148-2a847f565c41"
/>
<img width="488" height="520" alt="Screenshot from 2025-09-02 11-45-06"
src="https://github.com/user-attachments/assets/5849eb34-57e8-4c36-af26-cb2f9647605f"
/>



# After
<img width="488" height="520" alt="Screenshot from 2025-09-02 11-41-41"
src="https://github.com/user-attachments/assets/6ffdde1f-ba36-4253-8092-12afe1c8f84e"
/>
<img width="488" height="520" alt="Screenshot from 2025-09-02 11-44-38"
src="https://github.com/user-attachments/assets/e44b57b7-ee03-4e8b-a186-444e8a5bf5aa"
/>

---------

Co-authored-by: Ryan Hults <contact@ryanthults.com>
2025-09-02 13:13:10 -04:00
CaliBrain 199d8453eb Adding Release version (#263) 2025-08-30 03:10:15 -04:00
76 changed files with 9599 additions and 1327 deletions
+10
View File
@@ -37,3 +37,13 @@ dist/
venv/
.venv/
env/
# Frontend build artifacts (built in separate stage)
src/frontend/node_modules/
src/frontend/dist/
src/frontend/.vite/
# Old frontend code (replaced by src/frontend)
templates/
static/css/
static/js/
@@ -70,6 +70,7 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
build-args: |
BUILD_VERSION=${{ steps.date.outputs.date }}-${{ github.sha }}
RELEASE_VERSION=${{ github.ref_name }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+33 -4
View File
@@ -1,9 +1,36 @@
ARG TARGETPLATFORM
ARG TARGETARCH
ARG BUILDPLATFORM
ARG BUILDARCH
# Frontend build stage.
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder
# Helpful debug output to see what platforms BuildKit thinks it's using
RUN echo "BUILDPLATFORM=$BUILDPLATFORM BUILDARCH=$BUILDARCH TARGETPLATFORM=$TARGETPLATFORM TARGETARCH=$TARGETARCH"
WORKDIR /frontend
# Copy frontend package files
COPY src/frontend/package*.json ./
# Install dependencies
RUN npm ci
# Copy frontend source
COPY src/frontend/ ./
# Build the frontend
RUN npm run build
# Use python-slim as the base image
FROM python:3.10-slim AS base
# Add build argument for version
ARG BUILD_VERSION
ENV BUILD_VERSION=${BUILD_VERSION}
ARG RELEASE_VERSION
ENV RELEASE_VERSION=${RELEASE_VERSION}
# Set shell to bash with pipefail option
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
@@ -22,8 +49,7 @@ ENV DEBIAN_FRONTEND=noninteractive \
# UID/GID will be handled by entrypoint script, but TZ/Locale are still needed
LANG=en_US.UTF-8 \
LANGUAGE=en_US:en \
LC_ALL=en_US.UTF-8 \
APP_ENV=prod
LC_ALL=en_US.UTF-8
# Set ARG for build-time expansion (FLASK_PORT), ENV for runtime access
ENV FLASK_PORT=8084
@@ -68,6 +94,9 @@ RUN pip install --no-cache-dir -r requirements-base.txt && \
# Copy application code *after* dependencies are installed
COPY . .
# Copy built frontend from frontend-builder stage
COPY --from=frontend-builder /frontend/dist /app/frontend-dist
# Final setup: permissions and directories in one layer
# Only creating directories and setting executable bits.
# Ownership will be handled by the entrypoint script.
@@ -78,9 +107,9 @@ RUN mkdir -p /var/log/cwa-book-downloader /cwa-book-ingest && \
EXPOSE ${FLASK_PORT}
# Add healthcheck for container status
# This will run as root initially, but check localhost which should work if the app binds correctly.
# Uses /api/health which doesn't require authentication
HEALTHCHECK --interval=60s --timeout=60s --start-period=60s --retries=3 \
CMD curl -s http://localhost:${FLASK_PORT}/request/api/status > /dev/null || exit 1
CMD curl -s http://localhost:${FLASK_PORT}/api/health > /dev/null || exit 1
# Use dumb-init as the entrypoint to handle signals properly
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
+78
View File
@@ -0,0 +1,78 @@
.PHONY: help install dev build preview typecheck clean up down docker-build refresh
# Frontend directory
FRONTEND_DIR := src/frontend
# Docker compose file
COMPOSE_FILE := docker-compose.dev.yml
# Default target
help:
@echo "Available targets:"
@echo ""
@echo "Frontend:"
@echo " install - Install frontend dependencies"
@echo " dev - Start development server"
@echo " build - Build frontend for production"
@echo " preview - Preview production build"
@echo " typecheck - Run TypeScript type checking"
@echo " clean - Remove node_modules and build artifacts"
@echo ""
@echo "Backend (Docker):"
@echo " up - Start backend services"
@echo " down - Stop backend services"
@echo " docker-build - Build Docker image"
@echo " refresh - Rebuild and restart backend services"
# Install dependencies
install:
@echo "Installing frontend dependencies..."
cd $(FRONTEND_DIR) && npm install
# Start development server
dev:
@echo "Starting development server..."
cd $(FRONTEND_DIR) && npm run dev
# Build for production
build:
@echo "Building frontend for production..."
cd $(FRONTEND_DIR) && npm run build
# Preview production build
preview:
@echo "Previewing production build..."
cd $(FRONTEND_DIR) && npm run preview
# Type checking
typecheck:
@echo "Running TypeScript type checking..."
cd $(FRONTEND_DIR) && npm run typecheck
# Clean build artifacts and dependencies
clean:
@echo "Cleaning build artifacts and dependencies..."
rm -rf $(FRONTEND_DIR)/node_modules
rm -rf $(FRONTEND_DIR)/dist
# Start backend services
up:
@echo "Starting backend services..."
docker compose -f $(COMPOSE_FILE) up -d
# Stop backend services
down:
@echo "Stopping backend services..."
docker compose -f $(COMPOSE_FILE) down
# Build Docker image
docker-build:
@echo "Building Docker image..."
docker compose -f $(COMPOSE_FILE) build
# Rebuild and restart backend services
refresh:
@echo "Rebuilding and restarting backend services..."
docker compose -f $(COMPOSE_FILE) down
docker compose -f $(COMPOSE_FILE) build
docker compose -f $(COMPOSE_FILE) up -d
Binary file not shown.

Before

Width:  |  Height:  |  Size: 874 KiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 371 KiB

+394 -103
View File
@@ -3,20 +3,24 @@
import logging
import io, re, os
import sqlite3
import time
from datetime import datetime, timedelta
from functools import wraps
from flask import Flask, request, jsonify, render_template, send_file, send_from_directory
from flask import Flask, request, jsonify, send_file, send_from_directory, session
from flask_cors import CORS
from flask_socketio import SocketIO, emit
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.security import check_password_hash
from werkzeug.wrappers import Response
from flask import url_for as flask_url_for
import typing
from logger import setup_logger
from config import _SUPPORTED_BOOK_LANGUAGE, BOOK_LANGUAGE
from env import FLASK_HOST, FLASK_PORT, APP_ENV, CWA_DB_PATH, DEBUG, USING_EXTERNAL_BYPASSER
from config import _SUPPORTED_BOOK_LANGUAGE, BOOK_LANGUAGE, SUPPORTED_FORMATS
from env import FLASK_HOST, FLASK_PORT, CWA_DB_PATH, DEBUG, USING_EXTERNAL_BYPASSER, BUILD_VERSION, RELEASE_VERSION, CALIBRE_WEB_URL
import backend
from models import SearchFilters
from websocket_manager import ws_manager
logger = setup_logger(__name__)
app = Flask(__name__)
@@ -24,6 +28,116 @@ app.wsgi_app = ProxyFix(app.wsgi_app) # type: ignore
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 # Disable caching
app.config['APPLICATION_ROOT'] = '/'
# Determine async mode based on DEBUG setting
# In production (DEBUG=False) with Gunicorn + gevent worker, use 'gevent'
# In development (DEBUG=True) with Flask dev server, use 'threading'
if DEBUG:
async_mode = 'threading'
else:
async_mode = 'gevent'
# Initialize Flask-SocketIO with reverse proxy support
socketio = SocketIO(
app,
cors_allowed_origins="*",
async_mode=async_mode,
logger=False,
engineio_logger=False,
# Reverse proxy / Traefik compatibility settings
path='/socket.io',
ping_timeout=60, # Time to wait for pong response
ping_interval=25, # Send ping every 25 seconds
# Allow both websocket and polling for better compatibility
transports=['websocket', 'polling'],
# Enable CORS for all origins (you can restrict this in production)
allow_upgrades=True,
# Important for proxies that buffer
http_compression=True
)
# Initialize WebSocket manager
ws_manager.init_app(app, socketio)
logger.info(f"Flask-SocketIO initialized with async_mode='{async_mode}'")
# Rate limiting for login attempts
# Structure: {username: {'count': int, 'lockout_until': datetime}}
failed_login_attempts: typing.Dict[str, typing.Dict[str, typing.Any]] = {}
MAX_LOGIN_ATTEMPTS = 10
LOCKOUT_DURATION_MINUTES = 30
def cleanup_old_lockouts() -> None:
"""Remove expired lockout entries to prevent memory buildup."""
current_time = datetime.now()
expired_users = [
username for username, data in failed_login_attempts.items()
if 'lockout_until' in data and data['lockout_until'] < current_time
]
for username in expired_users:
logger.info(f"Lockout expired for user: {username}")
del failed_login_attempts[username]
def is_account_locked(username: str) -> bool:
"""Check if an account is currently locked due to failed login attempts."""
cleanup_old_lockouts()
if username not in failed_login_attempts:
return False
lockout_until = failed_login_attempts[username].get('lockout_until')
if lockout_until and datetime.now() < lockout_until:
return True
return False
def record_failed_login(username: str, ip_address: str) -> bool:
"""
Record a failed login attempt and lock account if threshold is reached.
Returns True if account is now locked, False otherwise.
"""
if username not in failed_login_attempts:
failed_login_attempts[username] = {'count': 0}
failed_login_attempts[username]['count'] += 1
count = failed_login_attempts[username]['count']
logger.warning(f"Failed login attempt {count}/{MAX_LOGIN_ATTEMPTS} for user '{username}' from IP {ip_address}")
if count >= MAX_LOGIN_ATTEMPTS:
lockout_until = datetime.now() + timedelta(minutes=LOCKOUT_DURATION_MINUTES)
failed_login_attempts[username]['lockout_until'] = lockout_until
logger.warning(f"Account locked for user '{username}' until {lockout_until.strftime('%Y-%m-%d %H:%M:%S')} due to {count} failed login attempts")
return True
return False
def clear_failed_logins(username: str) -> None:
"""Clear failed login attempts for a user after successful login."""
if username in failed_login_attempts:
del failed_login_attempts[username]
logger.debug(f"Cleared failed login attempts for user: {username}")
# Enable CORS in development mode for local frontend development
if DEBUG:
CORS(app, resources={
r"/*": {
"origins": ["http://localhost:5173", "http://127.0.0.1:5173"],
"supports_credentials": True,
"allow_headers": ["Content-Type", "Authorization"],
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
}
})
# Custom log filter to exclude routine status endpoint polling
class StatusEndpointFilter(logging.Filter):
"""Filter out routine status endpoint requests to reduce log noise."""
def filter(self, record):
# Exclude GET /api/status requests
if hasattr(record, 'getMessage'):
message = record.getMessage()
if 'GET /api/status' in message:
return False
return True
# Flask logger
app.logger.handlers = logger.handlers
app.logger.setLevel(logger.level)
@@ -31,14 +145,27 @@ app.logger.setLevel(logger.level)
werkzeug_logger = logging.getLogger('werkzeug')
werkzeug_logger.handlers = logger.handlers
werkzeug_logger.setLevel(logger.level)
# Add filter to suppress routine status endpoint polling logs
werkzeug_logger.addFilter(StatusEndpointFilter())
# Set up authentication defaults
# The secret key will reset every time we restart, which will
# require users to authenticate again
# Session cookie security - set to 'true' if exclusively using HTTPS
session_cookie_secure_env = os.getenv('SESSION_COOKIE_SECURE', 'false').lower()
SESSION_COOKIE_SECURE = session_cookie_secure_env in ['true', 'yes', '1']
app.config.update(
SECRET_KEY = os.urandom(64)
SECRET_KEY = os.urandom(64),
SESSION_COOKIE_HTTPONLY = True,
SESSION_COOKIE_SAMESITE = 'Lax',
SESSION_COOKIE_SECURE = SESSION_COOKIE_SECURE,
PERMANENT_SESSION_LIFETIME = 604800 # 7 days in seconds
)
logger.info(f"Session cookie secure setting: {SESSION_COOKIE_SECURE} (from env: {session_cookie_secure_env})")
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
@@ -46,72 +173,51 @@ def login_required(f):
# path, return a server error
if CWA_DB_PATH is not None and not os.path.isfile(CWA_DB_PATH):
logger.error(f"CWA_DB_PATH is set to {CWA_DB_PATH} but this is not a valid path")
return Response("Internal Server Error", 500)
if not authenticate():
return Response(
response="Unauthorized",
status=401,
headers={
"WWW-Authenticate": 'Basic realm="Calibre-Web-Automated-Book-Downloader"',
},
)
return jsonify({"error": "Internal Server Error"}), 500
# If no database is configured, allow access
if not CWA_DB_PATH:
return f(*args, **kwargs)
# Check if user has a valid session
if 'user_id' not in session:
return jsonify({"error": "Unauthorized"}), 401
return f(*args, **kwargs)
return decorated_function
def register_dual_routes(app : Flask) -> None:
"""
Register each route both with and without the /request prefix.
This function should be called after all routes are defined.
"""
# Store original url_map rules
rules = list(app.url_map.iter_rules())
# Add /request prefix to each rule
for rule in rules:
if rule.rule != '/request/' and rule.rule != '/request': # Skip if it's already a request route
# Create new routes with /request prefix, both with and without trailing slash
base_rule = rule.rule[:-1] if rule.rule.endswith('/') else rule.rule
if base_rule == '': # Special case for root path
app.add_url_rule('/request', f"root_request",
view_func=app.view_functions[rule.endpoint],
methods=rule.methods)
app.add_url_rule('/request/', f"root_request_slash",
view_func=app.view_functions[rule.endpoint],
methods=rule.methods)
else:
app.add_url_rule(f"/request{base_rule}",
f"{rule.endpoint}_request",
view_func=app.view_functions[rule.endpoint],
methods=rule.methods)
app.add_url_rule(f"/request{base_rule}/",
f"{rule.endpoint}_request_slash",
view_func=app.view_functions[rule.endpoint],
methods=rule.methods)
app.jinja_env.globals['url_for'] = url_for_with_request
def url_for_with_request(endpoint : str, **values : typing.Any) -> str:
"""Generate URLs with /request prefix by default."""
if endpoint == 'static':
# For static files, add /request prefix
url = flask_url_for(endpoint, **values)
return f"/request{url}"
return flask_url_for(endpoint, **values)
# Serve frontend static files
@app.route('/assets/<path:filename>')
def serve_frontend_assets(filename: str) -> Response:
"""
Serve static assets from the built frontend.
"""
return send_from_directory(os.path.join(app.root_path, 'frontend-dist', 'assets'), filename)
@app.route('/')
@login_required
def index() -> str:
def index() -> Response:
"""
Render main page with search and status table.
Serve the React frontend application.
Authentication is handled by the React app itself.
"""
return render_template('index.html', book_languages=_SUPPORTED_BOOK_LANGUAGE, default_language=BOOK_LANGUAGE, debug=DEBUG)
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
@@ -259,6 +365,38 @@ 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,
"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/health', methods=['GET'])
def api_health() -> Union[Response, Tuple[Response, int]]:
"""
Health check endpoint for container orchestration.
No authentication required.
Returns:
flask.Response: JSON with status "ok".
"""
return jsonify({"status": "ok"})
@app.route('/api/status', methods=['GET'])
@login_required
def api_status() -> Union[Response, Tuple[Response, int]]:
@@ -443,6 +581,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}")
@@ -476,59 +619,207 @@ def internal_error(error: Exception) -> Union[Response, Tuple[Response, int]]:
logger.error_trace(f"500 error: {error}")
return jsonify({"error": "Internal server error"}), 500
def authenticate() -> bool:
@app.route('/api/auth/login', methods=['POST'])
def api_login() -> Union[Response, Tuple[Response, int]]:
"""
Helper function that validates Basic credentials
against a Calibre-Web app.db SQLite database
Database structure:
- Table 'user' with columns: 'name' (username), 'password'
Login endpoint that validates credentials and creates a session.
Includes rate limiting: 10 failed attempts = 30 minute lockout.
Request Body:
username (str): Username
password (str): Password
remember_me (bool): Whether to extend session duration
Returns:
flask.Response: JSON with success status or error message.
"""
# If the database doesn't exist, the user is always authenticated
if not CWA_DB_PATH:
return True
# If no authorization object exists, return false to prompt
# a request to the user
if not request.authorization:
return False
username = request.authorization.get("username")
password = request.authorization.get("password")
# Validate credentials against database
try:
# Open database in true read-only mode to avoid journal/WAL writes on RO mounts
db_path = os.fspath(CWA_DB_PATH)
db_uri = f"file:{db_path}?mode=ro&immutable=1"
conn = sqlite3.connect(db_uri, uri=True)
cur = conn.cursor()
cur.execute("SELECT password FROM user WHERE name = ?", (username,))
row = cur.fetchone()
conn.close()
# Check if user exists and password is correct
if not row or not row[0] or not check_password_hash(row[0], password):
logger.error("User not found or password check failed")
return False
# Get client IP address (handles reverse proxy forwarding)
ip_address = request.headers.get('X-Forwarded-For', request.remote_addr)
if ip_address and ',' in ip_address:
# X-Forwarded-For can contain multiple IPs, take the first one
ip_address = ip_address.split(',')[0].strip()
data = request.get_json()
if not data:
return jsonify({"error": "No data provided"}), 400
username = data.get('username', '').strip()
password = data.get('password', '')
remember_me = data.get('remember_me', False)
if not username or not password:
return jsonify({"error": "Username and password are required"}), 400
# Check if account is locked due to failed login attempts
if is_account_locked(username):
lockout_until = failed_login_attempts[username].get('lockout_until')
remaining_time = (lockout_until - datetime.now()).total_seconds() / 60
logger.warning(f"Login attempt blocked for locked account '{username}' from IP {ip_address}")
return jsonify({
"error": f"Account temporarily locked due to multiple failed login attempts. Try again in {int(remaining_time)} minutes."
}), 429
# If the database doesn't exist, authentication always succeeds
if not CWA_DB_PATH:
session['user_id'] = username
session.permanent = remember_me
clear_failed_logins(username)
logger.info(f"Login successful for user '{username}' from IP {ip_address} (no DB configured)")
return jsonify({"success": True})
# If the CWA_DB_PATH variable exists, but isn't a valid path, return error
if not os.path.isfile(CWA_DB_PATH):
logger.error(f"CWA_DB_PATH is set to {CWA_DB_PATH} but this is not a valid path")
return jsonify({"error": "Database configuration error"}), 500
# Validate credentials against database
try:
db_path = os.fspath(CWA_DB_PATH)
db_uri = f"file:{db_path}?mode=ro&immutable=1"
conn = sqlite3.connect(db_uri, uri=True)
cur = conn.cursor()
cur.execute("SELECT password FROM user WHERE name = ?", (username,))
row = cur.fetchone()
conn.close()
# Check if user exists and password is correct
if not row or not row[0] or not check_password_hash(row[0], password):
# Record failed login attempt
is_now_locked = record_failed_login(username, ip_address)
if is_now_locked:
return jsonify({
"error": f"Account locked due to {MAX_LOGIN_ATTEMPTS} failed login attempts. Try again in {LOCKOUT_DURATION_MINUTES} minutes."
}), 429
else:
attempts_remaining = MAX_LOGIN_ATTEMPTS - failed_login_attempts[username]['count']
# Only show attempts remaining when 5 or fewer attempts remain (after 6+ failed attempts)
if attempts_remaining <= 5:
return jsonify({
"error": f"Invalid username or password. {attempts_remaining} attempts remaining."
}), 401
else:
return jsonify({
"error": "Invalid username or password."
}), 401
# Successful authentication - create session and clear failed attempts
session['user_id'] = username
session.permanent = remember_me
clear_failed_logins(username)
logger.info(f"Login successful for user '{username}' from IP {ip_address} (remember_me={remember_me})")
return jsonify({"success": True})
except Exception as e:
logger.error_trace(f"Database error during login: {e}")
return jsonify({"error": "Authentication system error"}), 500
except Exception as e:
logger.error_trace(f"CWA DB or authentication send_from_directory: {e}")
return False
logger.error_trace(f"Login error: {e}")
return jsonify({"error": "Login failed"}), 500
logger.info(f"Authentication successful for user {username}")
return True
@app.route('/api/auth/logout', methods=['POST'])
def api_logout() -> Union[Response, Tuple[Response, int]]:
"""
Logout endpoint that clears the session.
Returns:
flask.Response: JSON with success status.
"""
try:
# Get client IP address (handles reverse proxy forwarding)
ip_address = request.headers.get('X-Forwarded-For', request.remote_addr)
if ip_address and ',' in ip_address:
ip_address = ip_address.split(',')[0].strip()
username = session.get('user_id', 'unknown')
session.clear()
logger.info(f"Logout successful for user '{username}' from IP {ip_address}")
return jsonify({"success": True})
except Exception as e:
logger.error_trace(f"Logout error: {e}")
return jsonify({"error": "Logout failed"}), 500
# Register all routes with /request prefix
register_dual_routes(app)
@app.route('/api/auth/check', methods=['GET'])
def api_auth_check() -> Union[Response, Tuple[Response, int]]:
"""
Check if user has a valid session.
Returns:
flask.Response: JSON with authentication status and whether auth is required.
"""
try:
# If no database is configured, authentication is not required
if not CWA_DB_PATH:
return jsonify({
"authenticated": True,
"auth_required": False
})
# Check if user has a valid session
is_authenticated = 'user_id' in session
return jsonify({
"authenticated": is_authenticated,
"auth_required": True
})
except Exception as e:
logger.error_trace(f"Auth check error: {e}")
return jsonify({
"authenticated": False,
"auth_required": True
})
# Catch-all route for React Router (must be last)
# This handles client-side routing by serving index.html for any unmatched routes
@app.route('/<path:path>')
def catch_all(path: str) -> Response:
"""
Serve the React app for any route not matched by API endpoints.
This allows React Router to handle client-side routing.
Authentication is handled by the React app itself.
"""
# If the request is for an API endpoint or static file, let it 404
if path.startswith('api/') or path.startswith('assets/'):
return jsonify({"error": "Resource not found"}), 404
# Otherwise serve the React app
return send_from_directory(os.path.join(app.root_path, 'frontend-dist'), 'index.html')
# WebSocket event handlers
@socketio.on('connect')
def handle_connect():
"""Handle client connection."""
logger.info("WebSocket client connected")
# Send initial status to the newly connected client
try:
status = backend.queue_status()
emit('status_update', status)
except Exception as e:
logger.error(f"Error sending initial status: {e}")
@socketio.on('disconnect')
def handle_disconnect():
"""Handle client disconnection."""
logger.info("WebSocket client disconnected")
@socketio.on('request_status')
def handle_status_request():
"""Handle manual status request from client."""
try:
status = backend.queue_status()
emit('status_update', status)
except Exception as e:
logger.error(f"Error handling status request: {e}")
emit('error', {'message': 'Failed to get status'})
logger.log_resource_usage()
if __name__ == '__main__':
logger.info(f"Starting Flask application on {FLASK_HOST}:{FLASK_PORT} IN {APP_ENV} mode")
app.run(
logger.info(f"Starting Flask application with WebSocket support on {FLASK_HOST}:{FLASK_PORT} (debug={DEBUG})")
socketio.run(
app,
host=FLASK_HOST,
port=FLASK_PORT,
debug=DEBUG
debug=DEBUG,
allow_unsafe_werkzeug=True # For development only
)
+107 -11
View File
@@ -11,12 +11,20 @@ from threading import Event
from logger import setup_logger
from config import CUSTOM_SCRIPT
from env import INGEST_DIR, TMP_DIR, MAIN_LOOP_SLEEP_TIME, USE_BOOK_TITLE, MAX_CONCURRENT_DOWNLOADS, DOWNLOAD_PROGRESS_UPDATE_INTERVAL
from env import (INGEST_DIR, DOWNLOAD_PATHS, TMP_DIR, MAIN_LOOP_SLEEP_TIME, USE_BOOK_TITLE,
MAX_CONCURRENT_DOWNLOADS, DOWNLOAD_PROGRESS_UPDATE_INTERVAL)
from models import book_queue, BookInfo, QueueStatus, SearchFilters
import book_manager
logger = setup_logger(__name__)
# Import WebSocket manager (will be initialized by app.py)
try:
from websocket_manager import ws_manager
except ImportError:
logger.warning("WebSocket manager not available")
ws_manager = None
def _sanitize_filename(filename: str) -> str:
"""Sanitize a filename by replacing spaces with underscores and removing invalid characters."""
keepcharacters = (' ','.','_')
@@ -69,6 +77,11 @@ def queue_book(book_id: str, priority: int = 0) -> bool:
book_info = book_manager.get_book_info(book_id)
book_queue.add(book_id, book_info, priority)
logger.info(f"Book queued with priority {priority}: {book_info.title}")
# Broadcast status update via WebSocket
if ws_manager and ws_manager.is_enabled():
ws_manager.broadcast_status_update(queue_status())
return True
except Exception as e:
logger.error_trace(f"Error queueing book: {e}")
@@ -78,12 +91,21 @@ 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()
# Convert Enum keys to strings and properly format the response
for _, books in status.items():
for _, book_info in books.items():
if book_info.download_path:
if not os.path.exists(book_info.download_path):
book_info.download_path = None
# 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()
}
@@ -114,6 +136,13 @@ def _book_info_to_dict(book: BookInfo) -> Dict[str, Any]:
if value is not None
}
def _prepare_download_folder(book_info: BookInfo) -> Path:
"""Prepare final content-type subdir"""
content = book_info.content
content_dir = DOWNLOAD_PATHS.get(content) if content and content in DOWNLOAD_PATHS else INGEST_DIR
os.makedirs(content_dir, exist_ok=True)
return content_dir
def _download_book_with_cancellation(book_id: str, cancel_flag: Event) -> Optional[str]:
"""Download and process a book with cancellation support.
@@ -137,6 +166,9 @@ def _download_book_with_cancellation(book_id: str, cancel_flag: Event) -> Option
book_name = _sanitize_filename(book_info.title)
else:
book_name = book_id
# If format is not set, use the format of the first download URL
if book_info.format == "":
book_info.format = book_info.download_urls[0].split(".")[-1]
book_name += f".{book_info.format}"
book_path = TMP_DIR / book_name
@@ -146,7 +178,8 @@ def _download_book_with_cancellation(book_id: str, cancel_flag: Event) -> Option
return None
progress_callback = lambda progress: update_download_progress(book_id, progress)
success = book_manager.download_book(book_info, book_path, progress_callback, cancel_flag)
status_callback = lambda status: update_download_status(book_id, status)
success_download_url = book_manager.download_book(book_info, book_path, progress_callback, cancel_flag, status_callback)
# Stop progress updates
cancel_flag.wait(0.1) # Brief pause for progress thread cleanup
@@ -158,7 +191,7 @@ def _download_book_with_cancellation(book_id: str, cancel_flag: Event) -> Option
book_path.unlink()
return None
if not success:
if not success_download_url:
raise Exception("Unknown error downloading book")
# Check cancellation before post-processing
@@ -168,12 +201,28 @@ def _download_book_with_cancellation(book_id: str, cancel_flag: Event) -> Option
book_path.unlink()
return None
# Update status to verifying
book_queue.update_status(book_id, QueueStatus.VERIFYING)
if ws_manager and ws_manager.is_enabled():
ws_manager.broadcast_status_update(queue_status())
logger.info(f"Verifying download: {book_info.title}")
if CUSTOM_SCRIPT:
logger.info(f"Running custom script: {CUSTOM_SCRIPT}")
subprocess.run([CUSTOM_SCRIPT, book_path])
intermediate_path = INGEST_DIR / f"{book_id}.crdownload"
final_path = INGEST_DIR / book_name
# Update status to ingesting
book_queue.update_status(book_id, QueueStatus.INGESTING)
if ws_manager and ws_manager.is_enabled():
ws_manager.broadcast_status_update(queue_status())
if success_download_url and book_info.format == "":
book_info.format = success_download_url.split(".")[-1]
book_name += f".{book_info.format}"
final_dir = _prepare_download_folder(book_info)
intermediate_path = final_dir / f"{book_id}.crdownload"
final_path = final_dir / book_name
if os.path.exists(book_path):
logger.info(f"Moving book to ingest directory: {book_path} -> {final_path}")
@@ -209,6 +258,35 @@ def _download_book_with_cancellation(book_id: str, cancel_flag: Event) -> Option
def update_download_progress(book_id: str, progress: float) -> None:
"""Update download progress."""
book_queue.update_progress(book_id, progress)
# Broadcast progress via WebSocket
if ws_manager and ws_manager.is_enabled():
ws_manager.broadcast_download_progress(book_id, progress, 'downloading')
def update_download_status(book_id: str, status: str) -> None:
"""Update download status."""
# Map string status to QueueStatus enum
status_map = {
'queued': QueueStatus.QUEUED,
'resolving': QueueStatus.RESOLVING,
'bypassing': QueueStatus.BYPASSING,
'downloading': QueueStatus.DOWNLOADING,
'verifying': QueueStatus.VERIFYING,
'ingesting': QueueStatus.INGESTING,
'complete': QueueStatus.COMPLETE,
'available': QueueStatus.AVAILABLE,
'error': QueueStatus.ERROR,
'done': QueueStatus.DONE,
'cancelled': QueueStatus.CANCELLED,
}
queue_status_enum = status_map.get(status.lower())
if queue_status_enum:
book_queue.update_status(book_id, queue_status_enum)
# Broadcast status update via WebSocket
if ws_manager and ws_manager.is_enabled():
ws_manager.broadcast_status_update(queue_status())
def cancel_download(book_id: str) -> bool:
"""Cancel a download.
@@ -219,7 +297,13 @@ def cancel_download(book_id: str) -> bool:
Returns:
bool: True if cancellation was successful
"""
return book_queue.cancel_download(book_id)
result = book_queue.cancel_download(book_id)
# Broadcast status update via WebSocket
if result and ws_manager and ws_manager.is_enabled():
ws_manager.broadcast_status_update(queue_status())
return result
def set_book_priority(book_id: str, priority: int) -> bool:
"""Set priority for a queued book.
@@ -259,21 +343,29 @@ def clear_completed() -> int:
def _process_single_download(book_id: str, cancel_flag: Event) -> None:
"""Process a single download job."""
try:
book_queue.update_status(book_id, QueueStatus.DOWNLOADING)
# Status will be updated through callbacks during download process
# (resolving -> bypassing -> downloading -> verifying -> ingesting -> complete)
download_path = _download_book_with_cancellation(book_id, cancel_flag)
if cancel_flag.is_set():
book_queue.update_status(book_id, QueueStatus.CANCELLED)
# Broadcast cancellation
if ws_manager and ws_manager.is_enabled():
ws_manager.broadcast_status_update(queue_status())
return
if download_path:
book_queue.update_download_path(book_id, download_path)
new_status = QueueStatus.AVAILABLE
new_status = QueueStatus.COMPLETE
else:
new_status = QueueStatus.ERROR
book_queue.update_status(book_id, new_status)
# Broadcast final status (completed or error)
if ws_manager and ws_manager.is_enabled():
ws_manager.broadcast_status_update(queue_status())
logger.info(
f"Book {book_id} download {'successful' if download_path else 'failed'}"
)
@@ -285,6 +377,10 @@ def _process_single_download(book_id: str, cancel_flag: Event) -> None:
else:
logger.info(f"Download cancelled: {book_id}")
book_queue.update_status(book_id, QueueStatus.CANCELLED)
# Broadcast error/cancelled status
if ws_manager and ws_manager.is_enabled():
ws_manager.broadcast_status_update(queue_status())
def concurrent_download_loop() -> None:
"""Main download coordinator using ThreadPoolExecutor for concurrent downloads."""
+129 -20
View File
@@ -1,6 +1,6 @@
"""Book download manager handling search and retrieval operations."""
import time, json, re
import time, json, os, re
from pathlib import Path
from urllib.parse import quote
from typing import List, Optional, Dict, Union, Callable
@@ -10,7 +10,7 @@ from bs4 import BeautifulSoup, Tag, NavigableString, ResultSet
import downloader
from logger import setup_logger
from config import SUPPORTED_FORMATS, BOOK_LANGUAGE, AA_BASE_URL
from env import AA_DONATOR_KEY, USE_CF_BYPASS, PRIORITIZE_WELIB
from env import AA_DONATOR_KEY, USE_CF_BYPASS, PRIORITIZE_WELIB, ALLOW_USE_WELIB, DOWNLOAD_PATHS
from models import BookInfo, SearchFilters
logger = setup_logger(__name__)
@@ -110,6 +110,9 @@ def search_books(query: str, filters: SearchFilters) -> List[BookInfo]:
def _parse_search_result_row(row: Tag) -> Optional[BookInfo]:
"""Parse a single search result row into a BookInfo object."""
try:
# Skip ad rows
if row.text.strip().lower().startswith("your ad here"):
return None
cells = row.find_all("td")
preview_img = cells[0].find("img")
preview = preview_img["src"] if preview_img else None
@@ -122,6 +125,7 @@ def _parse_search_result_row(row: Tag) -> Optional[BookInfo]:
publisher=cells[3].find("span").next,
year=cells[4].find("span").next,
language=cells[7].find("span").next,
content=cells[8].find("span").next.lower(),
format=cells[9].find("span").next.lower(),
size=cells[10].find("span").next,
)
@@ -169,8 +173,6 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
data = soup.find_all("div", {"class": "main-inner"})[0].find_next("div")
divs = list(data.children)
format = divs[13].text.split(" · ")[-6].strip().lower()
size = divs[13].text.split(" · ")[-5].strip().lower()
every_url = soup.find_all("a")
slow_urls_no_waitlist = set()
@@ -224,20 +226,55 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
# Remove empty urls
urls = [url for url in urls if url != ""]
# Filter out divs that are not text
original_divs = divs
divs = [div for div in divs if div.text.strip() != ""]
all_details = _find_in_divs(divs, " · ")
format = ""
size = ""
content = ""
for _details in all_details:
_details = _details.split(" · ")
for f in _details:
if format == "" and f.strip().lower() in SUPPORTED_FORMATS:
format = f.strip().lower()
if size == "" and any(u in f.strip().lower() for u in ["mb", "kb", "gb"]):
size = f.strip().lower()
if content == "":
for ct in DOWNLOAD_PATHS.keys():
if ct in f.strip().lower():
content = ct
break
if format == "" or size == "":
for f in _details:
stripped = f.strip().lower()
if format == "" and stripped and " " not in stripped:
format = stripped
if size == "" and "." in stripped:
size = stripped
book_title = _find_in_divs(divs, "🔍")[0].strip("🔍").strip()
# Extract basic information
description = _extract_book_description(soup)
book_info = BookInfo(
id=book_id,
preview=preview,
title=divs[7].next.strip(),
publisher=divs[11].text.strip(),
author=divs[9].text.strip(),
title=book_title,
content=content,
publisher=_find_in_divs(divs, "icon-[mdi--company]", isClass=True)[0],
author=_find_in_divs(divs, "icon-[mdi--user-edit]", isClass=True)[0],
format=format,
size=size,
description=description,
download_urls=urls,
)
# Extract additional metadata
info = _extract_book_metadata(divs[-6])
info = _extract_book_metadata(original_divs[-6])
book_info.info = info
# Set language and year from metadata if available
@@ -246,9 +283,26 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
if info.get("Year"):
book_info.year = info["Year"][0]
# TODO :
# Backfill missing metadata from original book
# To do this, we need to cache the results of search_books() in some kind of LRU
return book_info
def _find_in_divs(divs: List[str], text: str, isClass: bool = False) -> List[str]:
divs_found = []
for div in divs:
if isClass:
if div.find(class_ = text):
divs_found.append(div.text.strip())
else:
if text in div.text.strip():
divs_found.append(div.text.strip())
return divs_found
def _get_download_urls_from_welib(book_id: str) -> set[str]:
if ALLOW_USE_WELIB == False:
return set()
"""Get download urls from welib.org."""
url = f"https://welib.org/md5/{book_id}"
logger.info(f"Getting download urls from welib.org for {book_id}. While this uses the bypasser, it will not start downloading them yet.")
@@ -262,9 +316,53 @@ def _get_download_urls_from_welib(book_id: str) -> set[str]:
download_links = [downloader.get_absolute_url(url, link) for link in download_links]
return set(download_links)
def _extract_book_metadata(
metadata_divs
) -> Dict[str, List[str]]:
def _get_next_value_div(label_div: Tag) -> Optional[Tag]:
"""Find the next sibling div that holds the value for a metadata label."""
sibling = label_div.next_sibling
while sibling:
if isinstance(sibling, Tag) and sibling.name == "div":
return sibling
sibling = sibling.next_sibling
return None
def _extract_book_description(soup: BeautifulSoup) -> Optional[str]:
"""Extract the primary or alternative description from the book page."""
container = soup.select_one(".js-md5-top-box-description")
if not container:
return None
description: Optional[str] = None
alternative: Optional[str] = None
label_divs = container.select("div.text-xs.text-gray-500.uppercase")
for label_div in label_divs:
label_text = label_div.get_text(strip=True).lower()
value_div = _get_next_value_div(label_div)
if not value_div:
continue
value_text = value_div.get_text(separator=" ", strip=True)
if not value_text:
continue
if label_text == "description":
return value_text
if label_text == "alternative description" and not alternative:
alternative = value_text
if alternative:
return alternative
# Fallback to the first text block inside the description container
fallback_div = container.find("div", class_="mb-1")
if fallback_div:
fallback_text = fallback_div.get_text(separator=" ", strip=True)
if fallback_text:
return fallback_text
return None
def _extract_book_metadata(metadata_divs) -> Dict[str, List[str]]:
"""Extract metadata from book info divs."""
info: Dict[str, List[str]] = {}
@@ -302,15 +400,18 @@ def _extract_book_metadata(
}
def download_book(book_info: BookInfo, book_path: Path, progress_callback: Optional[Callable[[float], None]] = None, cancel_flag: Optional[Event] = None) -> bool:
def download_book(book_info: BookInfo, book_path: Path, progress_callback: Optional[Callable[[float], None]] = None, cancel_flag: Optional[Event] = None, status_callback: Optional[Callable[[str], None]] = None) -> Optional[str]:
"""Download a book from available sources.
Args:
book_id: Book identifier (MD5 hash)
title: Book title for logging
progress_callback: Optional callback for download progress updates
cancel_flag: Optional cancellation flag
status_callback: Optional callback for status updates
Returns:
Optional[BytesIO]: Book content buffer if successful
str: Download URL if successful, None otherwise
"""
if len(book_info.download_urls) == 0:
@@ -326,8 +427,16 @@ def download_book(book_info: BookInfo, book_path: Path, progress_callback: Optio
for link in download_links:
try:
download_url = _get_download_url(link, book_info.title, cancel_flag)
# Update status to resolving before attempting download URL fetch
if status_callback:
status_callback("resolving")
download_url = _get_download_url(link, book_info.title, cancel_flag, status_callback)
if download_url != "":
# Update status to downloading before starting actual download
if status_callback:
status_callback("downloading")
logger.info(f"Downloading `{book_info.title}` from `{download_url}`")
data = downloader.download_url(download_url, book_info.size or "", progress_callback, cancel_flag)
@@ -338,25 +447,25 @@ def download_book(book_info: BookInfo, book_path: Path, progress_callback: Optio
with open(book_path, "wb") as f:
f.write(data.getbuffer())
logger.info(f"Writing `{book_info.title}` successfully")
return True
return download_url
except Exception as e:
logger.error_trace(f"Failed to download from {link}: {e}")
continue
return False
return None
def _get_download_url(link: str, title: str, cancel_flag: Optional[Event] = None) -> str:
def _get_download_url(link: str, title: str, cancel_flag: Optional[Event] = None, status_callback: Optional[Callable[[str], None]] = None) -> str:
"""Extract actual download URL from various source pages."""
url = ""
if link.startswith(f"{AA_BASE_URL}/dyn/api/fast_download.json"):
page = downloader.html_get_page(link)
page = downloader.html_get_page(link, status_callback=status_callback)
url = json.loads(page).get("download_url")
else:
html = downloader.html_get_page(link)
html = downloader.html_get_page(link, status_callback=status_callback)
if html == "":
return ""
@@ -377,7 +486,7 @@ def _get_download_url(link: str, title: str, cancel_flag: Optional[Event] = None
if cancel_flag is not None and cancel_flag.wait(timeout=sleep_time):
logger.info(f"Cancelled wait for {title}")
return ""
url = _get_download_url(link, title, cancel_flag)
url = _get_download_url(link, title, cancel_flag, status_callback)
else:
url = download_links[0]["href"]
else:
-1
View File
@@ -9,7 +9,6 @@ services:
target: cwa-bd
environment:
DEBUG: true
APP_ENV: dev
USE_DOH: true
CUSTOM_DNS: cloudflare
volumes:
-1
View File
@@ -10,7 +10,6 @@ services:
target: cwa-bd-extbp
environment:
DEBUG: true
APP_ENV: dev
USE_DOH: true
CUSTOM_DNS: cloudflare
USE_CF_BYPASS: true # Enable Cloudflare bypass (default: true)
+4 -2
View File
@@ -7,9 +7,11 @@ services:
BOOK_LANGUAGE: en
USE_BOOK_TITLE: true
TZ: America/New_York
APP_ENV: prod
UID: 1000
GID: 100
# CWA_DB_PATH: /auth/app.db # Uncomment to enable authentication
# SESSION_COOKIE_SECURE: 'true' # Set to 'true' if accessing ONLY via HTTPS
# DEBUG: 'true' # Enable debug mode (debug button, verbose logging)
EXT_BYPASSER_URL: http://flaresolverr:8191
ports:
- 8084:8084
@@ -19,7 +21,7 @@ services:
# the same as whatever you gave in "calibre-web-automated"
- /tmp/data/calibre-web/ingest:/cwa-book-ingest
# This is the location of CWA's app.db, which contains authentication
# details
# details. Uncomment to enable authentication (also uncomment CWA_DB_PATH above)
#- /cwa/config/path/app.db:/auth/app.db:ro
flaresolverr:
image: ghcr.io/flaresolverr/flaresolverr:latest
-1
View File
@@ -9,7 +9,6 @@ services:
target: cwa-bd-tor
environment:
DEBUG: true
APP_ENV: dev
volumes:
- /tmp/cwa-book-downloader:/tmp/cwa-book-downloader
- /tmp/cwa-book-downloader-log:/var/log/cwa-book-downloader
+8 -3
View File
@@ -8,7 +8,9 @@ services:
USE_BOOK_TITLE: true
TZ: America/New_York
USING_TOR: true
APP_ENV: prod
# CWA_DB_PATH: /auth/app.db # Uncomment to enable authentication
# SESSION_COOKIE_SECURE: 'true' # Set to 'true' if accessing ONLY via HTTPS
# DEBUG: 'true' # Enable debug mode (debug button, verbose logging)
cap_add:
- NET_ADMIN
- NET_RAW
@@ -16,6 +18,9 @@ services:
- 8084:8084
restart: unless-stopped
volumes:
# This is where the books will be downloaded to, usually it would be
# the same as whatever you gave in "calibre-web-automated"
# This is where the books will be downloaded to, usually it would be
# the same as whatever you gave in "calibre-web-automated"
- /tmp/data/calibre-web/ingest:/cwa-book-ingest
# This is the location of CWA's app.db, which contains authentication
# details. Uncomment to enable authentication (also uncomment CWA_DB_PATH above)
#- /cwa/config/path/app.db:/auth/app.db:ro
+5 -3
View File
@@ -11,10 +11,12 @@ services:
BOOK_LANGUAGE: en
USE_BOOK_TITLE: true
TZ: America/New_York
APP_ENV: prod
UID: 1000
GID: 100
# CWA_DB_PATH: /auth/app.db # Comment out to disable authentication
# CWA_DB_PATH: /auth/app.db # Uncomment to enable authentication (also uncomment volume below)
# CALIBRE_WEB_URL: http://localhost:8080 # Uncomment and add your custom library URL to enable "Go To Library" button in the Web UI
# SESSION_COOKIE_SECURE: 'true' # Set to 'true' if accessing ONLY via HTTPS
# DEBUG: 'true' # Enable debug mode (debug button, verbose logging)
# Queue management settings
MAX_CONCURRENT_DOWNLOADS: 3
DOWNLOAD_PROGRESS_UPDATE_INTERVAL: 5
@@ -26,5 +28,5 @@ services:
# the same as whatever you gave in "calibre-web-automated"
- /tmp/data/calibre-web/ingest:/cwa-book-ingest
# This is the location of CWA's app.db, which contains authentication
# details. Comment out to disable authentication
# details. Uncomment to enable authentication (also uncomment CWA_DB_PATH above)
#- /cwa/config/path/app.db:/auth/app.db:ro
+7 -4
View File
@@ -22,13 +22,14 @@ if USE_CF_BYPASS:
logger = setup_logger(__name__)
def html_get_page(url: str, retry: int = MAX_RETRY, use_bypasser: bool = False) -> str:
def html_get_page(url: str, retry: int = MAX_RETRY, use_bypasser: bool = False, status_callback: Optional[Callable[[str], None]] = None) -> str:
"""Fetch HTML content from a URL with retry mechanism.
Args:
url: Target URL
retry: Number of retry attempts
skip_404: Whether to skip 404 errors
use_bypasser: Whether to use Cloudflare bypasser
status_callback: Optional callback for status updates
Returns:
str: HTML content if successful, None otherwise
@@ -37,6 +38,8 @@ def html_get_page(url: str, retry: int = MAX_RETRY, use_bypasser: bool = False)
try:
logger.debug(f"html_get_page: {url}, retry: {retry}, use_bypasser: {use_bypasser}")
if use_bypasser and USE_CF_BYPASS:
if status_callback:
status_callback("bypassing")
logger.info(f"GET Using Cloudflare Bypasser for: {url}")
return get_bypassed_page(url)
else:
@@ -61,14 +64,14 @@ def html_get_page(url: str, retry: int = MAX_RETRY, use_bypasser: bool = False)
return ""
elif response is not None and response.status_code == 403:
logger.warning(f"403 detected for URL: {url}. Should retry using cloudflare bypass.")
return html_get_page(url, retry - 1, True)
return html_get_page(url, retry - 1, True, status_callback)
sleep_time = DEFAULT_SLEEP * (MAX_RETRY - retry + 1)
logger.warning(
f"Retrying GET {url} in {sleep_time} seconds due to error: {e}"
)
time.sleep(sleep_time)
return html_get_page(url, retry - 1, use_bypasser)
return html_get_page(url, retry - 1, use_bypasser, status_callback)
def download_url(link: str, size: str = "", progress_callback: Optional[Callable[[float], None]] = None, cancel_flag: Optional[Event] = None) -> Optional[BytesIO]:
"""Download content from URL into a BytesIO buffer.
+12 -6
View File
@@ -20,6 +20,7 @@ set -e
# Print build version
echo "Build version: $BUILD_VERSION"
echo "Release version: $RELEASE_VERSION"
# Configure timezone
if [ "$TZ" ]; then
@@ -104,12 +105,17 @@ change_ownership /tmp/cwa-book-downloader
# Test write to all folders
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"
else
# Set the command to run based on DEBUG setting
# DEBUG=true uses Flask dev server, otherwise uses gunicorn for production
is_debug=$(echo "$DEBUG" | tr '[:upper:]' '[:lower:]')
if [ "$is_debug" = "true" ]; then
command="python3 app.py"
else
# 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"
fi
# If DEBUG and not using an external bypass
@@ -174,7 +180,7 @@ sum=$(python3 -c "print(sum(int(l.strip()) for l in open('/tmp/test.cwa-bd').rea
[ "$sum" == 11250075000 ] && echo "Success: /tmp is writable" || (echo "Failure: /tmp is not writable" && exit 1)
rm /tmp/test.cwa-bd
echo "Running command: '$command' as '$USERNAME' in '$APP_ENV' mode"
echo "Running command: '$command' as '$USERNAME' (debug=$is_debug)"
# Stop logging
exec 1>&3 2>&4
+33 -1
View File
@@ -4,12 +4,36 @@ from pathlib import Path
def string_to_bool(s: str) -> bool:
return s.lower() in ["true", "yes", "1", "y"]
# Authentication and session settings
SESSION_COOKIE_SECURE_ENV = os.getenv("SESSION_COOKIE_SECURE", "false")
CWA_DB = os.getenv("CWA_DB_PATH")
CWA_DB_PATH = Path(CWA_DB) if CWA_DB else None
LOG_ROOT = Path(os.getenv("LOG_ROOT", "/var/log/"))
LOG_DIR = LOG_ROOT / "cwa-book-downloader"
TMP_DIR = Path(os.getenv("TMP_DIR", "/tmp/cwa-book-downloader"))
INGEST_DIR = Path(os.getenv("INGEST_DIR", "/cwa-book-ingest"))
INGEST_DIR_BOOK_FICTION = os.getenv("INGEST_DIR_BOOK_FICTION", "")
INGEST_DIR_BOOK_NON_FICTION = os.getenv("INGEST_DIR_BOOK_NON_FICTION", "")
INGEST_DIR_BOOK_UNKNOWN = os.getenv("INGEST_DIR_BOOK_UNKNOWN", "")
INGEST_DIR_MAGAZINE = os.getenv("INGEST_DIR_MAGAZINE", "")
INGEST_DIR_COMIC_BOOK = os.getenv("INGEST_DIR_COMIC_BOOK", "")
INGEST_DIR_AUDIOBOOK = os.getenv("INGEST_DIR_AUDIOBOOK", "")
INGEST_DIR_STANDARDS_DOCUMENT = os.getenv("INGEST_DIR_STANDARDS_DOCUMENT", "")
INGEST_DIR_MUSICAL_SCORE = os.getenv("INGEST_DIR_MUSICAL_SCORE", "")
INGEST_DIR_OTHER = os.getenv("INGEST_DIR_OTHER", "")
DOWNLOAD_PATHS = {
"book (fiction)": Path(INGEST_DIR_BOOK_FICTION) if INGEST_DIR_BOOK_FICTION else INGEST_DIR,
"book (non-fiction)": Path(INGEST_DIR_BOOK_NON_FICTION) if INGEST_DIR_BOOK_NON_FICTION else INGEST_DIR,
"book (unknown)": Path(INGEST_DIR_BOOK_UNKNOWN) if INGEST_DIR_BOOK_UNKNOWN else INGEST_DIR,
"magazine": Path(INGEST_DIR_MAGAZINE) if INGEST_DIR_MAGAZINE else INGEST_DIR,
"comic book": Path(INGEST_DIR_COMIC_BOOK) if INGEST_DIR_COMIC_BOOK else INGEST_DIR,
"audiobook": Path(INGEST_DIR_AUDIOBOOK) if INGEST_DIR_AUDIOBOOK else INGEST_DIR,
"standards document": Path(INGEST_DIR_STANDARDS_DOCUMENT) if INGEST_DIR_STANDARDS_DOCUMENT else INGEST_DIR,
"musical score": Path(INGEST_DIR_MUSICAL_SCORE) if INGEST_DIR_MUSICAL_SCORE else INGEST_DIR,
"other": Path(INGEST_DIR_OTHER) if INGEST_DIR_OTHER else INGEST_DIR,
}
STATUS_TIMEOUT = int(os.getenv("STATUS_TIMEOUT", "3600"))
USE_BOOK_TITLE = string_to_bool(os.getenv("USE_BOOK_TITLE", "false"))
MAX_RETRY = int(os.getenv("MAX_RETRY", "10"))
@@ -27,6 +51,11 @@ FLASK_HOST = os.getenv("FLASK_HOST", "0.0.0.0")
FLASK_PORT = int(os.getenv("FLASK_PORT", "8084"))
DEBUG = string_to_bool(os.getenv("DEBUG", "false"))
PRIORITIZE_WELIB = string_to_bool(os.getenv("PRIORITIZE_WELIB", "false"))
ALLOW_USE_WELIB = string_to_bool(os.getenv("ALLOW_USE_WELIB", "true"))
# Version information from Docker build
BUILD_VERSION = os.getenv("BUILD_VERSION", "N/A")
RELEASE_VERSION = os.getenv("RELEASE_VERSION", "N/A")
# If debug is true, we want to log everything
if DEBUG:
@@ -41,7 +70,7 @@ DOCKERMODE = string_to_bool(os.getenv("DOCKERMODE", "false"))
_CUSTOM_DNS = os.getenv("CUSTOM_DNS", "").strip()
USE_DOH = string_to_bool(os.getenv("USE_DOH", "false"))
BYPASS_RELEASE_INACTIVE_MIN = int(os.getenv("BYPASS_RELEASE_INACTIVE_MIN", "5"))
APP_ENV = os.getenv("APP_ENV", "prod").lower()
# Logging settings
LOG_FILE = LOG_DIR / "cwa-book-downloader.log"
@@ -58,4 +87,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()
+5 -5
View File
@@ -3,7 +3,7 @@
# Set up log paths
LOG_ROOT=${LOG_ROOT:-"/var/log"}
LOG_DIR="$LOG_ROOT/cwa-book-downloader"
OUTPUT_FILE_NAME="cwa-book-downloader-debug_BUILD-${BUILD_VERSION:-local}_$(date +%Y%m%d-%H%M%S)"
OUTPUT_FILE_NAME="cwa-book-downloader-debug_BUILD-${BUILD_VERSION:-local}_RELEASE-${RELEASE_VERSION:-NA}_$(date +%Y%m%d-%H%M%S)"
OUTPUT_FILE="/tmp/$OUTPUT_FILE_NAME.zip"
# Create LOG_DIR if it doesn't exist
@@ -125,16 +125,16 @@ fi
env | grep -v -E "(AA_DONATOR_KEY)" | sort > "$LOG_DIR/environment.txt"
echo "--- HTTPBin ---" > $LOG_DIR/network_info.txt
pyrequests https://httpbin.org/get >> $LOG_DIR/network_info.txt
curl -s https://httpbin.org/get >> $LOG_DIR/network_info.txt
ehco ""
echo "--- HowsMySSL ---" >> $LOG_DIR/network_info.txt
pyrequests https://www.howsmyssl.com/a/check >> $LOG_DIR/network_info.txt
curl -s https://www.howsmyssl.com/a/check >> $LOG_DIR/network_info.txt
ehco ""
echo "--- IPInfo ---" >> $LOG_DIR/network_info.txt
pyrequests https://ipinfo.io >> $LOG_DIR/network_info.txt
curl -s https://ipinfo.io >> $LOG_DIR/network_info.txt
ehco ""
echo "--- Cloudflare Trace ---" >> $LOG_DIR/network_info.txt
pyrequests https://1.1.1.1/cdn-cgi/trace >> $LOG_DIR/network_info.txt
curl -s https://1.1.1.1/cdn-cgi/trace >> $LOG_DIR/network_info.txt
# Create the zip file directly from LOG_DIR
ln -s "$LOG_DIR" /tmp/$OUTPUT_FILE_NAME
+4
View File
@@ -13,21 +13,25 @@ class CustomLogger(logging.Logger):
def error_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
"""Log an error message with full stack trace."""
self.log_resource_usage()
kwargs.pop('exc_info', None)
self.error(msg, *args, exc_info=True, **kwargs)
def warning_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
"""Log a warning message with full stack trace."""
self.log_resource_usage()
kwargs.pop('exc_info', None)
self.warning(msg, *args, exc_info=True, **kwargs)
def info_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
"""Log an info message with full stack trace."""
self.log_resource_usage()
kwargs.pop('exc_info', None)
self.info(msg, *args, exc_info=True, **kwargs)
def debug_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
"""Log a debug message with full stack trace."""
self.log_resource_usage()
kwargs.pop('exc_info', None)
self.debug(msg, *args, exc_info=True, **kwargs)
def log_resource_usage(self):
+12 -4
View File
@@ -13,7 +13,12 @@ from env import INGEST_DIR, STATUS_TIMEOUT
class QueueStatus(str, Enum):
"""Enum for possible book queue statuses."""
QUEUED = "queued"
RESOLVING = "resolving"
BYPASSING = "bypassing"
DOWNLOADING = "downloading"
VERIFYING = "verifying"
INGESTING = "ingesting"
COMPLETE = "complete"
AVAILABLE = "available"
ERROR = "error"
DONE = "done"
@@ -42,9 +47,11 @@ class BookInfo:
publisher: Optional[str] = None
year: Optional[str] = None
language: Optional[str] = None
content: Optional[str] = None
format: Optional[str] = None
size: Optional[str] = None
info: Optional[Dict[str, List[str]]] = None
description: Optional[str] = None
download_urls: List[str] = field(default_factory=list)
download_path: Optional[str] = None
priority: int = 0
@@ -116,7 +123,7 @@ class BookQueue:
self._update_status(book_id, status)
# Clean up active download tracking when finished
if status in [QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.DONE, QueueStatus.CANCELLED]:
if status in [QueueStatus.COMPLETE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.DONE, QueueStatus.CANCELLED]:
self._active_downloads.pop(book_id, None)
self._cancel_flags.pop(book_id, None)
@@ -184,7 +191,8 @@ class BookQueue:
with self._lock:
current_status = self._status.get(book_id)
if current_status == QueueStatus.DOWNLOADING:
# Allow cancellation during any active state
if current_status in [QueueStatus.RESOLVING, QueueStatus.BYPASSING, QueueStatus.DOWNLOADING, QueueStatus.VERIFYING, QueueStatus.INGESTING]:
# Signal active download to stop
if book_id in self._cancel_flags:
self._cancel_flags[book_id].set()
@@ -283,7 +291,7 @@ class BookQueue:
with self._lock:
to_remove = []
for book_id, status in self._status.items():
if status in [QueueStatus.DONE, QueueStatus.ERROR, QueueStatus.CANCELLED]:
if status in [QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.CANCELLED]:
to_remove.append(book_id)
removed_count = len(to_remove)
@@ -318,7 +326,7 @@ class BookQueue:
# Check for stale status entries
last_update = self._status_timestamps.get(book_id)
if last_update and (current_time - last_update) > self._status_timeout:
if status in [QueueStatus.DONE, QueueStatus.ERROR, QueueStatus.AVAILABLE, QueueStatus.CANCELLED]:
if status in [QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.ERROR, QueueStatus.AVAILABLE, QueueStatus.CANCELLED]:
to_remove.append(book_id)
# Remove stale entries
+50 -5
View File
@@ -1,6 +1,6 @@
# 📚 Calibre-Web-Automated-Book-Downloader
![Calibre-Web Automated Book Downloader](static/media/logo.png 'Calibre-Web Automated Book Downloader')
![Calibre-Web Automated Book Downloader](src/frontend/public/logo.png 'Calibre-Web Automated Book Downloader')
An intuitive web interface for searching and requesting book downloads, designed to work seamlessly with [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated). This project streamlines the process of downloading books and preparing them for integration into your Calibre library.
@@ -63,10 +63,14 @@ An intuitive web interface for searching and requesting book downloads, designed
| `CWA_DB_PATH` | Calibre-Web's database | None |
| `ENABLE_LOGGING` | Enable log file | `true` |
| `LOG_LEVEL` | Log level to use | `info` |
| `SESSION_COOKIE_SECURE` | Secure cookie enforcement - Use for HTTPS connections only | `false` |
| `CALIBRE_WEB_URL` | Custom WebUI library link | None |
If you wish to enable authentication, you must set `CWA_DB_PATH` to point to Calibre-Web's `app.db`, in order to match the username and password.
If logging is enabld, log folder default location is `/var/log/cwa-book-downloader`
Set `CALIBRE_WEB_URL` to your Calibre-Web / Booklore base URL. A ‘Go to library’ button will appear in the Web UI for quick access while downloading, and it also provides library access when CWA-BD is installed as a mobile PWA.
If logging is enabled, log folder default location is `/var/log/cwa-book-downloader`
Available log levels: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`. Higher levels show fewer messages.
Note that if using TOR, the TZ will be calculated automatically based on IP.
@@ -83,9 +87,34 @@ Note that if using TOR, the TZ will be calculated automatically based on IP.
| `AA_DONATOR_KEY` | Optional Donator key for Anna's Archive fast download API | `` |
| `USE_BOOK_TITLE` | Use book title as filename instead of ID | `false` |
| `PRIORITIZE_WELIB` | When downloading, download from WELIB first instead of AA | `false` |
| `ALLOW_USE_WELIB` | Allow usage of welib for downloading books if found there | `true` |
If you change `BOOK_LANGUAGE`, you can add multiple comma separated languages, such as `en,fr,ru` etc.
Use the following environment variables to set specific folders in which to download
different content types (Book, Magazine, Comic, etc.):
| Variable | Description | Default Value |
|---------------------------------|--------------------------------|---------------|
| `INGEST_DIR_BOOK_FICTION` | Book (fiction) folder name | `` |
| `INGEST_DIR_BOOK_NON_FICTION` | Book (non-fiction) folder name | `` |
| `INGEST_DIR_BOOK_UNKNOWN` | Book (unknown) folder name | `` |
| `INGEST_DIR_MAGAZINE` | Magazine folder name | `` |
| `INGEST_DIR_COMIC_BOOK` | Comic book folder name | `` |
| `INGEST_DIR_AUDIOBOOK` | Audiobook folder name | `` |
| `INGEST_DIR_STANDARDS_DOCUMENT` | Standards document folder name | `` |
| `INGEST_DIR_MUSICAL_SCORE` | Musical score folder name | `` |
If no specific path is set for a content type the default is `INGEST_DIR`.
Remember to map the specified paths to where your instance of Calibre-Web-Automated (CWA) will find them, e.g.:
```
volumes:
- /tmp/data/calibre-web/comicbook-ingest:/cwa-comicbook-ingest
```
if `INGEST_DIR_COMIC_BOOK=/cwa-comicbook-ingest` and your CWA is configured to use `/tmp/data/calibre-web/comicbook-ingest`
for comic books.
#### AA
| Variable | Description | Default Value |
@@ -242,9 +271,25 @@ This feature is designed to work with any resolver that implements the `FlareSol
## 🏗️ Architecture
The application consists of a single service:
The application consists of a Flask backend with a React-based frontend:
1. **calibre-web-automated-bookdownloader**: Main application providing web interface and download functionality
### Backend
- **Flask Application**: Python-based backend (`app.py`, `backend.py`) providing REST API and WebSocket support
- **Download Manager**: Handles book search, download requests, and queue management (`downloader.py`, `book_manager.py`)
- **Network Layer**: Cloudflare bypass and proxy support (`cloudflare_bypasser.py`, `network.py`)
### Frontend
- **React + TypeScript**: Modern web interface built with Vite (`src/frontend`)
- **Real-time Updates**: WebSocket integration for live download status
- **Responsive UI**: TailwindCSS-based design for mobile and desktop
For frontend development, use the provided Makefile:
```bash
make install # Install dependencies
make dev # Start development server
make build # Build for production
```
If you run the docker compose file, the frontend will be built and served automatically. But if you run the frontend dev server it will supercede the docker compose frontend.
## 🏥 Health Monitoring
@@ -258,7 +303,7 @@ Checks run every 30 seconds with a 30-second timeout and 3 retries.
You can enable by adding this to your compose :
```
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
CMD pyrequests http://localhost:8084/request/api/status || exit 1
CMD curl -s http://localhost:8084/api/status || exit 1
```
## 📝 Logging
+5
View File
@@ -1,8 +1,13 @@
flask
flask-cors
flask-socketio
python-socketio
requests[socks]
beautifulsoup4
tqdm
dnspython
gunicorn
gevent
gevent-websocket
psutil
emoji
+99
View File
@@ -0,0 +1,99 @@
# Source Code Documentation
This directory contains the frontend application for Calibre-Web Automated Book Downloader.
## Structure
```
src/
└── frontend/ # React + TypeScript frontend application
├── public/ # Static assets (logo, favicon)
├── src/ # Source code
│ ├── components/ # React components
│ ├── App.tsx # Main application component
│ └── styles.css # Global styles
├── package.json # Dependencies and scripts
├── vite.config.ts # Vite configuration
└── tsconfig.json # TypeScript configuration
```
## Frontend Development
### Prerequisites
- Node.js (v16 or higher)
- npm or yarn
### Quick Start
From the project root:
```bash
# Install dependencies
make install
# Start development server (http://localhost:5173)
make dev
# Build for production
make build
# Preview production build
make preview
# Run type checking
make typecheck
```
Alternatively, from `src/frontend`:
```bash
npm install
npm run dev
npm run build
```
### Technology Stack
- **Framework**: React 18 with TypeScript
- **Build Tool**: Vite 5
- **Styling**: TailwindCSS 3
- **Communication**: WebSocket for real-time updates
### Key Features
- **Search Interface**: Real-time book search with filtering
- **Download Queue**: Live status updates via WebSocket
- **Details Modal**: Rich book information display
- **Responsive Design**: Mobile-first approach
## Development Tips
### Hot Module Replacement (HMR)
The development server supports HMR for instant feedback during development.
### API Integration
The frontend communicates with the Flask backend via:
- REST API endpoints (`/api/*`)
- WebSocket connection (`ws://localhost:8084/ws`)
### Building for Production
The production build is optimized and minified:
```bash
make build
```
Output is generated in `src/frontend/dist/`
### Type Safety
Run TypeScript checks without building:
```bash
make typecheck
```
## Debugging
### Development Server Issues
- Ensure port 5173 is available
- Check that the backend is running on port 8084
- Verify WebSocket connection in browser console
### Build Issues
- Clear `node_modules` and reinstall: `make clean && make install`
- Check Node.js version compatibility
- Verify TypeScript configuration
+26
View File
@@ -0,0 +1,26 @@
# Dependencies
node_modules
/.pnp
.pnp.js
# Production
/dist
# Misc
.DS_Store
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# IDE
.vscode/*
!.vscode/extensions.json
.idea
# Environment
.env
.env.local
.env.production.local
.env.development.local
.env.test.local
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" content="Calibre Web Book Downloader - Modern UI" />
<!-- Theme color with media queries for light/dark mode -->
<meta name="theme-color" content="#f8f8f8" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#121212" media="(prefers-color-scheme: dark)" />
<!-- iOS PWA Meta Tags -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Book Downloader" />
<!-- App Icons -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/logo.png" />
<title>Book Downloader</title>
<script>
// Apply theme immediately before first paint to prevent flash
(function() {
const savedTheme = localStorage.getItem('preferred-theme') || 'auto';
let theme = savedTheme;
if (savedTheme === 'auto') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', theme);
// Add class to prevent transitions on initial load
document.documentElement.classList.add('preload');
})();
</script>
</head>
<body style="background: var(--bg); color: var(--text);">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3299
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "cwad-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.2",
"socket.io-client": "^4.7.5"
},
"devDependencies": {
"@types/node": "^24.10.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.5.3",
"vite": "^5.4.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+560
View File
@@ -0,0 +1,560 @@
import { useState, useEffect, useCallback, useRef, CSSProperties } from 'react';
import { Navigate, Route, Routes, useNavigate } from 'react-router-dom';
import {
Book,
StatusData,
ButtonStateInfo,
AppConfig,
LoginCredentials,
AdvancedFilterState,
} from './types';
import { searchBooks, getBookInfo, downloadBook, cancelDownload, clearCompleted, getConfig, login, logout, checkAuth, AuthenticationError } from './services/api';
import { useToast } from './hooks/useToast';
import { useRealtimeStatus } from './hooks/useRealtimeStatus';
import { Header } from './components/Header';
import { SearchSection } from './components/SearchSection';
import { AdvancedFilters } from './components/AdvancedFilters';
import { ResultsSection } from './components/ResultsSection';
import { DetailsModal } from './components/DetailsModal';
import { DownloadsSidebar } from './components/DownloadsSidebar';
import { ToastContainer } from './components/ToastContainer';
import { Footer } from './components/Footer';
import { LoginPage } from './pages/LoginPage';
import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages';
import { LANGUAGE_OPTION_DEFAULT } from './utils/languageFilters';
import { buildSearchQuery } from './utils/buildSearchQuery';
import './styles.css';
const DEFAULT_FORMAT_SELECTION = DEFAULT_SUPPORTED_FORMATS.filter(format => format !== 'pdf');
function App() {
// Authentication state
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [authRequired, setAuthRequired] = useState<boolean>(true);
const [authChecked, setAuthChecked] = useState<boolean>(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [isLoggingIn, setIsLoggingIn] = useState<boolean>(false);
const navigate = useNavigate();
const [books, setBooks] = useState<Book[]>([]);
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [config, setConfig] = useState<AppConfig | null>(null);
const [searchInput, setSearchInput] = useState('');
const [showAdvanced, setShowAdvanced] = useState(false);
const [downloadsSidebarOpen, setDownloadsSidebarOpen] = useState(false);
const [lastSearchQuery, setLastSearchQuery] = useState('');
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilterState>({
isbn: '',
author: '',
title: '',
lang: [LANGUAGE_OPTION_DEFAULT],
sort: '',
content: '',
formats: DEFAULT_FORMAT_SELECTION,
});
const { toasts, showToast } = useToast();
const updateAdvancedFilters = useCallback((updates: Partial<AdvancedFilterState>) => {
setAdvancedFilters(prev => ({ ...prev, ...updates }));
}, []);
// Determine WebSocket URL based on current location
// In production, use the same origin as the page; in dev, use localhost
const wsUrl = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
? 'http://localhost:8084'
: window.location.origin;
// Use realtime status with WebSocket and polling fallback
const {
status: currentStatus,
isUsingWebSocket,
forceRefresh: fetchStatus
} = useRealtimeStatus({
wsUrl,
pollInterval: 5000,
reconnectAttempts: 3,
});
// Calculate status counts for header badges
const getStatusCounts = () => {
const ongoing = [
currentStatus.queued,
currentStatus.resolving,
currentStatus.bypassing,
currentStatus.downloading,
currentStatus.verifying,
currentStatus.ingesting,
].reduce((sum, status) => sum + (status ? Object.keys(status).length : 0), 0);
const completed = [
currentStatus.completed,
currentStatus.complete,
currentStatus.available,
currentStatus.done,
].reduce((sum, status) => sum + (status ? Object.keys(status).length : 0), 0);
const errored = currentStatus.error ? Object.keys(currentStatus.error).length : 0;
return { ongoing, completed, errored };
};
const statusCounts = getStatusCounts();
const activeCount = statusCounts.ongoing;
// Compute visibility states
const hasResults = books.length > 0;
const isInitialState = !hasResults;
// Detect status changes and show notifications
const detectChanges = useCallback((prev: StatusData, curr: StatusData) => {
if (!prev || Object.keys(prev).length === 0) return;
// Check for new items in queue
const prevQueued = prev.queued || {};
const currQueued = curr.queued || {};
Object.keys(currQueued).forEach(bookId => {
if (!prevQueued[bookId]) {
const book = currQueued[bookId];
showToast(`${book.title || 'Book'} added to queue`, 'info');
}
});
// Check for items that started downloading
const prevDownloading = prev.downloading || {};
const currDownloading = curr.downloading || {};
Object.keys(currDownloading).forEach(bookId => {
if (!prevDownloading[bookId]) {
const book = currDownloading[bookId];
showToast(`${book.title || 'Book'} started downloading`, 'info');
}
});
// Check for completed items
const prevDownloadingIds = new Set(Object.keys(prevDownloading));
const prevQueuedIds = new Set(Object.keys(prevQueued));
const currAvailable = curr.available || {};
const currDone = curr.done || {};
Object.keys(currAvailable).forEach(bookId => {
if (prevDownloadingIds.has(bookId) || prevQueuedIds.has(bookId)) {
const book = currAvailable[bookId];
showToast(`${book.title || 'Book'} completed`, 'success');
}
});
Object.keys(currDone).forEach(bookId => {
if (prevDownloadingIds.has(bookId) || prevQueuedIds.has(bookId)) {
const book = currDone[bookId];
showToast(`${book.title || 'Book'} completed`, 'success');
}
});
}, [showToast]);
// Track previous status for change detection
const prevStatusRef = useRef<StatusData>({});
// Check authentication on mount
useEffect(() => {
const verifyAuth = async () => {
try {
const response = await checkAuth();
const authenticated = response.authenticated || false;
const authIsRequired = response.auth_required !== false; // Default to true if undefined
setAuthRequired(authIsRequired);
setIsAuthenticated(authenticated);
} catch (error) {
console.error('Auth check failed:', error);
// On error, assume auth is required and user is not authenticated
setAuthRequired(true);
setIsAuthenticated(false);
} finally {
setAuthChecked(true);
}
};
verifyAuth();
}, []);
// Authentication handlers
const handleLogin = async (credentials: LoginCredentials) => {
setIsLoggingIn(true);
setLoginError(null);
try {
const response = await login(credentials);
if (response.success) {
setIsAuthenticated(true);
setLoginError(null);
navigate('/', { replace: true });
} else {
setLoginError(response.error || 'Login failed');
}
} catch (error) {
if (error instanceof Error) {
setLoginError(error.message || 'Login failed');
} else {
setLoginError('Login failed');
}
} finally {
setIsLoggingIn(false);
}
};
const handleLogout = async () => {
try {
await logout();
setIsAuthenticated(false);
// Clear application state
setBooks([]);
setSelectedBook(null);
setSearchInput('');
setLastSearchQuery('');
navigate('/login', { replace: true });
} catch (error) {
console.error('Logout failed:', error);
showToast('Logout failed', 'error');
}
};
// Detect status changes when currentStatus updates
useEffect(() => {
if (prevStatusRef.current && Object.keys(prevStatusRef.current).length > 0) {
detectChanges(prevStatusRef.current, currentStatus);
}
prevStatusRef.current = currentStatus;
}, [currentStatus, detectChanges]);
// Fetch config on mount and when authentication changes
useEffect(() => {
const loadConfig = async () => {
try {
const cfg = await getConfig();
setConfig(cfg);
} catch (error) {
console.error('Failed to load config:', error);
// Use defaults if config fails to load
}
};
// Only fetch config if authenticated (or auth is not required)
if (isAuthenticated) {
loadConfig();
}
}, [isAuthenticated]);
// Log WebSocket connection status changes
useEffect(() => {
if (isUsingWebSocket) {
console.log('✅ Using WebSocket for real-time updates');
} else {
console.log('⏳ Using polling fallback (5s interval)');
}
}, [isUsingWebSocket]);
// Fetch status immediately on startup
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
// Search handler
const handleSearch = async (query: string) => {
if (!query) {
setBooks([]);
setLastSearchQuery('');
return;
}
setIsSearching(true);
setLastSearchQuery(query);
try {
const results = await searchBooks(query);
setBooks(results);
if (results.length === 0) {
showToast('No results found', 'error');
}
} catch (error) {
if (error instanceof AuthenticationError) {
setIsAuthenticated(false);
if (authRequired) {
navigate('/login', { replace: true });
}
} else {
console.error('Search failed:', error);
setBooks([]);
}
} finally {
setIsSearching(false);
}
};
// Show book details
const handleShowDetails = async (id: string): Promise<void> => {
try {
const book = await getBookInfo(id);
setSelectedBook(book);
} catch (error) {
console.error('Failed to load book details:', error);
showToast('Failed to load book details', 'error');
}
};
// Download book
const handleDownload = async (book: Book): Promise<void> => {
try {
await downloadBook(book.id);
// Fetch status to update button states (detectChanges will show toast)
await fetchStatus();
} catch (error) {
console.error('Download failed:', error);
showToast('Failed to queue download', 'error');
}
};
// Cancel download
const handleCancel = async (id: string) => {
try {
await cancelDownload(id);
await fetchStatus();
} catch (error) {
console.error('Cancel failed:', error);
}
};
// Clear completed
const handleClearCompleted = async () => {
try {
await clearCompleted();
await fetchStatus();
} catch (error) {
console.error('Clear completed failed:', error);
}
};
// Reset search state (clear books and search input)
const handleResetSearch = () => {
setBooks([]);
setSearchInput('');
setShowAdvanced(false);
setLastSearchQuery('');
setAdvancedFilters({
isbn: '',
author: '',
title: '',
lang: [LANGUAGE_OPTION_DEFAULT],
sort: '',
content: '',
formats: DEFAULT_FORMAT_SELECTION,
});
};
const handleSortChange = (value: string) => {
updateAdvancedFilters({ sort: value });
if (!lastSearchQuery) return;
const params = new URLSearchParams(lastSearchQuery);
if (value) {
params.set('sort', value);
} else {
params.delete('sort');
}
const nextQuery = params.toString();
if (!nextQuery) return;
handleSearch(nextQuery);
};
// Get button state for a book - memoized to ensure proper re-renders when status changes
const getButtonState = useCallback((bookId: string): ButtonStateInfo => {
// Check error first
if (currentStatus.error && currentStatus.error[bookId]) {
return { text: 'Failed', state: 'error' };
}
// Check completed states
if (currentStatus.completed && currentStatus.completed[bookId]) {
return { text: 'Downloaded', state: 'completed' };
}
if (currentStatus.complete && currentStatus.complete[bookId]) {
return { text: 'Downloaded', state: 'completed' };
}
if (currentStatus.available && currentStatus.available[bookId]) {
return { text: 'Downloaded', state: 'completed' };
}
if (currentStatus.done && currentStatus.done[bookId]) {
return { text: 'Downloaded', state: 'completed' };
}
// Check in-progress states with detailed status
if (currentStatus.ingesting && currentStatus.ingesting[bookId]) {
return { text: 'Ingesting', state: 'ingesting' };
}
if (currentStatus.verifying && currentStatus.verifying[bookId]) {
return { text: 'Verifying', state: 'verifying' };
}
if (currentStatus.downloading && currentStatus.downloading[bookId]) {
const book = currentStatus.downloading[bookId];
return {
text: 'Downloading',
state: 'downloading',
progress: book.progress
};
}
if (currentStatus.bypassing && currentStatus.bypassing[bookId]) {
return { text: 'Bypassing Cloudflare...', state: 'bypassing' };
}
if (currentStatus.resolving && currentStatus.resolving[bookId]) {
return { text: 'Resolving', state: 'resolving' };
}
if (currentStatus.queued && currentStatus.queued[bookId]) {
return { text: 'Queued', state: 'queued' };
}
return { text: 'Download', state: 'download' };
}, [currentStatus]);
const bookLanguages = config?.book_languages || DEFAULT_LANGUAGES;
const supportedFormats = config?.supported_formats || DEFAULT_SUPPORTED_FORMATS;
const defaultLanguageCodes =
config?.default_language && config.default_language.length > 0
? config.default_language
: [bookLanguages[0]?.code || 'en'];
const mainAppContent = (
<>
<Header
calibreWebUrl={config?.calibre_web_url || ''}
debug={config?.debug || false}
logoUrl="/logo.png"
showSearch={!isInitialState}
searchInput={searchInput}
onSearchChange={setSearchInput}
onDownloadsClick={() => setDownloadsSidebarOpen(true)}
statusCounts={statusCounts}
onLogoClick={handleResetSearch}
authRequired={authRequired}
isAuthenticated={isAuthenticated}
onLogout={handleLogout}
onSearch={() => {
const query = buildSearchQuery({
searchInput,
showAdvanced,
advancedFilters,
bookLanguages,
defaultLanguage: defaultLanguageCodes,
});
handleSearch(query);
}}
onAdvancedToggle={() => setShowAdvanced(!showAdvanced)}
isLoading={isSearching}
/>
<AdvancedFilters
visible={showAdvanced && !isInitialState}
bookLanguages={bookLanguages}
defaultLanguage={defaultLanguageCodes}
supportedFormats={supportedFormats}
filters={advancedFilters}
onFiltersChange={updateAdvancedFilters}
/>
<main className="w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-6">
<SearchSection
onSearch={handleSearch}
isLoading={isSearching}
isInitialState={isInitialState}
bookLanguages={bookLanguages}
defaultLanguage={defaultLanguageCodes}
supportedFormats={config?.supported_formats || DEFAULT_SUPPORTED_FORMATS}
logoUrl="/logo.png"
searchInput={searchInput}
onSearchInputChange={setSearchInput}
showAdvanced={showAdvanced}
onAdvancedToggle={() => setShowAdvanced(!showAdvanced)}
advancedFilters={advancedFilters}
onAdvancedFiltersChange={updateAdvancedFilters}
/>
<ResultsSection
books={books}
visible={hasResults}
onDetails={handleShowDetails}
onDownload={handleDownload}
getButtonState={getButtonState}
sortValue={advancedFilters.sort}
onSortChange={handleSortChange}
/>
{selectedBook && (
<DetailsModal
book={selectedBook}
onClose={() => setSelectedBook(null)}
onDownload={handleDownload}
buttonState={getButtonState(selectedBook.id)}
/>
)}
</main>
<Footer
buildVersion={config?.build_version}
releaseVersion={config?.release_version}
debug={config?.debug}
/>
<ToastContainer toasts={toasts} />
{/* Downloads Sidebar */}
<DownloadsSidebar
isOpen={downloadsSidebarOpen}
onClose={() => setDownloadsSidebarOpen(false)}
status={currentStatus}
onRefresh={fetchStatus}
onClearCompleted={handleClearCompleted}
onCancel={handleCancel}
activeCount={activeCount}
/>
</>
);
const visuallyHiddenStyle: CSSProperties = {
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
};
if (!authChecked) {
return (
<div aria-live="polite" style={visuallyHiddenStyle}>
Checking authentication…
</div>
);
}
const shouldRedirectFromLogin = !authRequired || isAuthenticated;
const appElement = authRequired && !isAuthenticated ? (
<Navigate to="/login" replace />
) : (
mainAppContent
);
return (
<Routes>
<Route
path="/login"
element={
shouldRedirectFromLogin ? (
<Navigate to="/" replace />
) : (
<LoginPage
onLogin={handleLogin}
error={loginError}
isLoading={isLoggingIn}
/>
)
}
/>
<Route path="/*" element={appElement} />
</Routes>
);
}
export default App;
@@ -0,0 +1,165 @@
import { ReactNode } from 'react';
import { AdvancedFilterState, Language } from '../types';
import { normalizeLanguageSelection } from '../utils/languageFilters';
import { LanguageMultiSelect } from './LanguageMultiSelect';
import { DropdownList } from './DropdownList';
import { CONTENT_OPTIONS } from '../data/filterOptions';
const FORMAT_TYPES = ['pdf', 'epub', 'mobi', 'azw3', 'fb2', 'djvu', 'cbz', 'cbr'] as const;
interface AdvancedFiltersProps {
visible: boolean;
bookLanguages: Language[];
defaultLanguage: string[];
supportedFormats: string[];
filters: AdvancedFilterState;
onFiltersChange: (updates: Partial<AdvancedFilterState>) => void;
formClassName?: string;
renderWrapper?: (form: ReactNode) => ReactNode;
}
export const AdvancedFilters = ({
visible,
bookLanguages,
defaultLanguage,
supportedFormats,
filters,
onFiltersChange,
formClassName,
renderWrapper,
}: AdvancedFiltersProps) => {
const { isbn, author, title, lang, content, formats } = filters;
const handleLangChange = (next: string[]) => {
const normalized = normalizeLanguageSelection(next);
onFiltersChange({ lang: normalized });
};
const handleContentChange = (next: string[] | string) => {
const value = Array.isArray(next) ? next[0] ?? '' : next;
onFiltersChange({ content: value });
};
const handleFormatsChange = (next: string[] | string) => {
const nextFormats = Array.isArray(next) ? next : next ? [next] : [];
onFiltersChange({ formats: nextFormats });
};
const formatOptions = FORMAT_TYPES.map(format => ({
value: format,
label: format.toUpperCase(),
disabled: !supportedFormats.includes(format),
}));
if (!visible) return null;
const form = (
<form
id="search-filters"
className={
formClassName ??
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 lg:ml-[calc(3rem+1rem)] lg:w-[50vw]'
}
>
<div>
<label htmlFor="isbn-input" className="block text-sm mb-1 opacity-80">
ISBN
</label>
<input
id="isbn-input"
type="text"
placeholder="ISBN"
autoComplete="off"
className="w-full px-3 py-2 rounded-md border"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={isbn}
onChange={e => {
onFiltersChange({ isbn: e.target.value });
}}
/>
</div>
<div>
<label htmlFor="author-input" className="block text-sm mb-1 opacity-80">
Author
</label>
<input
id="author-input"
type="text"
placeholder="Author"
autoComplete="off"
className="w-full px-3 py-2 rounded-md border"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={author}
onChange={e => {
onFiltersChange({ author: e.target.value });
}}
/>
</div>
<div>
<label htmlFor="title-input" className="block text-sm mb-1 opacity-80">
Title
</label>
<input
id="title-input"
type="text"
placeholder="Title"
autoComplete="off"
className="w-full px-3 py-2 rounded-md border"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={title}
onChange={e => {
onFiltersChange({ title: e.target.value });
}}
/>
</div>
<LanguageMultiSelect
options={bookLanguages}
value={lang}
onChange={handleLangChange}
defaultLanguageCodes={defaultLanguage}
label="Language"
/>
<DropdownList
label="Content"
options={CONTENT_OPTIONS}
value={content}
onChange={handleContentChange}
placeholder="All"
/>
<div>
<DropdownList
label="Formats"
placeholder="Any"
options={formatOptions}
value={formats}
onChange={handleFormatsChange}
multiple
showCheckboxes
keepOpenOnSelect
/>
</div>
</form>
);
const wrappedForm = renderWrapper ? (
renderWrapper(form)
) : (
<div className="w-full border-b pt-6 pb-4 mb-4" style={{ borderColor: 'var(--border-muted)' }}>
<div className="w-full px-4 sm:px-6 lg:px-8">{form}</div>
</div>
);
return wrappedForm;
};
@@ -0,0 +1,256 @@
import { useEffect, useState, CSSProperties } from 'react';
import { ButtonStateInfo } from '../types';
interface CircularProgressProps {
progress?: number;
size?: number;
className?: string;
}
const CircularProgress = ({ progress, size = 16, className }: CircularProgressProps) => {
const radius = (size - 2) / 2;
const circumference = 2 * Math.PI * radius;
const progressValue = progress ?? 0;
const strokeDashoffset = circumference - (progressValue / 100) * circumference;
const svgClassName = className ? `transform -rotate-90 ${className}` : 'transform -rotate-90';
return (
<svg width={size} height={size} className={svgClassName}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="2"
opacity="0.3"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
style={{ transition: 'stroke-dashoffset 0.3s ease' }}
/>
</svg>
);
};
type ButtonSize = 'sm' | 'md';
type ButtonVariant = 'primary' | 'icon';
interface BookDownloadButtonProps {
buttonState: ButtonStateInfo;
onDownload: () => Promise<void>;
size?: ButtonSize;
fullWidth?: boolean;
className?: string;
showIcon?: boolean;
style?: CSSProperties;
variant?: ButtonVariant;
ariaLabel?: string;
}
const sizeClasses: Record<ButtonSize, string> = {
sm: 'px-2.5 py-1.5 text-xs',
md: 'px-4 py-2.5 text-sm',
};
const iconVariantSizeClasses: Record<ButtonSize, string> = {
sm: 'p-1 sm:p-1.5',
md: 'p-1.5 sm:p-2',
};
const primaryIconSizes: Record<ButtonSize, string> = {
sm: 'w-3.5 h-3.5',
md: 'w-4 h-4',
};
const iconVariantIconSizes: Record<ButtonSize, { mobile: string; desktop: string }> = {
sm: { mobile: 'w-3.5 h-3.5', desktop: 'w-4 h-4' },
md: { mobile: 'w-4 h-4', desktop: 'w-5 h-5' },
};
const iconVariantProgressSizes: Record<ButtonSize, { mobile: number; desktop: number }> = {
sm: { mobile: 14, desktop: 16 },
md: { mobile: 16, desktop: 20 },
};
export const BookDownloadButton = ({
buttonState,
onDownload,
size = 'md',
fullWidth = false,
className = '',
showIcon = false,
style,
variant = 'primary',
ariaLabel,
}: BookDownloadButtonProps) => {
const [isQueuing, setIsQueuing] = useState(false);
useEffect(() => {
if (isQueuing && buttonState.state !== 'download') {
setIsQueuing(false);
}
}, [buttonState.state, isQueuing]);
const isCompleted = buttonState.state === 'completed';
const hasError = buttonState.state === 'error';
const isInProgress = ['queued', 'resolving', 'bypassing', 'downloading', 'verifying', 'ingesting'].includes(
buttonState.state,
);
const isDisabled = buttonState.state !== 'download' || isQueuing || isCompleted;
const displayText = isQueuing ? 'Queuing...' : buttonState.text;
const showCircularProgress = buttonState.state === 'downloading' && buttonState.progress !== undefined;
const showSpinner = (isInProgress && !showCircularProgress) || isQueuing;
const primaryStateClasses =
isCompleted
? 'bg-green-600 cursor-not-allowed'
: hasError
? 'bg-red-600 cursor-not-allowed opacity-75'
: isInProgress
? 'bg-gray-500 cursor-not-allowed opacity-75'
: 'bg-sky-700 hover:bg-sky-800';
const iconStateClasses =
isCompleted
? 'bg-green-600 text-white cursor-not-allowed'
: hasError
? 'bg-red-600 text-white cursor-not-allowed opacity-75'
: isInProgress
? 'bg-gray-500 text-white cursor-not-allowed opacity-75'
: 'text-gray-600 dark:text-gray-200 hover-action';
const stateClasses = variant === 'icon' ? iconStateClasses : primaryStateClasses;
const widthClasses = variant === 'primary' && fullWidth ? 'w-full' : '';
const baseClasses =
variant === 'icon'
? 'flex items-center justify-center rounded-full transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500'
: 'inline-flex items-center justify-center gap-1.5 rounded text-white transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500';
const sizeClass = variant === 'icon' ? iconVariantSizeClasses[size] : sizeClasses[size];
const iconSizes = variant === 'icon' ? iconVariantIconSizes[size] : undefined;
const handleDownload = async () => {
if (isDisabled) return;
setIsQueuing(true);
try {
await onDownload();
} catch (error) {
setIsQueuing(false);
}
};
const renderStatusIcon = () => {
if (isCompleted) {
if (variant === 'icon' && iconSizes) {
return (
<>
<svg className={`${iconSizes.mobile} sm:hidden`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />
</svg>
<svg className={`${iconSizes.desktop} hidden sm:block`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />
</svg>
</>
);
}
return (
<svg className={primaryIconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
);
}
if (hasError) {
if (variant === 'icon' && iconSizes) {
return (
<>
<svg className={`${iconSizes.mobile} sm:hidden`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
</svg>
<svg className={`${iconSizes.desktop} hidden sm:block`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
</svg>
</>
);
}
return (
<svg className={primaryIconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
);
}
if (showCircularProgress) {
if (variant === 'icon') {
const sizes = iconVariantProgressSizes[size];
return (
<>
<CircularProgress progress={buttonState.progress} size={sizes.mobile} className="block sm:hidden" />
<CircularProgress progress={buttonState.progress} size={sizes.desktop} className="hidden sm:block" />
</>
);
}
return <CircularProgress progress={buttonState.progress} size={size === 'sm' ? 12 : 16} />;
}
if (showSpinner) {
const spinnerClass =
variant === 'icon'
? size === 'sm'
? 'w-3.5 h-3.5 sm:w-4 h-4'
: 'w-4 h-4 sm:w-5 h-5'
: size === 'sm'
? 'w-3 h-3'
: 'w-4 h-4';
return <div className={`${spinnerClass} border-2 border-current border-t-transparent rounded-full animate-spin`} />;
}
if (variant === 'icon' && iconSizes) {
return (
<>
<svg className={`${iconSizes.mobile} sm:hidden`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
<svg className={`${iconSizes.desktop} hidden sm:block`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</>
);
}
return null;
};
return (
<button
className={`${baseClasses} ${sizeClass} ${stateClasses} ${widthClasses} ${className}`.trim()}
onClick={handleDownload}
disabled={isDisabled || isInProgress}
data-action="download"
style={style}
aria-label={ariaLabel ?? displayText}
>
{variant === 'primary' && showIcon && !isCompleted && !hasError && !showCircularProgress && !showSpinner && (
<svg className={primaryIconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v12m0 0l-4-4m4 4 4-4M6 20h12" />
</svg>
)}
{variant === 'primary' && <span className="download-button-text">{displayText}</span>}
{variant === 'icon' && <span className="sr-only">{ariaLabel ?? displayText}</span>}
{renderStatusIcon()}
</button>
);
};
@@ -0,0 +1,199 @@
import { useState, useEffect } from 'react';
import { Book, ButtonStateInfo } from '../types';
import { BookDownloadButton } from './BookDownloadButton';
interface DetailsModalProps {
book: Book | null;
onClose: () => void;
onDownload: (book: Book) => Promise<void>;
buttonState: ButtonStateInfo;
}
export const DetailsModal = ({ book, onClose, onDownload, buttonState }: DetailsModalProps) => {
const [isQueuing, setIsQueuing] = useState(false);
// Clear queuing state and close modal once button state changes from download
useEffect(() => {
if (isQueuing && buttonState.state !== 'download') {
setIsQueuing(false);
// Close modal after status has updated
const timer = setTimeout(onClose, 500);
return () => clearTimeout(timer);
}
}, [buttonState.state, isQueuing, onClose]);
// Handle ESC key to close modal
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [onClose]);
useEffect(() => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, []);
if (!book) return null;
const titleId = `book-details-title-${book.id}`;
const handleDownload = async () => {
setIsQueuing(true);
try {
await onDownload(book);
// Don't close here - wait for button state to change
} catch (error) {
setIsQueuing(false);
// Close on error
setTimeout(onClose, 300);
}
};
const publisherInfo = { label: 'Publisher', value: book.publisher || '-' };
const metadata = [
{ label: 'Year', value: book.year || '-' },
{ label: 'Language', value: book.language || '-' },
{ label: 'Format', value: book.format || '-' },
{ label: 'Size', value: book.size || '-' },
];
const artworkMaxHeight = 'calc(90vh - 220px)';
const artworkMaxWidth = 'min(45vw, 520px, calc((90vh - 220px) / 1.6))';
const additionalInfo =
book.info && Object.keys(book.info).length > 0
? Object.entries(book.info).filter(([key]) => {
const normalized = key.toLowerCase();
return normalized !== 'language' && normalized !== 'year';
})
: [];
const extendedInfoEntries = [[publisherInfo.label, publisherInfo.value], ...additionalInfo];
const infoCardClass = 'rounded-2xl border border-[var(--border-muted)] px-4 py-3 text-sm';
const infoCardStyle = { background: 'var(--bg)' };
const infoLabelClass = 'text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400';
const infoValueClass = 'text-gray-900 dark:text-gray-100';
return (
<div
className="modal-overlay active px-4 py-6 sm:px-6"
onClick={e => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
className="details-container w-full max-w-4xl animate-fade-in-up"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="flex max-h-[90vh] flex-col overflow-hidden rounded-2xl border border-[var(--border-muted)] bg-[var(--bg-soft)] text-[var(--text)] shadow-2xl">
<header className="flex items-start gap-4 border-b border-[var(--border-muted)] px-5 py-4">
<div className="flex-1 space-y-1">
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Book</p>
<h3 id={titleId} className="text-lg font-semibold leading-snug">
{book.title || 'Untitled'}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-300">
{book.author || 'Unknown author'}
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full p-2 text-gray-500 transition-colors hover-action hover:text-gray-900 dark:hover:text-gray-100"
aria-label="Close details"
>
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</header>
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-stretch lg:gap-8 lg:min-h-0">
<div className="flex w-full justify-center lg:w-auto lg:flex-none lg:justify-start lg:self-stretch lg:pr-4">
{book.preview ? (
<div
className="flex w-full items-center justify-center lg:h-full lg:max-w-none"
style={{ maxHeight: artworkMaxHeight, maxWidth: artworkMaxWidth }}
>
<img
src={book.preview}
alt="Book cover"
className="h-auto max-h-full w-auto max-w-full rounded-xl object-contain shadow-lg"
style={{ maxHeight: '100%', maxWidth: '100%' }}
/>
</div>
) : (
<div
className="flex w-full items-center justify-center rounded-xl border border-dashed border-[var(--border-muted)] bg-[var(--bg)]/60 p-6 text-sm text-gray-500 lg:h-full lg:max-w-none"
style={{ maxHeight: artworkMaxHeight, maxWidth: artworkMaxWidth }}
>
No cover
</div>
)}
</div>
<div className="flex flex-1 flex-col gap-4 sm:gap-5 lg:min-h-0">
{book.description && (
<div className={`${infoCardClass} space-y-1`} style={infoCardStyle}>
<p className={infoLabelClass}>Description</p>
<p className={`${infoValueClass} whitespace-pre-line`}>{book.description}</p>
</div>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-2 lg:grid-cols-4 lg:gap-4">
{metadata.map(item => (
<div key={item.label} className={`${infoCardClass} space-y-1`} style={infoCardStyle}>
<p className={infoLabelClass}>{item.label}</p>
<p className={infoValueClass}>{item.value}</p>
</div>
))}
</div>
{extendedInfoEntries.length > 0 && (
<div className={`${infoCardClass} space-y-3`} style={infoCardStyle}>
<ul className="space-y-3 list-none">
{extendedInfoEntries.map(([key, value]) => (
<li key={key} className="space-y-1">
<p className={infoLabelClass}>{key}</p>
<p className={infoValueClass}>{Array.isArray(value) ? value.join(', ') : value}</p>
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
<footer className="border-t border-[var(--border-muted)] bg-[var(--bg-soft)] px-5 py-4">
<div className="flex justify-end">
<BookDownloadButton
buttonState={buttonState}
onDownload={handleDownload}
size="md"
fullWidth
className="rounded-full px-4 py-3 text-sm font-medium"
ariaLabel={`Download ${book.title || 'book'}`}
/>
</div>
</footer>
</div>
</div>
</div>
);
};
@@ -0,0 +1,381 @@
import { useEffect } from 'react';
import { StatusData, Book } from '../types';
interface DownloadsSidebarProps {
isOpen: boolean;
onClose: () => void;
status: StatusData;
onRefresh: () => void;
onClearCompleted: () => void;
onCancel: (id: string) => void;
activeCount: number;
}
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
queued: { bg: 'bg-amber-500/10', text: 'text-amber-600', label: 'Queued' },
resolving: { bg: 'bg-indigo-500/10', text: 'text-indigo-600', label: 'Resolving' },
bypassing: { bg: 'bg-purple-500/10', text: 'text-purple-600', label: 'Bypassing Cloudflare...' },
downloading: { bg: 'bg-blue-500/10', text: 'text-blue-600', label: 'Downloading' },
verifying: { bg: 'bg-cyan-500/10', text: 'text-cyan-600', label: 'Verifying' },
ingesting: { bg: 'bg-teal-500/10', text: 'text-teal-600', label: 'Ingesting' },
complete: { bg: 'bg-green-500/10', text: 'text-green-600', label: 'Complete' },
completed: { bg: 'bg-green-500/10', text: 'text-green-600', label: 'Completed' },
available: { bg: 'bg-green-500/10', text: 'text-green-600', label: 'Available' },
done: { bg: 'bg-green-500/10', text: 'text-green-600', label: 'Done' },
error: { bg: 'bg-red-500/10', text: 'text-red-600', label: 'Error' },
cancelled: { bg: 'bg-gray-500/10', text: 'text-gray-600', label: 'Cancelled' },
};
// Helper to format file size
const formatSize = (sizeStr?: string): string => {
if (!sizeStr) return '';
return sizeStr;
};
// Add keyframe animation for wave effect
const styleSheet = document.createElement('style');
styleSheet.textContent = `
@keyframes wave {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
`;
if (!document.head.querySelector('style[data-wave-animation]')) {
styleSheet.setAttribute('data-wave-animation', 'true');
document.head.appendChild(styleSheet);
}
// Helper to get book preview image
const getBookPreview = (book: Book): string => {
return book.preview || '/placeholder-book.png';
};
// Helper to get progress percentage based on status
const getStatusProgress = (statusName: string, bookProgress?: number): number => {
switch (statusName) {
case 'queued':
return 5;
case 'resolving':
return 10;
case 'bypassing':
return 15;
case 'downloading':
// Map actual progress (0-100) to 20-90 range
if (typeof bookProgress === 'number') {
return 20 + (bookProgress * 0.7);
}
return 20;
case 'verifying':
return 95;
case 'ingesting':
return 99;
case 'completed':
case 'complete':
case 'available':
case 'done':
return 100;
case 'error':
return 100;
default:
return 0;
}
};
// Helper to get progress bar color based on status
const getProgressBarColor = (statusName: string): string => {
const isCompleted = ['completed', 'complete', 'available', 'done'].includes(statusName);
if (isCompleted) return 'bg-green-600';
if (statusName === 'error') return 'bg-red-600';
if (statusName === 'queued') return 'bg-gray-600';
if (statusName === 'resolving') return 'bg-purple-600';
if (statusName === 'bypassing') return 'bg-violet-600';
if (statusName === 'downloading') return 'bg-sky-600';
if (statusName === 'verifying') return 'bg-cyan-600';
if (statusName === 'ingesting') return 'bg-teal-600';
return 'bg-sky-600';
};
export const DownloadsSidebar = ({
isOpen,
onClose,
status,
onRefresh,
onClearCompleted,
onCancel,
activeCount,
}: DownloadsSidebarProps) => {
// Handle ESC key to close sidebar
useEffect(() => {
if (!isOpen) return; // Only listen when sidebar is open
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
// Collect all download items from different status sections
const allDownloadItems: Array<{ book: Book; status: string; order: number }> = [];
// Priority order for display
const statusOrder = ['downloading', 'bypassing', 'resolving', 'queued', 'verifying', 'ingesting', 'error', 'completed', 'complete', 'available', 'done', 'cancelled'];
statusOrder.forEach((statusName, index) => {
const items = (status as any)[statusName];
if (items && Object.keys(items).length > 0) {
Object.values(items).forEach((book: any) => {
allDownloadItems.push({ book, status: statusName, order: index });
});
}
});
// Sort by status priority
allDownloadItems.sort((a, b) => a.order - b.order);
const renderDownloadItem = (item: { book: Book; status: string }) => {
const { book, status: statusName } = item;
const statusStyle = STATUS_STYLES[statusName] || {
bg: 'bg-gray-500/10',
text: 'text-gray-600',
label: statusName.charAt(0).toUpperCase() + statusName.slice(1),
};
const isInProgress = ['queued', 'resolving', 'bypassing', 'downloading', 'verifying', 'ingesting'].includes(statusName);
const isCompleted = ['completed', 'complete', 'available', 'done'].includes(statusName);
const hasError = statusName === 'error';
// Get progress information
const progress = getStatusProgress(statusName, book.progress);
const progressBarColor = getProgressBarColor(statusName);
// Format progress text
let progressText = statusStyle.label;
if (statusName === 'downloading' && book.progress && book.size) {
const downloadedMB = (book.progress / 100) * parseFloat(book.size.replace(/[^\d.]/g, ''));
progressText = `${downloadedMB.toFixed(1)}mb / ${book.size}`;
} else if (isCompleted) {
progressText = 'Complete';
} else if (hasError) {
progressText = 'Failed';
}
return (
<div
key={book.id}
className="relative rounded-lg border hover:shadow-md transition-shadow overflow-hidden"
style={{ borderColor: 'var(--border-muted)', background: 'var(--bg-soft)' }}
>
{/* Main content area */}
<div className="flex gap-2">
{/* Book Thumbnail - left side */}
<div className="flex-shrink-0">
<img
src={getBookPreview(book)}
alt={book.title || 'Book cover'}
className="w-16 h-24 object-cover rounded shadow-sm"
style={{ aspectRatio: '2/3' }}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = '/placeholder-book.png';
}}
/>
</div>
{/* Book Info - right side */}
<div className="flex-1 min-w-0 flex flex-col justify-between px-3 pt-2 pb-3">
{/* Title & Author */}
<div className="mb-1">
<h3 className="font-semibold text-sm truncate" title={book.title}>
{isCompleted && book.download_path ? (
<a
href={`/api/localdownload?id=${encodeURIComponent(book.id)}`}
className="text-sky-600 hover:underline"
>
{book.title || 'Unknown Title'}
</a>
) : (
book.title || 'Unknown Title'
)}
</h3>
<p className="text-xs opacity-70 truncate" title={book.author}>
{book.author || 'Unknown Author'}
</p>
</div>
{/* Status Badge and Details Row */}
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${statusStyle.bg} ${statusStyle.text}`}
>
{statusStyle.label}
</span>
{/* Cancel Button for in-progress items */}
{isInProgress && (
<button
type="button"
onClick={() => onCancel(book.id)}
className="text-xs px-2 py-1 rounded border hover-action transition-colors"
style={{ borderColor: 'var(--border-muted)' }}
title="Cancel download"
>
✕
</button>
)}
</div>
{/* Format and Size */}
<div className="text-xs opacity-70">
{book.format && <span className="uppercase">{book.format}</span>}
{book.format && book.size && <span> • </span>}
{book.size && <span>{formatSize(book.size)}</span>}
</div>
{/* Error Message */}
{hasError && (
<p className="text-xs text-red-600">Download failed</p>
)}
</div>
</div>
</div>
{/* Progress Bar - absolute positioned at bottom - always visible */}
<div className="absolute bottom-0 left-0 right-0">
<p className="text-xs opacity-70 mt-0.5 text-right p-2">{progressText}</p>
<div className="h-1.5 bg-gray-200 dark:bg-gray-700 overflow-hidden relative">
<div
className={`h-full ${progressBarColor} transition-all duration-300 relative overflow-hidden`}
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
>
{/* Animated wave effect for in-progress states */}
{isInProgress && progress < 100 && (
<div
className="absolute inset-0 opacity-30"
style={{
background: 'linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.5) 50%, transparent 100%)',
backgroundSize: '200% 100%',
animation: 'wave 2s ease-in-out infinite',
}}
/>
)}
</div>
</div>
</div>
</div>
);
};
return (
<>
{/* Backdrop */}
<div
className={`fixed inset-0 bg-black/50 z-40 transition-opacity duration-300 ${
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
onClick={onClose}
/>
{/* Sidebar */}
<div
className={`fixed top-0 right-0 h-full w-full sm:w-96 z-50 flex flex-col shadow-2xl transition-transform duration-300 ${
isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
style={{ background: 'var(--bg)' }}
>
{/* Header */}
<div
className="flex items-center justify-between p-4"
style={{ paddingTop: 'calc(1rem + env(safe-area-inset-top))' }}
>
<h2 className="text-lg font-semibold">Downloads</h2>
<button
type="button"
onClick={onClose}
className="flex h-10 w-10 items-center justify-center rounded-full hover-action transition-colors"
aria-label="Close sidebar"
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Controls */}
<div
className="flex items-center gap-2 p-4 border-b"
style={{ borderColor: 'var(--border-muted)' }}
>
<button
type="button"
onClick={onClearCompleted}
className="flex-1 flex items-center justify-center px-3 py-2 h-10 rounded border text-sm hover-action transition-colors"
style={{ borderColor: 'var(--border-muted)' }}
>
Clear Completed
</button>
<button
type="button"
onClick={onRefresh}
className="flex items-center justify-center h-10 w-10 rounded-full text-sm hover-action transition-colors ml-auto"
aria-label="Refresh"
title="Refresh"
>
<svg
className="w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
</div>
{/* Queue Items */}
<div
className="flex-1 overflow-y-auto p-4 space-y-3"
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
>
{allDownloadItems.length > 0 ? (
allDownloadItems.map((item) => renderDownloadItem(item))
) : (
<div className="text-center text-sm opacity-70 mt-8">
No downloads in queue
</div>
)}
</div>
{/* Footer with active count */}
{activeCount > 0 && (
<div
className="p-3 border-t text-xs text-center opacity-70"
style={{
borderColor: 'var(--border-muted)',
paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))',
}}
>
{activeCount} active {activeCount === 1 ? 'download' : 'downloads'}
</div>
)}
</div>
</>
);
};
+144
View File
@@ -0,0 +1,144 @@
import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
interface DropdownProps {
label?: string;
summary?: ReactNode;
children: (helpers: { close: () => void }) => ReactNode;
align?: 'left' | 'right';
widthClassName?: string;
buttonClassName?: string;
panelClassName?: string;
disabled?: boolean;
renderTrigger?: (props: { isOpen: boolean; toggle: () => void }) => ReactNode;
}
export const Dropdown = ({
label,
summary,
children,
align = 'left',
widthClassName = 'w-full',
buttonClassName = '',
panelClassName = '',
disabled = false,
renderTrigger,
}: DropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [panelDirection, setPanelDirection] = useState<'down' | 'up'>('down');
const toggleOpen = () => {
if (disabled) return;
setIsOpen(prev => !prev);
};
const close = () => setIsOpen(false);
useEffect(() => {
if (!isOpen) return;
const handleClick = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
close();
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
close();
}
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);
useLayoutEffect(() => {
if (!isOpen) return;
const updatePanelDirection = () => {
if (!containerRef.current || !panelRef.current) {
return;
}
const rect = containerRef.current.getBoundingClientRect();
const panelHeight = panelRef.current.offsetHeight || panelRef.current.scrollHeight;
const spaceBelow = window.innerHeight - rect.bottom - 8;
const spaceAbove = rect.top - 8;
const shouldOpenUp = spaceBelow < panelHeight && spaceAbove >= panelHeight;
setPanelDirection(shouldOpenUp ? 'up' : 'down');
};
updatePanelDirection();
window.addEventListener('resize', updatePanelDirection);
window.addEventListener('scroll', updatePanelDirection, true);
return () => {
window.removeEventListener('resize', updatePanelDirection);
window.removeEventListener('scroll', updatePanelDirection, true);
};
}, [isOpen]);
return (
<div className={`relative ${widthClassName}`} ref={containerRef}>
{label && (
<label className="block text-sm mb-1 opacity-80" onClick={toggleOpen}>
{label}
</label>
)}
{renderTrigger ? (
renderTrigger({ isOpen, toggle: toggleOpen })
) : (
<button
type="button"
onClick={toggleOpen}
disabled={disabled}
className={`w-full px-3 py-2 rounded-md border flex items-center justify-between text-left text-base focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 ${buttonClassName}`}
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
>
<span className="truncate text-base">
{summary ?? <span className="opacity-60">Select an option</span>}
</span>
<svg
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth="1.5"
>
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
)}
{isOpen && (
<div
ref={panelRef}
className={`absolute ${align === 'right' ? 'right-0' : 'left-0'} ${
panelDirection === 'down' ? 'mt-2' : 'bottom-full mb-2'
} rounded-md border shadow-lg z-20 ${panelClassName || widthClassName}`}
style={{
background: 'var(--bg)',
borderColor: 'var(--border-muted)',
}}
>
<div className="max-h-64 overflow-auto">
{children({ close })}
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,155 @@
import { ReactNode } from 'react';
import { Dropdown } from './Dropdown';
export interface DropdownListOption {
value: string;
label: string;
description?: string;
disabled?: boolean;
icon?: ReactNode;
}
interface DropdownListProps {
label?: string;
placeholder?: string;
options: DropdownListOption[];
multiple?: boolean;
showCheckboxes?: boolean;
value: string[] | string | null | undefined;
onChange: (value: string[] | string) => void;
align?: 'left' | 'right';
widthClassName?: string;
buttonClassName?: string;
summaryFormatter?: (selected: DropdownListOption[], placeholder: string) => ReactNode;
keepOpenOnSelect?: boolean;
}
export const DropdownList = ({
label,
placeholder = 'Select an option',
options,
multiple = false,
showCheckboxes,
value,
onChange,
align,
widthClassName,
buttonClassName,
summaryFormatter,
keepOpenOnSelect,
}: DropdownListProps) => {
const selectedValues = normalizeValue(value, multiple);
const selectedOptions = options.filter(opt => selectedValues.includes(opt.value));
const checkboxEnabled = showCheckboxes ?? multiple;
const stayOpenOnSelect = keepOpenOnSelect ?? multiple;
const renderSummary = () => {
if (summaryFormatter) {
return summaryFormatter(selectedOptions, placeholder);
}
if (selectedOptions.length === 0) {
return <span className="opacity-60 text-base">{placeholder}</span>;
}
if (!multiple) {
return selectedOptions[0]?.label ?? placeholder;
}
if (selectedOptions.length === 1) {
return selectedOptions[0].label;
}
const [first, second, ...rest] = selectedOptions.map(opt => opt.label);
const suffix = rest.length > 0 ? ` +${rest.length}` : '';
return `${first}, ${second ?? ''}${suffix}`.trim();
};
const handleOptionClick = (option: DropdownListOption, close: () => void) => {
if (option.disabled) return;
if (multiple) {
const next = selectedValues.includes(option.value)
? selectedValues.filter(v => v !== option.value)
: [...selectedValues, option.value];
onChange(next);
if (!stayOpenOnSelect) {
close();
}
return;
}
if (selectedValues[0] === option.value) {
close();
return;
}
onChange(option.value);
close();
};
return (
<Dropdown
label={label}
summary={renderSummary()}
align={align}
widthClassName={widthClassName}
buttonClassName={buttonClassName}
>
{({ close }) => (
<div role="listbox" aria-multiselectable={multiple}>
{options.map(option => (
<button
type="button"
key={option.value}
className={`w-full px-3 py-2 text-left text-base flex items-center gap-2 hover-surface ${
option.disabled ? 'opacity-50 cursor-not-allowed' : ''
}`}
onClick={() => handleOptionClick(option, close)}
disabled={option.disabled}
>
{checkboxEnabled && (
<input
type="checkbox"
checked={selectedValues.includes(option.value)}
readOnly
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500 pointer-events-none"
/>
)}
{option.icon}
<div className="flex flex-col">
<span className="text-base">{option.label}</span>
{option.description && (
<span className="text-xs opacity-70">{option.description}</span>
)}
</div>
</button>
))}
</div>
)}
</Dropdown>
);
};
const normalizeValue = (value: string[] | string | null | undefined, multiple: boolean): string[] => {
if (multiple) {
if (Array.isArray(value)) {
return value;
}
if (typeof value === 'string') {
return [value];
}
return [];
}
if (Array.isArray(value)) {
return value.length ? [value[0]] : [];
}
if (typeof value === 'string' && value) {
return [value];
}
return [];
};
+48
View File
@@ -0,0 +1,48 @@
interface FooterProps {
buildVersion?: string;
releaseVersion?: string;
debug?: boolean;
}
export const Footer = ({ buildVersion, releaseVersion, debug }: FooterProps) => {
// Determine version display - show "dev" if no version is set
const versionDisplay = releaseVersion && releaseVersion !== 'N/A'
? releaseVersion
: 'dev';
return (
<footer
className="mt-8 border-t py-6"
style={{
borderColor: 'var(--border-muted)',
paddingBottom: 'calc(1.5rem + env(safe-area-inset-bottom))',
}}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 text-left">
<p className="text-sm opacity-80">Calibre Web Book Downloader</p>
<p className="text-xs opacity-60 mt-1">
Version: {versionDisplay}
{buildVersion && buildVersion !== 'N/A' && ` (${buildVersion})`}
{debug && ' • Debug Mode'}
</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<a
href="https://github.com/calibrain/calibre-web-automated-book-downloader"
className="opacity-80 hover:opacity-100"
aria-label="GitHub"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
className="w-6 h-6"
>
<path d="M12 1C5.923 1 1 5.923 1 12c0 4.867 3.149 8.979 7.521 10.436.55.096.756-.233.756-.522 0-.262-.013-1.128-.013-2.049-2.764.509-3.479-.674-3.699-1.292-.124-.317-.66-1.293-1.127-1.554-.385-.207-.936-.715-.014-.729.866-.014 1.485.797 1.691 1.128.99 1.663 2.571 1.196 3.204.907.096-.715.385-1.196.701-1.471-2.448-.275-5.005-1.224-5.005-5.432 0-1.196.426-2.186 1.128-2.956-.111-.275-.496-1.402.11-2.915 0 0 .921-.288 3.024 1.128a10.193 10.193 0 0 1 2.75-.371c.936 0 1.871.123 2.75.371 2.104-1.43 3.025-1.128 3.025-1.128.605 1.513.221 2.64.111 2.915.701.77 1.127 1.747 1.127 2.956 0 4.222-2.571 5.157-5.019 5.432.399.344.743 1.004.743 2.035 0 1.471-.014 2.654-.014 3.025 0 .289.206.632.756.522C19.851 20.979 23 16.854 23 12c0-6.077-4.922-11-11-11Z"></path>
</svg>
</a>
</div>
</div>
</footer>
);
};
+410
View File
@@ -0,0 +1,410 @@
import { useState, useEffect, useRef } from 'react';
import { SearchBar } from './SearchBar';
interface StatusCounts {
ongoing: number;
completed: number;
errored: number;
}
interface HeaderProps {
calibreWebUrl?: string;
debug?: boolean;
logoUrl?: string;
showSearch?: boolean;
searchInput?: string;
onSearchChange?: (value: string) => void;
onSearch?: () => void;
onAdvancedToggle?: () => void;
isLoading?: boolean;
onDownloadsClick?: () => void;
statusCounts?: StatusCounts;
onLogoClick?: () => void;
authRequired?: boolean;
isAuthenticated?: boolean;
onLogout?: () => void;
}
export const Header = ({
calibreWebUrl,
debug,
logoUrl,
showSearch = false,
searchInput = '',
onSearchChange,
onSearch,
onAdvancedToggle,
isLoading = false,
onDownloadsClick,
statusCounts = { ongoing: 0, completed: 0, errored: 0 },
onLogoClick,
authRequired = false,
isAuthenticated = false,
onLogout,
}: HeaderProps) => {
const [theme, setTheme] = useState<string>('auto');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [shouldAnimateIn, setShouldAnimateIn] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const saved = localStorage.getItem('preferred-theme') || 'auto';
setTheme(saved);
applyTheme(saved);
// Remove preload class after initial theme is applied to enable transitions
requestAnimationFrame(() => {
document.documentElement.classList.remove('preload');
});
}, []);
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
if (localStorage.getItem('preferred-theme') === 'auto') {
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
}
};
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
// Helper function to close dropdown with animation
const closeDropdown = () => {
setIsClosing(true);
setTimeout(() => {
setIsDropdownOpen(false);
setIsClosing(false);
}, 150); // Match the animation duration
};
// Close dropdown when clicking outside or pressing ESC
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
closeDropdown();
}
};
const handleEscapeKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeDropdown();
}
};
if (isDropdownOpen && !isClosing) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscapeKey);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscapeKey);
};
}, [isDropdownOpen, isClosing]);
const applyTheme = (pref: string) => {
if (pref === 'auto') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
} else {
document.documentElement.setAttribute('data-theme', pref);
}
};
const handleThemeChange = (newTheme: string) => {
localStorage.setItem('preferred-theme', newTheme);
setTheme(newTheme);
applyTheme(newTheme);
};
const cycleTheme = () => {
const themeOrder = ['light', 'dark', 'auto'];
const currentIndex = themeOrder.indexOf(theme);
const nextIndex = (currentIndex + 1) % themeOrder.length;
handleThemeChange(themeOrder[nextIndex]);
};
const handleLogout = () => {
closeDropdown();
onLogout?.();
};
const toggleDropdown = () => {
if (isDropdownOpen) {
closeDropdown();
} else {
setShouldAnimateIn(true);
setIsDropdownOpen(true);
// Reset animation flag after animation completes
setTimeout(() => setShouldAnimateIn(false), 200);
}
};
const handleHeaderSearch = () => {
onSearch?.();
};
const handleSearchChange = (value: string) => {
onSearchChange?.(value);
};
// Icon buttons component - reused for both states
const IconButtons = () => (
<div className="flex items-center gap-2">
{/* Calibre-Web Button */}
{calibreWebUrl && (
<a
href={calibreWebUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded-full hover-action transition-all duration-200 text-gray-900 dark:text-gray-100"
aria-label="Open Calibre-Web"
title="Go To Library"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
<span className="text-sm font-medium">Go To Library</span>
</a>
)}
{/* Downloads Button */}
{onDownloadsClick && (
<button
onClick={onDownloadsClick}
className="relative flex items-center gap-2 px-3 py-2 rounded-full hover-action transition-all duration-200 text-gray-900 dark:text-gray-100"
aria-label="View downloads"
title="Downloads"
>
<div className="relative">
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
{/* Show badge with appropriate color based on status */}
{(statusCounts.ongoing > 0 || statusCounts.completed > 0 || statusCounts.errored > 0) && (
<span
className={`absolute -top-1 -right-1 text-white text-[0.55rem] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center ${
statusCounts.errored > 0
? 'bg-red-500'
: statusCounts.ongoing > 0
? 'bg-blue-500'
: 'bg-green-500'
}`}
title={`${statusCounts.ongoing} ongoing, ${statusCounts.completed} completed, ${statusCounts.errored} failed`}
>
{statusCounts.ongoing + statusCounts.completed + statusCounts.errored}
</span>
)}
</div>
<span className="hidden sm:inline text-sm font-medium">Downloads</span>
</button>
)}
{/* User Menu Dropdown */}
<div className="relative" ref={dropdownRef}>
<button
onClick={toggleDropdown}
className={`relative p-2 rounded-full hover-action transition-colors ${
isDropdownOpen ? 'bg-gray-100 dark:bg-gray-700' : ''
}`}
aria-label="User menu"
aria-expanded={isDropdownOpen}
aria-haspopup="true"
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
</button>
{/* Dropdown Menu */}
{(isDropdownOpen || isClosing) && (
<div
className={`absolute right-0 mt-2 w-48 rounded-lg shadow-lg border z-50 ${
isClosing ? 'animate-fade-out-up' : shouldAnimateIn ? 'animate-fade-in-down' : ''
}`}
style={{
background: 'var(--bg)',
borderColor: 'var(--border-muted)',
}}
>
<div className="py-1">
{/* Theme Button */}
<button
type="button"
onClick={cycleTheme}
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3"
>
{theme === 'light' && (
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
)}
{theme === 'dark' && (
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
)}
{theme === 'auto' && (
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25" />
</svg>
)}
<span>Theme: {theme.charAt(0).toUpperCase() + theme.slice(1)}</span>
</button>
<a
href="https://github.com/calibrain/calibre-web-automated-book-downloader/issues"
target="_blank"
rel="noopener noreferrer"
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3 text-slate-700 dark:text-slate-200"
title="Submit a bug report"
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5"
/>
</svg>
<span>Report a Bug</span>
</a>
{/* Debug Buttons */}
{debug && (
<>
<form action="/debug" method="get" className="w-full">
<button
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3 text-orange-600 dark:text-orange-400"
type="submit"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 12.75c1.148 0 2.278.08 3.383.237 1.037.146 1.866.966 1.866 2.013 0 3.728-2.35 6.75-5.25 6.75S6.75 18.728 6.75 15c0-1.046.83-1.867 1.866-2.013A24.204 24.204 0 0112 12.75zm0 0c2.883 0 5.647.508 8.207 1.44a23.91 23.91 0 01-1.152 6.06M12 12.75c-2.883 0-5.647.508-8.208 1.44.125 2.104.52 4.136 1.153 6.06M12 12.75a2.25 2.25 0 002.248-2.354M12 12.75a2.25 2.25 0 01-2.248-2.354M12 8.25c.995 0 1.971-.08 2.922-.236.403-.066.74-.358.795-.762a3.778 3.778 0 00-.399-2.25M12 8.25c-.995 0-1.97-.08-2.922-.236-.402-.066-.74-.358-.795-.762a3.734 3.734 0 01.4-2.253M12 8.25a2.25 2.25 0 00-2.248 2.146M12 8.25a2.25 2.25 0 012.248 2.146M8.683 5a6.032 6.032 0 01-1.155-1.002c.07-.63.27-1.222.574-1.747m.581 2.749A3.75 3.75 0 0115.318 5m0 0c.427-.283.815-.62 1.155-.999a4.471 4.471 0 00-.575-1.752M4.921 6a24.048 24.048 0 00-.392 3.314c1.668.546 3.416.914 5.223 1.082M19.08 6c.205 1.08.337 2.187.392 3.314a23.882 23.882 0 01-5.223 1.082" />
</svg>
<span>Debug</span>
</button>
</form>
<form action="/api/restart" method="get" className="w-full">
<button
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3 text-orange-600 dark:text-orange-400"
type="submit"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
<span>Restart</span>
</button>
</form>
</>
)}
{/* Logout Button */}
{authRequired && isAuthenticated && onLogout && (
<button
type="button"
onClick={handleLogout}
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3 text-red-600 dark:text-red-400"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg>
<span>Sign Out</span>
</button>
)}
</div>
</div>
)}
</div>
</div>
);
return (
<header
className="w-full sticky top-0 z-40 backdrop-blur-sm header-with-fade"
style={{ background: 'var(--bg)', paddingTop: 'env(safe-area-inset-top)' }}
>
<div className={`max-w-full mx-auto px-4 sm:px-6 lg:px-8 transition-all duration-500 ${
showSearch ? 'h-auto py-4' : 'h-24'
}`}>
{/* When search is active: stack on mobile, side-by-side on desktop */}
{showSearch && (
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3">
{/* Logo + Icon buttons - appear first on mobile (above search), last on desktop (right side) */}
<div className="flex items-center justify-between w-full lg:w-auto lg:justify-end lg:order-2">
{/* Logo - visible on mobile only, aligned left */}
{logoUrl && (
<img
src={logoUrl}
onClick={onLogoClick}
alt="Logo"
className="h-10 w-10 flex-shrink-0 cursor-pointer lg:hidden"
/>
)}
<IconButtons />
</div>
{/* Search bar - appear second on mobile (below logo+icons), first on desktop (left side) */}
<div className="flex items-center gap-4 lg:order-1 flex-1">
{/* Logo - visible on desktop only, aligned with search */}
{logoUrl && (
<img
src={logoUrl}
onClick={onLogoClick}
alt="Logo"
className="hidden lg:block h-12 w-12 flex-shrink-0 cursor-pointer"
/>
)}
<SearchBar
className="flex-1 lg:flex-initial"
inputClassName="lg:w-[50vw]"
value={searchInput}
onChange={handleSearchChange}
onSubmit={handleHeaderSearch}
onAdvancedToggle={onAdvancedToggle}
isLoading={isLoading}
/>
</div>
</div>
)}
{/* When search is NOT active: show icon buttons only on the right */}
{!showSearch && (
<div className="flex items-center justify-end h-full">
<IconButtons />
</div>
)}
</div>
</header>
);
};
@@ -0,0 +1,117 @@
import { Language } from '../types';
import {
formatDefaultLanguageLabel,
LANGUAGE_OPTION_ALL,
LANGUAGE_OPTION_DEFAULT,
normalizeLanguageSelection,
} from '../utils/languageFilters';
import { DropdownList, DropdownListOption } from './DropdownList';
interface LanguageMultiSelectProps {
options: Language[];
value: string[];
onChange: (value: string[]) => void;
defaultLanguageCodes: string[];
label?: string;
placeholder?: string;
}
export const LanguageMultiSelect = ({
options,
value,
onChange,
defaultLanguageCodes,
label,
placeholder,
}: LanguageMultiSelectProps) => {
const defaultLabel = formatDefaultLanguageLabel(defaultLanguageCodes, options);
const defaultCodeSet = new Set(defaultLanguageCodes);
const nonDefaultLanguages = options.filter(lang => !defaultCodeSet.has(lang.code));
const selectableValues = [LANGUAGE_OPTION_DEFAULT, ...nonDefaultLanguages.map(lang => lang.code)];
const optionList: DropdownListOption[] = [
{
value: LANGUAGE_OPTION_ALL,
label: 'All languages',
},
{
value: LANGUAGE_OPTION_DEFAULT,
label: defaultLabel,
},
...nonDefaultLanguages.map(lang => ({
value: lang.code,
label: lang.language,
})),
];
const includesAllSelection = value.includes(LANGUAGE_OPTION_ALL);
const effectiveValue = includesAllSelection ? selectableValues : value;
const selectedSet = new Set(effectiveValue);
const isAllSelected = selectableValues.every(code => selectedSet.has(code));
const displayedValue = isAllSelected ? [LANGUAGE_OPTION_ALL, ...effectiveValue] : effectiveValue;
const summaryFormatter = (_selected: DropdownListOption[], fallback: string) => {
if (isAllSelected) {
return 'All languages';
}
const labels: string[] = [];
if (selectedSet.has(LANGUAGE_OPTION_DEFAULT)) {
labels.push(defaultLabel);
}
nonDefaultLanguages.forEach(lang => {
if (selectedSet.has(lang.code)) {
labels.push(lang.language);
}
});
if (labels.length === 0) {
return placeholder || fallback;
}
if (labels.length === 1) {
return labels[0];
}
const [first, second, ...rest] = labels;
const suffix = rest.length > 0 ? ` +${rest.length}` : '';
return `${first}, ${second ?? ''}${suffix}`.trim();
};
const handleChange = (nextValue: string[] | string) => {
const nextArray = Array.isArray(nextValue) ? nextValue : [nextValue];
const includesAll = nextArray.includes(LANGUAGE_OPTION_ALL);
const toggledAllOn = includesAll && !isAllSelected;
const toggledAllOff =
isAllSelected && !includesAll && nextArray.length === effectiveValue.length;
let resolved = nextArray.filter(code => code !== LANGUAGE_OPTION_ALL);
if (toggledAllOn) {
resolved = [LANGUAGE_OPTION_ALL];
} else if (toggledAllOff) {
resolved = [];
}
const normalized = normalizeLanguageSelection(resolved);
onChange(normalized);
};
return (
<DropdownList
label={label}
options={optionList}
multiple
showCheckboxes
value={displayedValue}
onChange={handleChange}
placeholder={placeholder}
summaryFormatter={summaryFormatter}
keepOpenOnSelect
/>
);
};
+231
View File
@@ -0,0 +1,231 @@
import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from 'react';
import { LoginCredentials } from '../types';
interface LoginFormProps {
onSubmit: (credentials: LoginCredentials) => void;
error?: string | null;
isLoading?: boolean;
autoFocus?: boolean;
}
const EyeIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-5 h-5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
);
const EyeSlashIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-5 h-5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"
/>
</svg>
);
export const LoginForm = ({
onSubmit,
error = null,
isLoading = false,
autoFocus = true,
}: LoginFormProps) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [rememberMe, setRememberMe] = useState(true);
const [showPassword, setShowPassword] = useState(false);
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (autoFocus) {
usernameRef.current?.focus();
}
}, [autoFocus]);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const usernameValue = (formData.get('username') as string)?.trim() || '';
const passwordValue = (formData.get('password') as string) || '';
if (usernameValue && passwordValue && !isLoading) {
onSubmit({
username: usernameValue,
password: passwordValue,
remember_me: rememberMe,
});
}
};
const handleUsernameKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
passwordRef.current?.focus();
}
};
return (
<div>
{error && (
<div className="mb-4 p-3 rounded-lg text-sm bg-red-600 text-white">
{error}
</div>
)}
<form
method="post"
action="/api/login"
autoComplete="on"
id="login-form"
name="login"
data-form-type="login"
onSubmit={handleSubmit}
>
<div className="mb-4">
<label htmlFor="username" className="block text-sm font-medium mb-2">
Username
</label>
<input
ref={usernameRef}
type="text"
id="username"
name="username"
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
inputMode="text"
enterKeyHint="next"
value={username}
onChange={(event) => setUsername(event.target.value)}
onKeyDown={handleUsernameKeyDown}
disabled={isLoading}
className="w-full px-4 py-2.5 rounded-lg border focus:outline-none focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{
backgroundColor: 'var(--input-background)',
borderColor: 'var(--border-color)',
color: 'var(--text-color)',
}}
required
/>
</div>
<div className="mb-4">
<label htmlFor="password" className="block text-sm font-medium mb-2">
Password
</label>
<div className="relative">
<input
ref={passwordRef}
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
autoComplete="current-password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
inputMode="text"
enterKeyHint="go"
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={isLoading}
className="w-full px-4 py-2.5 rounded-lg border focus:outline-none focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed pr-10 transition-colors"
style={{
backgroundColor: 'var(--input-background)',
borderColor: 'var(--border-color)',
color: 'var(--text-color)',
}}
required
/>
<button
type="button"
onClick={() => setShowPassword((current) => !current)}
disabled={isLoading}
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1.5 rounded-full hover-action disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeSlashIcon /> : <EyeIcon />}
</button>
</div>
</div>
<div className="mb-6 flex items-center">
<input
type="checkbox"
id="remember-me"
name="remember_me"
checked={rememberMe}
onChange={(event) => setRememberMe(event.target.checked)}
disabled={isLoading}
className="w-4 h-4 rounded focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed accent-sky-900"
style={{ borderColor: 'var(--border-color)' }}
/>
<label htmlFor="remember-me" className="ml-2 text-sm">
Remember me for 7 days
</label>
</div>
<button
type="submit"
name="submit"
disabled={isLoading}
className="w-full py-2.5 px-4 rounded-lg font-medium text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed bg-sky-700 hover:bg-sky-800 disabled:hover:bg-sky-700"
aria-label="Sign in"
>
{isLoading ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Signing in...
</span>
) : (
'Sign In'
)}
</button>
</form>
</div>
);
};
@@ -0,0 +1,263 @@
import { useState, useEffect } from 'react';
import { Book, ButtonStateInfo } from '../types';
import { CardView } from './resultsViews/CardView';
import { CompactView } from './resultsViews/CompactView';
import { ListView } from './resultsViews/ListView';
import { Dropdown } from './Dropdown';
import { SORT_OPTIONS } from '../data/filterOptions';
interface ResultsSectionProps {
books: Book[];
visible: boolean;
onDetails: (id: string) => Promise<void>;
onDownload: (book: Book) => Promise<void>;
getButtonState: (bookId: string) => ButtonStateInfo;
sortValue: string;
onSortChange: (value: string) => void;
}
export const ResultsSection = ({
books,
visible,
onDetails,
onDownload,
getButtonState,
sortValue,
onSortChange,
}: ResultsSectionProps) => {
const [viewMode, setViewMode] = useState<'card' | 'compact' | 'list'>(() => {
const saved = localStorage.getItem('bookViewMode');
return saved === 'card' || saved === 'compact' || saved === 'list' ? saved : 'compact';
});
const [isDesktop, setIsDesktop] = useState(false);
useEffect(() => {
localStorage.setItem('bookViewMode', viewMode);
}, [viewMode]);
// Track whether we're in desktop layout (sm breakpoint and above)
useEffect(() => {
const checkDesktop = () => {
setIsDesktop(window.innerWidth >= 640); // sm breakpoint
};
checkDesktop();
window.addEventListener('resize', checkDesktop);
return () => window.removeEventListener('resize', checkDesktop);
}, []);
if (!visible) return null;
return (
<section id="results-section" className="mb-4 sm:mb-8 w-full">
<div className="flex items-center justify-between mb-2 sm:mb-3 relative z-10">
<SortControl value={sortValue} onChange={onSortChange} />
{/* View toggle buttons - Desktop: show all 3, Mobile: show Compact and List only */}
<div className="flex items-center gap-2">
{isDesktop && (
<button
onClick={() => setViewMode('card')}
className={`p-2 rounded-full transition-all duration-200 ${
viewMode === 'card'
? 'text-white bg-sky-700 hover:bg-sky-800'
: 'hover-action text-gray-900 dark:text-gray-100'
}`}
title="Card view"
aria-label="Card view"
aria-pressed={viewMode === 'card'}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth="1.5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z"
/>
</svg>
</button>
)}
<button
onClick={() => setViewMode('compact')}
className={`p-2 rounded-full transition-all duration-200 ${
viewMode === 'compact'
? 'text-white bg-sky-700 hover:bg-sky-800'
: 'hover-action text-gray-900 dark:text-gray-100'
}`}
title="Compact view"
aria-label="Compact view"
aria-pressed={viewMode === 'compact'}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth="1.5"
>
<rect x="3.75" y="4.5" width="6" height="6" rx="1.125" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6h8.25M12 8.25h6" />
<rect x="3.75" y="13.5" width="6" height="6" rx="1.125" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15h8.25M12 17.25h6" />
</svg>
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded-full transition-all duration-200 ${
viewMode === 'list'
? 'text-white bg-sky-700 hover:bg-sky-800'
: 'hover-action text-gray-900 dark:text-gray-100'
}`}
title="List view"
aria-label="List view"
aria-pressed={viewMode === 'list'}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth="1.5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"
/>
</svg>
</button>
</div>
</div>
{viewMode === 'list' ? (
<ListView books={books} onDetails={onDetails} onDownload={onDownload} getButtonState={getButtonState} />
) : (
<div
id="results-grid"
className={`grid gap-8 ${
!isDesktop
? 'grid-cols-1 items-start'
: viewMode === 'card'
? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 items-stretch'
: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 items-start'
}`}
>
{books.map((book, index) => {
const shouldUseCardLayout = isDesktop && viewMode === 'card';
const animationDelay = index * 50;
return shouldUseCardLayout ? (
<CardView
key={book.id}
book={book}
onDetails={onDetails}
onDownload={onDownload}
buttonState={getButtonState(book.id)}
animationDelay={animationDelay}
/>
) : (
<CompactView
key={book.id}
book={book}
onDetails={onDetails}
onDownload={onDownload}
buttonState={getButtonState(book.id)}
showDetailsButton={!isDesktop}
animationDelay={animationDelay}
/>
);
})}
</div>
)}
{books.length === 0 && (
<div className="mt-4 text-sm opacity-80">No results found.</div>
)}
</section>
);
};
interface SortControlProps {
value: string;
onChange: (value: string) => void;
}
const SortControl = ({ value, onChange }: SortControlProps) => {
const selected = SORT_OPTIONS.find(option => option.value === value) ?? SORT_OPTIONS[0];
return (
<Dropdown
align="left"
widthClassName="w-60 sm:w-72"
renderTrigger={({ isOpen, toggle }) => (
<button
type="button"
onClick={toggle}
className={`relative flex items-center gap-2 px-3 py-2 rounded-full transition-all duration-200 text-gray-900 dark:text-gray-100 hover-action ${
isOpen ? 'bg-gray-100 dark:bg-gray-700' : ''
} animate-fade-in-up`}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-label="Change sort order"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-5 h-5 sm:w-6 sm:h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 7.5 7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5"
/>
</svg>
<span className="text-sm font-medium whitespace-nowrap">{selected.label}</span>
</button>
)}
>
{({ close }) => (
<div role="listbox" aria-label="Sort results">
{SORT_OPTIONS.map(option => {
const isSelected = option.value === selected.value;
return (
<button
type="button"
key={option.value || 'default'}
className={`w-full px-3 py-2 text-left text-base flex items-center justify-between gap-2 hover-surface ${
isSelected ? 'text-sky-600 dark:text-sky-300 font-medium' : ''
}`}
onClick={() => {
onChange(option.value);
close();
}}
role="option"
aria-selected={isSelected}
>
<span>{option.label}</span>
{isSelected && (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-4 h-4"
>
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
)}
</button>
);
})}
</div>
)}
</Dropdown>
);
};
+174
View File
@@ -0,0 +1,174 @@
import { KeyboardEvent, InputHTMLAttributes, useRef } from 'react';
interface SearchBarProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
isLoading?: boolean;
onAdvancedToggle?: () => void;
placeholder?: string;
inputAriaLabel?: string;
className?: string;
inputClassName?: string;
controlsClassName?: string;
clearButtonLabel?: string;
clearButtonTitle?: string;
advancedButtonLabel?: string;
advancedButtonTitle?: string;
searchButtonLabel?: string;
searchButtonTitle?: string;
autoComplete?: string;
enterKeyHint?: InputHTMLAttributes<HTMLInputElement>['enterKeyHint'];
}
export const SearchBar = ({
value,
onChange,
onSubmit,
isLoading = false,
onAdvancedToggle,
placeholder = 'Search by ISBN, title, author...',
inputAriaLabel = 'Search books',
className = '',
inputClassName = '',
controlsClassName = '',
clearButtonLabel = 'Clear search input',
clearButtonTitle = 'Clear search',
advancedButtonLabel = 'Advanced Search',
advancedButtonTitle = 'Advanced Search',
searchButtonLabel = 'Search books',
searchButtonTitle = 'Search',
autoComplete = 'off',
enterKeyHint = 'search',
}: SearchBarProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const hasSearchQuery = value.trim().length > 0;
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onSubmit();
(e.target as HTMLInputElement).blur();
}
};
const handleClearSearch = () => {
onChange('');
inputRef.current?.focus();
};
const wrapperClasses = ['relative', className].filter(Boolean).join(' ').trim();
const inputClasses = [
'w-full pl-4 pr-40 py-3 rounded-full border outline-none search-input',
inputClassName,
]
.filter(Boolean)
.join(' ')
.trim();
const controlsClasses = [
'absolute inset-y-0 right-0 flex items-center gap-1 pr-2',
controlsClassName,
]
.filter(Boolean)
.join(' ')
.trim();
return (
<div className={wrapperClasses}>
<input
type="search"
placeholder={placeholder}
aria-label={inputAriaLabel}
autoComplete={autoComplete}
enterKeyHint={enterKeyHint}
className={inputClasses}
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={value}
onChange={e => onChange(e.target.value)}
onKeyDown={handleKeyDown}
ref={inputRef}
/>
<div className={controlsClasses}>
{hasSearchQuery && (
<button
type="button"
onClick={handleClearSearch}
className="p-2 rounded-full hover-action flex items-center justify-center transition-colors"
aria-label={clearButtonLabel}
title={clearButtonTitle}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className="w-5 h-5"
style={{ color: 'var(--text)' }}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
)}
{onAdvancedToggle && (
<button
type="button"
onClick={onAdvancedToggle}
className="p-2 rounded-full hover-action flex items-center justify-center transition-colors"
aria-label={advancedButtonLabel}
title={advancedButtonTitle}
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
style={{ color: 'var(--text)' }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
/>
</svg>
</button>
)}
<button
type="button"
onClick={onSubmit}
className="p-2 rounded-full text-white bg-sky-700 hover:bg-sky-800 disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center transition-colors search-bar-button"
aria-label={searchButtonLabel}
title={searchButtonTitle}
disabled={isLoading}
>
{!isLoading && (
<svg
className="w-5 h-5 search-bar-icon"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
)}
{isLoading && (
<div className="spinner w-3 h-3 border-2 border-white border-t-transparent search-bar-spinner" />
)}
</button>
</div>
</div>
);
};
@@ -0,0 +1,86 @@
import { AdvancedFilterState, Language } from '../types';
import { buildSearchQuery } from '../utils/buildSearchQuery';
import { AdvancedFilters } from './AdvancedFilters';
import { SearchBar } from './SearchBar';
interface SearchSectionProps {
onSearch: (query: string) => void;
isLoading: boolean;
isInitialState: boolean;
bookLanguages: Language[];
defaultLanguage: string[];
supportedFormats: string[];
logoUrl: string;
searchInput: string;
onSearchInputChange: (value: string) => void;
showAdvanced: boolean;
onAdvancedToggle: () => void;
advancedFilters: AdvancedFilterState;
onAdvancedFiltersChange: (updates: Partial<AdvancedFilterState>) => void;
}
export const SearchSection = ({
onSearch,
isLoading,
isInitialState,
bookLanguages,
defaultLanguage,
supportedFormats,
logoUrl,
searchInput,
onSearchInputChange,
showAdvanced,
onAdvancedToggle,
advancedFilters,
onAdvancedFiltersChange,
}: SearchSectionProps) => {
const handleSearch = () => {
const query = buildSearchQuery({
searchInput,
showAdvanced,
advancedFilters,
bookLanguages,
defaultLanguage,
});
onSearch(query);
};
return (
<section
id="search-section"
className={`transition-all duration-500 ease-in-out ${
isInitialState
? 'search-initial-state mb-6'
: 'mb-3 sm:mb-4'
} ${showAdvanced ? 'search-advanced-visible' : ''}`}
>
<div className={`flex items-center justify-center gap-3 transition-all duration-300 ${
isInitialState ? 'opacity-100 mb-6 sm:mb-8' : 'opacity-0 h-0 mb-0 overflow-hidden'
}`}>
<img src={logoUrl} alt="Logo" className="h-8 w-8" />
<h1 className="text-2xl font-semibold">Book Search & Download</h1>
</div>
<div className={`flex flex-col gap-3 search-wrapper transition-all duration-500 ${
isInitialState ? '' : 'hidden'
}`}>
<SearchBar
value={searchInput}
onChange={onSearchInputChange}
onSubmit={handleSearch}
isLoading={isLoading}
onAdvancedToggle={onAdvancedToggle}
/>
<AdvancedFilters
visible={showAdvanced}
bookLanguages={bookLanguages}
defaultLanguage={defaultLanguage}
supportedFormats={supportedFormats}
filters={advancedFilters}
onFiltersChange={onAdvancedFiltersChange}
formClassName="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2"
renderWrapper={form => form}
/>
</div>
</section>
);
};
@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react';
import { Toast } from '../types';
interface ToastContainerProps {
toasts: Toast[];
}
export const ToastContainer = ({ toasts }: ToastContainerProps) => {
const [visibleToasts, setVisibleToasts] = useState<Set<string>>(new Set());
useEffect(() => {
toasts.forEach(toast => {
if (!visibleToasts.has(toast.id)) {
setTimeout(() => {
setVisibleToasts(prev => new Set([...prev, toast.id]));
}, 10);
}
});
}, [toasts]);
const toastTypeClasses: Record<Toast['type'], string> = {
success: 'bg-green-600 text-white',
error: 'bg-red-600 text-white',
info: 'bg-blue-600 text-white',
};
return (
<div id="toast-container" className="fixed bottom-4 right-4 z-50 space-y-2">
{toasts.map(toast => (
<div
key={toast.id}
className={`toast-notification px-4 py-3 rounded-md shadow-lg text-sm font-medium transition-all duration-300 ${
toastTypeClasses[toast.type]
} ${visibleToasts.has(toast.id) ? 'toast-visible' : ''}`}
>
{toast.message}
</div>
))}
</div>
);
};
+11
View File
@@ -0,0 +1,11 @@
export { Header } from './Header';
export { SearchSection } from './SearchSection';
export { SearchBar } from './SearchBar';
export { ResultsSection } from './ResultsSection';
export { DetailsModal } from './DetailsModal';
export { DownloadsSidebar } from './DownloadsSidebar';
export { ToastContainer } from './ToastContainer';
export { Footer } from './Footer';
export { CardView } from './resultsViews/CardView';
export { CompactView } from './resultsViews/CompactView';
export { ListView } from './resultsViews/ListView';
@@ -0,0 +1,151 @@
import { useState } from 'react';
import { Book, ButtonStateInfo } from '../../types';
import { BookDownloadButton } from '../BookDownloadButton';
const SkeletonLoader = () => (
<div className="w-full h-full bg-gradient-to-r from-gray-300 via-gray-200 to-gray-300 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
);
interface CardViewProps {
book: Book;
onDetails: (id: string) => Promise<void>;
onDownload: (book: Book) => Promise<void>;
buttonState: ButtonStateInfo;
animationDelay?: number;
}
export const CardView = ({ book, onDetails, onDownload, buttonState, animationDelay = 0 }: CardViewProps) => {
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const handleDetails = async (id: string) => {
setIsLoadingDetails(true);
try {
await onDetails(id);
} finally {
setIsLoadingDetails(false);
}
};
return (
<article
className="book-card overflow-hidden flex flex-col sm:flex-col max-sm:flex-row space-between w-full sm:max-w-[292px] max-sm:h-[180px] h-full transition-shadow duration-300 animate-slide-up will-change-transform"
style={{
background: 'var(--bg-soft)',
borderRadius: '.75rem',
boxShadow: isHovered ? '0 10px 30px rgba(0, 0, 0, 0.15)' : 'none',
animationDelay: `${animationDelay}ms`,
animationFillMode: 'both',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative w-full sm:w-full max-sm:w-[120px] max-sm:h-full max-sm:flex-shrink-0 group" style={{ aspectRatio: '2/3' }}>
{book.preview && !imageError ? (
<>
{!imageLoaded && (
<div className="absolute inset-0">
<SkeletonLoader />
</div>
)}
<img
src={book.preview}
alt={book.title || 'Book cover'}
className="w-full h-full"
style={{
opacity: imageLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
objectPosition: 'top',
}}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center text-sm opacity-50" style={{ background: 'var(--border-muted)' }}>
No Cover
</div>
)}
<div
className="absolute inset-0 bg-white transition-opacity duration-300 pointer-events-none"
style={{ opacity: isHovered ? 0.02 : 0 }}
/>
<button
className="absolute bottom-2 right-2 w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110 max-sm:hidden"
style={{
opacity: isHovered || isLoadingDetails ? 1 : 0,
pointerEvents: isHovered || isLoadingDetails ? 'auto' : 'none',
}}
onClick={(e) => {
e.stopPropagation();
handleDetails(book.id);
}}
disabled={isLoadingDetails}
aria-label="Book details"
>
{isLoadingDetails ? (
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
</button>
</div>
<div className="p-4 max-sm:p-3 max-sm:py-2 flex flex-col gap-3 max-sm:gap-2 max-sm:flex-1 max-sm:justify-between max-sm:min-w-0 sm:flex-1 sm:flex sm:flex-col sm:justify-end">
<div className="space-y-1 max-sm:space-y-0.5 max-sm:min-w-0">
<h3 className="font-semibold leading-tight line-clamp-2 text-base max-sm:line-clamp-3 max-sm:min-w-0" title={book.title || 'Untitled'}>
{book.title || 'Untitled'}
</h3>
<p className="text-sm max-sm:text-xs opacity-80 truncate max-sm:min-w-0">{book.author || 'Unknown author'}</p>
<div className="text-xs max-sm:text-[10px] opacity-70 flex flex-wrap gap-2 max-sm:gap-1">
<span>{book.year || '-'}</span>
<span>•</span>
<span>{book.language || '-'}</span>
<span>•</span>
<span>{book.format || '-'}</span>
{book.size && (
<>
<span>•</span>
<span>{book.size}</span>
</>
)}
</div>
</div>
<div className="flex gap-1.5 sm:hidden">
<button
className="px-2 py-1.5 rounded border text-xs flex-1 flex items-center justify-center gap-1"
onClick={() => handleDetails(book.id)}
style={{ borderColor: 'var(--border-muted)' }}
disabled={isLoadingDetails}
>
<span className="details-button-text">{isLoadingDetails ? 'Loading' : 'Details'}</span>
<div
className={`details-spinner w-3 h-3 border-2 border-current border-t-transparent rounded-full ${isLoadingDetails ? '' : 'hidden'}`}
/>
</button>
<BookDownloadButton buttonState={buttonState} onDownload={() => onDownload(book)} size="sm" className="flex-1" />
</div>
</div>
<BookDownloadButton
buttonState={buttonState}
onDownload={() => onDownload(book)}
className="hidden sm:flex rounded-none"
fullWidth
style={{
borderBottomLeftRadius: '.75rem',
borderBottomRightRadius: '.75rem',
}}
/>
</article>
);
};
@@ -0,0 +1,146 @@
import { useState } from 'react';
import { Book, ButtonStateInfo } from '../../types';
import { BookDownloadButton } from '../BookDownloadButton';
const SkeletonLoader = () => (
<div className="w-full h-full bg-gradient-to-r from-gray-300 via-gray-200 to-gray-300 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
);
interface CompactViewProps {
book: Book;
onDetails: (id: string) => Promise<void>;
onDownload: (book: Book) => Promise<void>;
buttonState: ButtonStateInfo;
showDetailsButton?: boolean;
animationDelay?: number;
}
export const CompactView = ({ book, onDetails, onDownload, buttonState, showDetailsButton = false, animationDelay = 0 }: CompactViewProps) => {
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const handleDetails = async (id: string) => {
setIsLoadingDetails(true);
try {
await onDetails(id);
} finally {
setIsLoadingDetails(false);
}
};
return (
<article
className="book-card overflow-hidden !flex !flex-row w-full !h-[180px] transition-shadow duration-300 animate-slide-up will-change-transform"
style={{
background: 'var(--bg-soft)',
borderRadius: '.75rem',
boxShadow: isHovered ? '0 10px 30px rgba(0, 0, 0, 0.15)' : 'none',
animationDelay: `${animationDelay}ms`,
animationFillMode: 'both',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative w-[120px] h-full flex-shrink-0">
{book.preview && !imageError ? (
<>
{!imageLoaded && (
<div className="absolute inset-0">
<SkeletonLoader />
</div>
)}
<img
src={book.preview}
alt={book.title || 'Book cover'}
className="w-full h-full"
style={{
opacity: imageLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
objectPosition: 'top',
}}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center text-sm opacity-50" style={{ background: 'var(--border-muted)' }}>
No Cover
</div>
)}
<div className="absolute inset-0 bg-white transition-opacity duration-300 pointer-events-none" style={{ opacity: isHovered ? 0.02 : 0 }} />
{!showDetailsButton && (
<button
className="absolute bottom-2 right-2 w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
style={{
opacity: isHovered || isLoadingDetails ? 1 : 0,
pointerEvents: isHovered || isLoadingDetails ? 'auto' : 'none',
}}
onClick={(e) => {
e.stopPropagation();
handleDetails(book.id);
}}
disabled={isLoadingDetails}
aria-label="Book details"
>
{isLoadingDetails ? (
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
</button>
)}
</div>
<div className="p-3 py-2 flex flex-col flex-1 min-w-0">
<div className="space-y-0.5 min-w-0">
<h3 className="font-semibold leading-tight line-clamp-3 text-base min-w-0" title={book.title || 'Untitled'}>
{book.title || 'Untitled'}
</h3>
<p className="text-xs opacity-80 truncate min-w-0">{book.author || 'Unknown author'}</p>
<div className="text-[10px] opacity-70">
<span>{book.year || '-'}</span>
</div>
</div>
<div className="mt-auto flex flex-col gap-2">
<div className="text-[10px] opacity-70 flex flex-wrap gap-1">
<span>{book.language || '-'}</span>
<span>•</span>
<span>{book.format || '-'}</span>
{book.size && (
<>
<span>•</span>
<span>{book.size}</span>
</>
)}
</div>
{showDetailsButton ? (
<div className="flex gap-1.5">
<button
className="px-2 py-1.5 rounded border text-xs flex-shrink-0 flex items-center justify-center gap-1"
onClick={() => handleDetails(book.id)}
style={{ borderColor: 'var(--border-muted)' }}
disabled={isLoadingDetails}
>
<span className="details-button-text">{isLoadingDetails ? 'Loading' : 'Details'}</span>
{isLoadingDetails && <div className="w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin" />}
</button>
<BookDownloadButton buttonState={buttonState} onDownload={() => onDownload(book)} size="sm" className="flex-1" />
</div>
) : (
<BookDownloadButton buttonState={buttonState} onDownload={() => onDownload(book)} size="sm" fullWidth />
)}
</div>
</div>
</article>
);
};
@@ -0,0 +1,215 @@
import { useState } from 'react';
import { Book, ButtonStateInfo } from '../../types';
import { BookDownloadButton } from '../BookDownloadButton';
interface ListViewProps {
books: Book[];
onDetails: (id: string) => Promise<void>;
onDownload: (book: Book) => Promise<void>;
getButtonState: (bookId: string) => ButtonStateInfo;
}
const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: string }) => {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
if (!preview || imageError) {
return (
<div
className="w-7 h-10 sm:w-10 sm:h-14 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] sm:text-[9px] font-medium text-gray-500 dark:text-gray-300"
aria-label="No cover available"
>
No Cover
</div>
);
}
return (
<div className="relative w-7 h-10 sm:w-10 sm:h-14 rounded overflow-hidden bg-gray-100 dark:bg-gray-800 border border-white/40 dark:border-gray-700/70">
{!imageLoaded && (
<div className="absolute inset-0 bg-gradient-to-r from-gray-200 via-gray-100 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
)}
<img
src={preview}
alt={title || 'Book cover'}
className="w-full h-full object-cover object-top"
loading="lazy"
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
style={{ opacity: imageLoaded ? 1 : 0, transition: 'opacity 0.2s ease-in-out' }}
/>
</div>
);
};
const getLanguageColor = (language?: string): string => {
if (!language || language === '-') return 'bg-gray-400 dark:bg-gray-600';
const lang = language.toLowerCase();
const colorMap: Record<string, string> = {
en: 'bg-blue-500 dark:bg-blue-600',
english: 'bg-blue-500 dark:bg-blue-600',
es: 'bg-orange-500 dark:bg-orange-600',
spanish: 'bg-orange-500 dark:bg-orange-600',
fr: 'bg-purple-500 dark:bg-purple-600',
french: 'bg-purple-500 dark:bg-purple-600',
de: 'bg-yellow-500 dark:bg-yellow-600',
german: 'bg-yellow-500 dark:bg-yellow-600',
it: 'bg-green-500 dark:bg-green-600',
italian: 'bg-green-500 dark:bg-green-600',
pt: 'bg-teal-500 dark:bg-teal-600',
portuguese: 'bg-teal-500 dark:bg-teal-600',
ru: 'bg-red-500 dark:bg-red-600',
russian: 'bg-red-500 dark:bg-red-600',
ja: 'bg-pink-500 dark:bg-pink-600',
japanese: 'bg-pink-500 dark:bg-pink-600',
zh: 'bg-rose-500 dark:bg-rose-600',
chinese: 'bg-rose-500 dark:bg-rose-600',
};
return colorMap[lang] || 'bg-indigo-500 dark:bg-indigo-600';
};
const getFormatColor = (format?: string): string => {
if (!format || format === '-') return 'bg-gray-400 dark:bg-gray-600';
const fmt = format.toLowerCase();
const colorMap: Record<string, string> = {
pdf: 'bg-red-500 dark:bg-red-600',
epub: 'bg-green-500 dark:bg-green-600',
mobi: 'bg-blue-500 dark:bg-blue-600',
azw3: 'bg-purple-500 dark:bg-purple-600',
txt: 'bg-gray-500 dark:bg-gray-600',
djvu: 'bg-orange-500 dark:bg-orange-600',
fb2: 'bg-teal-500 dark:bg-teal-600',
cbr: 'bg-yellow-500 dark:bg-yellow-600',
cbz: 'bg-amber-500 dark:bg-amber-600',
};
return colorMap[fmt] || 'bg-cyan-500 dark:bg-cyan-600';
};
export const ListView = ({ books, onDetails, onDownload, getButtonState }: ListViewProps) => {
const [detailsLoadingId, setDetailsLoadingId] = useState<string | null>(null);
if (books.length === 0) {
return null;
}
const handleDetails = async (bookId: string) => {
setDetailsLoadingId(bookId);
try {
await onDetails(bookId);
} finally {
setDetailsLoadingId((current) => (current === bookId ? null : current));
}
};
return (
<article
className="w-full overflow-hidden rounded-lg sm:rounded-2xl"
style={{
background: 'var(--bg-soft)',
boxShadow: '0 10px 30px rgba(15, 23, 42, 0.08)',
}}
role="region"
aria-label="List view of books"
>
<div className="divide-y divide-gray-200/60 dark:divide-gray-800/60 w-full">
{books.map((book, index) => {
const buttonState = getButtonState(book.id);
const isLoadingDetails = detailsLoadingId === book.id;
return (
<div
key={book.id}
className="px-1.5 sm:px-2 py-1.5 sm:py-2 transition-colors duration-200 hover-row w-full animate-slide-up will-change-transform"
style={{
animationDelay: `${index * 50}ms`,
animationFillMode: 'both',
}}
role="article"
>
{/* Mobile and Desktop: Single row layout */}
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto_auto] sm:grid-cols-[auto_minmax(0,2fr)_minmax(50px,0.25fr)_minmax(60px,0.3fr)_minmax(60px,0.3fr)_minmax(60px,0.3fr)_auto] items-center gap-2 sm:gap-y-1 sm:gap-x-0.5 w-full">
{/* Thumbnail */}
<div className="flex items-center pl-1 sm:pl-3">
<ListViewThumbnail preview={book.preview} title={book.title} />
</div>
{/* Title and Author */}
<div className="min-w-0 flex flex-col justify-center sm:pl-3">
<h3 className="font-semibold text-xs min-[400px]:text-sm sm:text-base leading-tight line-clamp-1 sm:line-clamp-2" title={book.title || 'Untitled'}>
{book.title || 'Untitled'}
</h3>
<p className="text-[10px] min-[400px]:text-xs sm:text-sm text-gray-600 dark:text-gray-300 truncate">
{book.author || 'Unknown author'}
{book.year && <span className="sm:hidden"> • {book.year}</span>}
</p>
</div>
{/* Format and Size - Mobile only */}
<div className="flex sm:hidden flex-col items-end text-[10px] opacity-70 leading-tight">
<span>{book.format || '-'}</span>
{book.size && <span>{book.size}</span>}
</div>
{/* Year - Desktop only */}
<div className="hidden sm:flex text-xs text-gray-700 dark:text-gray-200 justify-center">
{book.year || '-'}
</div>
{/* Language Badge - Desktop only */}
<div className="hidden sm:flex justify-center">
<span
className={`${getLanguageColor(book.language)} text-white text-[11px] font-semibold px-2 py-0.5 rounded uppercase tracking-wide`}
title={book.language || 'Unknown'}
>
{book.language || '-'}
</span>
</div>
{/* Format Badge - Desktop only */}
<div className="hidden sm:flex justify-center">
<span
className={`${getFormatColor(book.format)} text-white text-[11px] font-semibold px-2 py-0.5 rounded uppercase tracking-wide`}
title={book.format || 'Unknown'}
>
{book.format || '-'}
</span>
</div>
{/* Size - Desktop only */}
<div className="hidden sm:flex text-xs text-gray-700 dark:text-gray-200 justify-center">
{book.size || '-'}
</div>
{/* Action Buttons */}
<div className="flex flex-row justify-end gap-0.5 sm:gap-1">
<button
className="flex items-center justify-center p-1.5 sm:p-2 rounded-full text-gray-600 dark:text-gray-200 hover-action transition-all duration-200"
onClick={() => handleDetails(book.id)}
disabled={isLoadingDetails}
aria-label={`View details for ${book.title || 'this book'}`}
>
{isLoadingDetails ? (
<div className="w-4 h-4 sm:w-5 sm:h-5 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z" />
</svg>
)}
</button>
<BookDownloadButton
buttonState={buttonState}
onDownload={() => onDownload(book)}
variant="icon"
size="md"
ariaLabel={buttonState.text}
/>
</div>
</div>
</div>
);
})}
</div>
</article>
);
};
+23
View File
@@ -0,0 +1,23 @@
export const SORT_OPTIONS = [
{ value: '', label: 'Most relevant' },
{ value: 'newest', label: 'Newest (publication year)' },
{ value: 'oldest', label: 'Oldest (publication year)' },
{ value: 'largest', label: 'Largest (filesize)' },
{ value: 'smallest', label: 'Smallest (filesize)' },
{ value: 'newest_added', label: 'Newest (open sourced)' },
{ value: 'oldest_added', label: 'Oldest (open sourced)' },
];
export const CONTENT_OPTIONS = [
{ value: '', label: 'All' },
{ value: 'book_nonfiction', label: 'Book (non-fiction)' },
{ value: 'book_fiction', label: 'Book (fiction)' },
{ value: 'book_unknown', label: 'Book (unknown)' },
{ value: 'magazine', label: 'Magazine' },
{ value: 'book_comic', label: 'Comic Book' },
{ value: 'standards_document', label: 'Standards document' },
{ value: 'other', label: 'Other' },
{ value: 'musical_score', label: 'Musical score' },
{ value: 'audiobook', label: 'Audiobook' },
];
+16
View File
@@ -0,0 +1,16 @@
// This data is loaded from the backend in production
// For now, provide a default set
export const DEFAULT_LANGUAGES = [
{ code: 'en', language: 'English' },
{ code: 'es', language: 'Spanish' },
{ code: 'fr', language: 'French' },
{ code: 'de', language: 'German' },
{ code: 'it', language: 'Italian' },
{ code: 'pt', language: 'Portuguese' },
{ code: 'ru', language: 'Russian' },
{ code: 'zh', language: 'Chinese' },
{ code: 'ja', language: 'Japanese' },
{ code: 'ko', language: 'Korean' },
];
export const DEFAULT_SUPPORTED_FORMATS = ['epub', 'mobi', 'azw3', 'fb2', 'djvu', 'cbz', 'cbr'];
+258
View File
@@ -0,0 +1,258 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
import { StatusData } from '../types';
import { getStatus } from '../services/api';
interface UseRealtimeStatusOptions {
wsUrl: string;
pollInterval?: number;
reconnectAttempts?: number;
}
interface UseRealtimeStatusReturn {
status: StatusData;
connected: boolean;
isUsingWebSocket: boolean;
error: string | null;
forceRefresh: () => Promise<void>;
}
/**
* Hook for real-time status updates with WebSocket and polling fallback
*
* This hook attempts to connect via WebSocket first. If WebSocket connection
* fails or disconnects, it automatically falls back to polling. It will
* periodically retry WebSocket connections.
*/
export const useRealtimeStatus = ({
wsUrl,
pollInterval = 5000,
reconnectAttempts = 3,
}: UseRealtimeStatusOptions): UseRealtimeStatusReturn => {
const [status, setStatus] = useState<StatusData>({});
const [connected, setConnected] = useState(false);
const [isUsingWebSocket, setIsUsingWebSocket] = useState(false);
const [error, setError] = useState<string | null>(null);
const socketRef = useRef<Socket | null>(null);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isConnectingRef = useRef(false);
// Polling function
const pollStatus = useCallback(async () => {
try {
const data = await getStatus();
setStatus(data);
setError(null);
} catch (err) {
console.error('Error polling status:', err);
setError('Failed to fetch status');
}
}, []);
// Start polling
const startPolling = useCallback(() => {
if (pollIntervalRef.current) return;
console.log('Starting polling fallback');
setIsUsingWebSocket(false);
// Poll immediately
pollStatus();
// Then poll at intervals
pollIntervalRef.current = setInterval(pollStatus, pollInterval);
}, [pollStatus, pollInterval]);
// Stop polling
const stopPolling = useCallback(() => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
console.log('Stopped polling');
}
}, []);
// Attempt to reconnect WebSocket
const attemptReconnect = useCallback(() => {
if (reconnectAttemptsRef.current >= reconnectAttempts) {
console.log('Max reconnect attempts reached, using polling permanently');
return;
}
reconnectAttemptsRef.current += 1;
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
console.log(`Attempting WebSocket reconnect ${reconnectAttemptsRef.current}/${reconnectAttempts} in ${delay}ms`);
reconnectTimeoutRef.current = setTimeout(() => {
if (!isConnectingRef.current && !socketRef.current?.connected) {
initializeWebSocket();
}
}, delay);
}, [reconnectAttempts]);
// Initialize WebSocket connection
const initializeWebSocket = useCallback(() => {
if (isConnectingRef.current || socketRef.current?.connected) {
return;
}
isConnectingRef.current = true;
console.log('Initializing WebSocket connection to:', wsUrl);
try {
const socket = io(wsUrl, {
// Try websocket first, fall back to polling if needed
transports: ['websocket', 'polling'],
// Explicitly set the path to match backend
path: '/socket.io',
// Connection timeout
timeout: 10000,
// Reconnection settings
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
// Upgrade settings for reverse proxies
upgrade: true,
rememberUpgrade: true,
// Force new connection instead of reusing
forceNew: false,
// Enable multiplexing
multiplex: true,
// Auto-connect
autoConnect: true,
});
socketRef.current = socket;
socket.on('connect', () => {
console.log('WebSocket connected successfully');
setConnected(true);
setIsUsingWebSocket(true);
setError(null);
reconnectAttemptsRef.current = 0;
isConnectingRef.current = false;
// Stop polling when WebSocket connects
stopPolling();
});
socket.on('disconnect', (reason: string) => {
console.log('WebSocket disconnected. Reason:', reason);
setConnected(false);
setIsUsingWebSocket(false);
isConnectingRef.current = false;
// Start polling as fallback
startPolling();
// Attempt to reconnect WebSocket for most disconnect reasons
// 'io server disconnect' = server initiated disconnect
// 'transport close' = network error or server unreachable
// 'transport error' = transport failed (like websocket failed to connect)
if (reason !== 'io client disconnect') {
console.log('Attempting to reconnect WebSocket after disconnect:', reason);
attemptReconnect();
}
});
socket.on('connect_error', (err: Error) => {
console.error('WebSocket connection error:', err.message);
setError(`WebSocket error: ${err.message}`);
setConnected(false);
setIsUsingWebSocket(false);
isConnectingRef.current = false;
// Start polling immediately on connection error
startPolling();
// Attempt to reconnect WebSocket
attemptReconnect();
});
// Listen for status updates
socket.on('status_update', (data: StatusData) => {
setStatus(data);
setError(null);
});
// Listen for real-time progress updates
socket.on('download_progress', (data: { book_id: string; progress: number; status: string }) => {
setStatus(prev => {
const newStatus = { ...prev };
if (newStatus.downloading?.[data.book_id]) {
newStatus.downloading[data.book_id] = {
...newStatus.downloading[data.book_id],
progress: data.progress,
};
}
return newStatus;
});
});
socket.on('error', (err: Error) => {
console.error('WebSocket error:', err);
setError('WebSocket error occurred');
});
} catch (err) {
console.error('Failed to initialize WebSocket:', err);
setError('Failed to initialize WebSocket');
isConnectingRef.current = false;
startPolling();
}
}, [wsUrl, stopPolling, startPolling, attemptReconnect]);
// Force refresh function
const forceRefresh = useCallback(async () => {
if (socketRef.current?.connected) {
// Request update via WebSocket
socketRef.current.emit('request_status');
} else {
// Poll immediately
await pollStatus();
}
}, [pollStatus]);
// Initialize on mount
useEffect(() => {
// Try WebSocket first
initializeWebSocket();
// If WebSocket doesn't connect within 3 seconds, start polling
const fallbackTimeout = setTimeout(() => {
if (!socketRef.current?.connected) {
console.log('WebSocket connection timeout, starting polling');
startPolling();
}
}, 3000);
// Cleanup
return () => {
clearTimeout(fallbackTimeout);
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
stopPolling();
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
}
};
}, [initializeWebSocket, startPolling, stopPolling]);
return {
status,
connected,
isUsingWebSocket,
error,
forceRefresh,
};
};
+21
View File
@@ -0,0 +1,21 @@
import { useState, useCallback } from 'react';
import { Toast } from '../types';
export const useToast = () => {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((message: string, type: 'info' | 'success' | 'error' = 'info') => {
const id = Date.now().toString();
setToasts(prev => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, 4000);
}, []);
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
return { toasts, showToast, removeToast };
};
+15
View File
@@ -0,0 +1,15 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
const root = document.getElementById('root');
if (!root) throw new Error('Root element not found');
createRoot(root).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);
+36
View File
@@ -0,0 +1,36 @@
import { LoginForm } from '../components/LoginForm';
import { LoginCredentials } from '../types';
interface LoginPageProps {
onLogin: (credentials: LoginCredentials) => void;
error: string | null;
isLoading: boolean;
}
export const LoginPage = ({ onLogin, error, isLoading }: LoginPageProps) => {
return (
<div
className="min-h-screen flex items-center justify-center px-4 py-8"
style={{ backgroundColor: 'var(--background-color)', color: 'var(--text-color)' }}
>
<div className="w-full max-w-md">
<div className="text-center mb-8">
<img src="/logo.png" alt="Logo" className="mx-auto mb-6 w-20 h-20" />
<h1 className="text-2xl font-semibold">Sign in to continue</h1>
</div>
<div
className="rounded-lg shadow-2xl p-8 border"
style={{
backgroundColor: 'var(--card-background)',
borderColor: 'var(--border-color)',
color: 'var(--text-color)',
}}
>
<LoginForm onSubmit={onLogin} error={error} isLoading={isLoading} />
</div>
</div>
</div>
);
};
+108
View File
@@ -0,0 +1,108 @@
import { Book, StatusData, AppConfig, LoginCredentials, AuthResponse } from '../types';
const API_BASE = '/api';
// API endpoints
const API = {
search: `${API_BASE}/search`,
info: `${API_BASE}/info`,
download: `${API_BASE}/download`,
status: `${API_BASE}/status`,
cancelDownload: `${API_BASE}/download`,
setPriority: `${API_BASE}/queue`,
clearCompleted: `${API_BASE}/queue/clear`,
config: `${API_BASE}/config`,
login: `${API_BASE}/auth/login`,
logout: `${API_BASE}/auth/logout`,
authCheck: `${API_BASE}/auth/check`
};
// Custom error class for authentication failures
export class AuthenticationError extends Error {
constructor(message: string) {
super(message);
this.name = 'AuthenticationError';
}
}
// Utility function for JSON fetch with credentials
async function fetchJSON<T>(url: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(url, {
...opts,
credentials: 'include', // Enable cookies for session
headers: {
'Content-Type': 'application/json',
...opts.headers,
},
});
if (!res.ok) {
// Try to parse error message from response body
let errorMessage = `${res.status} ${res.statusText}`;
try {
const errorData = await res.json();
if (errorData.error) {
errorMessage = errorData.error;
}
} catch (e) {
// If we can't parse JSON, use the default error message
}
// Throw appropriate error based on status code
if (res.status === 401) {
throw new AuthenticationError(errorMessage);
}
throw new Error(errorMessage);
}
return res.json();
}
// API functions
export const searchBooks = async (query: string): Promise<Book[]> => {
if (!query) return [];
return fetchJSON<Book[]>(`${API.search}?${query}`);
};
export const getBookInfo = async (id: string): Promise<Book> => {
return fetchJSON<Book>(`${API.info}?id=${encodeURIComponent(id)}`);
};
export const downloadBook = async (id: string): Promise<void> => {
await fetchJSON(`${API.download}?id=${encodeURIComponent(id)}`);
};
export const getStatus = async (): Promise<StatusData> => {
return fetchJSON<StatusData>(API.status);
};
export const cancelDownload = async (id: string): Promise<void> => {
await fetchJSON(`${API.cancelDownload}/${encodeURIComponent(id)}/cancel`, { method: 'DELETE' });
};
export const clearCompleted = async (): Promise<void> => {
await fetchJSON(`${API_BASE}/queue/clear`, { method: 'DELETE' });
};
export const getConfig = async (): Promise<AppConfig> => {
return fetchJSON<AppConfig>(API.config);
};
// Authentication functions
export const login = async (credentials: LoginCredentials): Promise<AuthResponse> => {
return fetchJSON<AuthResponse>(API.login, {
method: 'POST',
body: JSON.stringify(credentials),
});
};
export const logout = async (): Promise<AuthResponse> => {
return fetchJSON<AuthResponse>(API.logout, {
method: 'POST',
});
};
export const checkAuth = async (): Promise<AuthResponse> => {
return fetchJSON<AuthResponse>(API.authCheck);
};
+547
View File
@@ -0,0 +1,547 @@
/* Base styles and CSS reset */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Disable transitions on initial load to prevent flash */
html.preload *,
html.preload *::before,
html.preload *::after {
transition: none !important;
animation-duration: 0s !important;
}
/* Smooth theme transitions for all elements using CSS variables */
header, footer, section,
input, select, textarea, button,
.details-container, .modal-overlay {
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
:root {
/* Light theme variables */
--primary-color: oklch(44.3% 0.11 240.79);
--primary-dark: oklch(39.1% 0.09 240.876);
--text-color: #333;
--background-color: #f8f8f8;
--border-color: #e5e5e5;
--loading-overlay: rgba(0, 0, 0, 0.5);
--card-background: #fff;
--input-background: #fff;
--heading-color: #333;
/* Modern UI alias tokens */
--bg: var(--background-color);
--text: var(--text-color);
--border-muted: var(--border-color);
--bg-soft: var(--card-background);
--hover-surface: rgba(15, 23, 42, 0.05);
--hover-action: rgba(15, 23, 42, 0.08);
--hover-row: rgba(15, 23, 42, 0.07);
}
[data-theme="dark"] {
/* Dark theme variables with improved contrast */
--background-color: #121212;
--text-color: #ffffff;
--heading-color: #ffffff;
--card-background: #1e1e1e;
--border-color: #404040;
--input-background: #2d2d2d;
--hover-surface: rgba(255, 255, 255, 0.05);
--hover-action: rgba(255, 255, 255, 0.12);
--hover-row: rgba(255, 255, 255, 0.08);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Typography */
html, body {
min-height: 100vh;
/* Support for iOS notch/dynamic island - extend to full viewport */
min-height: 100dvh;
margin: 0;
padding: 0;
}
html {
/* Transition on root element ensures smooth theme changes */
transition: background-color 0.2s ease, color 0.2s ease;
/* Extend background color to safe areas (status bar area) */
background-color: var(--background-color);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: var(--text-color);
background: var(--background-color);
transition: background-color 0.2s ease, color 0.2s ease;
display: flex;
flex-direction: column;
/* Safe areas handled by individual components (Header, Footer, Sidebar) */
/* Left/right safe areas still needed for notched devices in landscape */
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
#root {
display: flex;
flex-direction: column;
flex: 1;
min-height: 100vh;
min-height: 100dvh;
transition: background-color 0.2s ease;
background-color: var(--background-color);
}
main {
flex: 1;
min-height: 100%;
background: var(--background-color);
display: flex;
flex-direction: column;
transition: background-color 0.2s ease;
}
/* Layout */
footer {
text-align: center;
padding: 1rem;
margin-top: auto;
}
.spinner {
display: inline-block;
width: 1.25rem;
height: 1.25rem;
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in-down {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-out-up {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-10px);
}
}
.animate-slide-up {
animation: slide-up 0.5s ease-out;
}
.animate-fade-in-up {
animation: fade-in-up 0.4s ease-out;
}
.animate-fade-in-down {
animation: fade-in-down 0.2s ease-out;
}
.animate-fade-out-up {
animation: fade-out-up 0.15s ease-in;
}
/* Button spinner styles */
.search-bar-spinner {
border-radius: 50%;
animation: spin 0.8s linear infinite;
transition: opacity 0.2s ease;
}
.search-bar-icon {
transition: opacity 0.2s ease;
}
.search-bar-button:disabled {
cursor: not-allowed;
}
/* Download button spinner styles */
.download-spinner {
border-radius: 50%;
animation: spin 0.8s linear infinite;
transition: opacity 0.2s ease;
flex-shrink: 0;
}
.details-spinner {
border-radius: 50%;
animation: spin 0.8s linear infinite;
transition: opacity 0.2s ease;
flex-shrink: 0;
}
/* Modal Styles */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
width: 100%;
height: 100%;
background: var(--loading-overlay);
z-index: 1000;
padding: 1.5rem;
justify-content: center;
align-items: center;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.modal-overlay.active {
display: flex;
}
.details-container {
width: 100%;
max-width: 64rem;
max-height: 90vh;
pointer-events: auto;
}
/* Responsive Design */
@media (max-width: 768px) {
.modal-overlay {
padding: 1rem;
}
.details-container {
max-width: none;
}
}
/* Accessibility */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Toast Notification Styles */
#toast-container {
max-width: 400px;
pointer-events: none;
}
.toast-notification {
opacity: 0;
transform: translateX(100%);
max-width: 100%;
word-wrap: break-word;
pointer-events: auto;
}
.toast-notification.toast-visible {
opacity: 1;
transform: translateX(0);
}
/* Disabled button styles for queued/downloading states */
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.hover-surface,
.hover-action,
.hover-row {
transition: background-color 0.2s ease, color 0.2s ease;
}
.hover-surface:hover {
background-color: var(--hover-surface);
}
.hover-action:hover {
background-color: var(--hover-action);
}
.hover-row:hover {
background-color: var(--hover-row);
}
/* Search wrapper and input - base styles */
.search-wrapper {
width: 100%;
}
.search-input {
min-width: 0; /* Allow input to shrink on mobile flex containers */
-webkit-appearance: none;
appearance: none;
}
.search-input::-webkit-search-cancel-button,
.search-input::-webkit-search-decoration,
.search-input::-webkit-search-results-button,
.search-input::-webkit-search-results-decoration {
display: none;
}
.search-input::-ms-clear {
display: none;
width: 0;
height: 0;
}
/* ============================================
MOBILE: Full viewport width, no fixed widths
============================================ */
@media (max-width: 639px) {
/* Break search section out to full viewport width, escaping the centered main container */
#search-section {
position: relative;
left: 50%;
right: 50%;
margin-left: -50vw;
margin-right: -50vw;
width: 100vw;
padding-left: 1rem;
padding-right: 1rem;
box-sizing: border-box;
}
/* Remove any width constraints on search wrapper */
.search-wrapper {
width: 100% !important;
min-width: 0 !important;
max-width: none !important;
}
/* Ensure flex containers are full width */
.search-wrapper > .flex,
#search-filters {
width: 100%;
max-width: 100%;
min-width: 0;
}
}
/* ============================================
DESKTOP: Fixed px widths for consistency
============================================ */
/* Medium screens (small tablets/large phones): 640px - 1023px */
@media (min-width: 640px) and (max-width: 1023px) {
.search-wrapper {
width: 600px; /* Fixed width for medium screens */
max-width: 600px; /* Prevent expansion */
}
}
/* Large screens (desktop): 1024px+ */
@media (min-width: 1024px) {
.search-wrapper {
width: 800px; /* Fixed width for large screens */
max-width: 800px; /* Prevent expansion */
}
}
/* Search section base styles - always center horizontally on desktop */
#search-section {
display: flex;
flex-direction: column;
align-items: center; /* Keep search box centered horizontally */
}
/* Center search section vertically when in initial state (no results, no queue) */
/* Responsive to different window sizes */
.search-initial-state {
flex: 1;
justify-content: center; /* Only add vertical centering in initial state */
}
/* Mobile-friendly book card layout */
/* On mobile (below 640px), use horizontal layout: image left, text right, buttons below */
/* Desktop remains unchanged - full width, full height artwork via Tailwind classes */
@media (max-width: 639px) {
.search-initial-state {
justify-content: flex-start;
padding-top: 18vh; /* push the search UI toward the upper third */
}
/* When advanced filters are visible, pull the search bar to the very top */
.search-initial-state.search-advanced-visible {
padding-top: calc(env(safe-area-inset-top) + 1rem);
}
.book-card-content {
flex-direction: row !important;
gap: 0.75rem !important;
align-items: flex-start;
}
.book-card-cover {
width: 50% !important;
height: auto !important;
flex-shrink: 0;
aspect-ratio: 2/3; /* Maintain book cover proportions */
}
.book-card-text {
width: 50% !important;
flex-shrink: 0;
padding-left: 0.5rem;
}
.book-card-buttons {
width: 100%;
margin-top: 0.5rem;
}
}
/* Desktop: align details and buttons to bottom, artwork stays at top */
@media (min-width: 640px) {
.book-card {
/* Ensure card can grow to accommodate content */
display: flex;
flex-direction: column;
height: 100%; /* Ensure card fills grid cell height */
}
.book-card-content {
/* Grow to fill available space, pushing buttons to bottom */
flex: 1;
display: flex;
flex-direction: column;
min-height: 0; /* Allow flex shrinking */
}
.book-card-cover {
/* Artwork stays at top, doesn't grow */
flex-shrink: 0;
}
.book-card-text {
/* Push text to bottom of content area, right above buttons */
margin-top: auto;
flex: 0 0 auto !important; /* Override flex-1 from HTML, don't grow or shrink */
}
}
/* Fix download button text clipping on mobile */
.download-button-text {
white-space: nowrap;
overflow: visible;
flex-shrink: 0;
display: inline-block;
}
/* Ensure download buttons can accommodate their text content */
[data-action="download"],
#download-button {
min-width: 0;
overflow: visible;
width: 100%;
}
/* On mobile, ensure button text is fully visible */
@media (max-width: 639px) {
/* Ensure button container allows overflow */
.book-card-buttons {
overflow: visible;
}
/* Make text span flexible to use available space */
.download-button-text {
white-space: nowrap;
overflow: visible;
}
/* Ensure spinner doesn't interfere with text layout */
.download-spinner {
flex-shrink: 0;
flex-grow: 0;
width: 1rem;
height: 1rem;
}
}
/* Sticky Header with Gradient Fade Effect */
.header-with-fade {
overflow: visible;
}
.header-with-fade::after {
content: '';
position: absolute;
bottom: -20px;
left: 0;
right: 0;
height: 20px;
/* Use background-color instead of gradient with CSS variables for smooth transitions */
background: var(--bg);
opacity: 1;
pointer-events: none;
/* Create fade effect using mask instead of gradient */
mask-image: linear-gradient(to bottom, black 0%, black 20%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 20%, transparent 100%);
transition: background-color 0.2s ease, opacity 0.2s ease;
}
/* Skeleton Loader Animation */
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.animate-pulse {
background-size: 200% 100%;
animation: shimmer 2s ease-in-out infinite;
}
+93
View File
@@ -0,0 +1,93 @@
// Book data types
export interface Book {
id: string;
title: string;
author: string;
year?: string;
language?: string;
format?: string;
size?: string;
preview?: string;
publisher?: string;
info?: Record<string, string | string[]>;
description?: string;
download_path?: string;
progress?: number;
}
// Status response types
export interface StatusData {
queued?: Record<string, Book>;
resolving?: Record<string, Book>;
bypassing?: Record<string, Book>;
downloading?: Record<string, Book>;
verifying?: Record<string, Book>;
ingesting?: Record<string, Book>;
complete?: Record<string, Book>;
available?: Record<string, Book>;
done?: Record<string, Book>;
completed?: Record<string, Book>;
error?: Record<string, Book>;
cancelled?: Record<string, Book>;
}
export interface ActiveDownloadsResponse {
active_downloads: Book[];
}
// Button states
export type ButtonState = 'download' | 'queued' | 'resolving' | 'bypassing' | 'downloading' | 'verifying' | 'ingesting' | 'completed' | 'error';
export interface ButtonStateInfo {
text: string;
state: ButtonState;
progress?: number; // Download progress 0-100
}
// Language option
export interface Language {
code: string;
language: string;
}
export interface AdvancedFilterState {
isbn: string;
author: string;
title: string;
lang: string[];
sort: string;
content: string;
formats: string[];
}
// Toast notification
export interface Toast {
id: string;
message: string;
type: 'success' | 'error' | 'info';
}
// App configuration
export interface AppConfig {
calibre_web_url: string;
debug: boolean;
build_version: string;
release_version: string;
book_languages: Language[];
default_language: string[];
supported_formats: string[];
}
// Authentication types
export interface LoginCredentials {
username: string;
password: string;
remember_me: boolean;
}
export interface AuthResponse {
success?: boolean;
authenticated?: boolean;
auth_required?: boolean;
error?: string;
}
@@ -0,0 +1,46 @@
import { AdvancedFilterState, Language } from '../types';
import { getLanguageFilterValues } from './languageFilters';
interface BuildSearchQueryOptions {
searchInput: string;
showAdvanced: boolean;
advancedFilters: AdvancedFilterState;
bookLanguages: Language[];
defaultLanguage: string[];
}
export const buildSearchQuery = ({
searchInput,
showAdvanced,
advancedFilters,
bookLanguages,
defaultLanguage,
}: BuildSearchQueryOptions): string => {
const queryParts: string[] = [];
const basic = searchInput.trim();
if (basic) {
queryParts.push(`query=${encodeURIComponent(basic)}`);
}
if (showAdvanced) {
const { isbn, author, title, content, formats, lang } = advancedFilters;
if (isbn) queryParts.push(`isbn=${encodeURIComponent(isbn)}`);
if (author) queryParts.push(`author=${encodeURIComponent(author)}`);
if (title) queryParts.push(`title=${encodeURIComponent(title)}`);
const selectedLanguages = getLanguageFilterValues(lang, bookLanguages, defaultLanguage);
selectedLanguages?.forEach(code => queryParts.push(`lang=${encodeURIComponent(code)}`));
if (content) queryParts.push(`content=${encodeURIComponent(content)}`);
formats.forEach(format => queryParts.push(`format=${encodeURIComponent(format)}`));
}
if (advancedFilters.sort) {
queryParts.push(`sort=${encodeURIComponent(advancedFilters.sort)}`);
}
return queryParts.join('&');
};
+89
View File
@@ -0,0 +1,89 @@
import { Language } from '../types';
export const LANGUAGE_OPTION_DEFAULT = 'default';
export const LANGUAGE_OPTION_ALL = 'all';
export const normalizeLanguageSelection = (selected: string[]): string[] => {
const sanitized = (selected ?? []).filter(Boolean);
if (sanitized.length === 0) {
return [LANGUAGE_OPTION_DEFAULT];
}
const unique: string[] = [];
const seen = new Set<string>();
for (const value of sanitized) {
if (!seen.has(value)) {
unique.push(value);
seen.add(value);
}
}
if (unique.includes(LANGUAGE_OPTION_ALL)) {
return [LANGUAGE_OPTION_ALL];
}
return unique.length ? unique : [LANGUAGE_OPTION_DEFAULT];
};
export const getLanguageFilterValues = (
selection: string[],
supportedLanguages: Language[],
defaultLanguageCodes: string[] = [],
): string[] | null => {
if (!selection || selection.length === 0) {
return null;
}
const uniqueSelection = Array.from(new Set(selection.filter(Boolean)));
if (uniqueSelection.includes(LANGUAGE_OPTION_ALL)) {
return [LANGUAGE_OPTION_ALL];
}
const onlyDefaultSelected =
uniqueSelection.length === 1 && uniqueSelection[0] === LANGUAGE_OPTION_DEFAULT;
if (onlyDefaultSelected) {
return null;
}
const supportedCodes = new Set(supportedLanguages.map(lang => lang.code));
const defaultCodes = defaultLanguageCodes.filter(code => supportedCodes.has(code));
const resolved = new Set<string>();
uniqueSelection.forEach(code => {
if (code === LANGUAGE_OPTION_DEFAULT) {
defaultCodes.forEach(defaultCode => resolved.add(defaultCode));
return;
}
if (supportedCodes.has(code)) {
resolved.add(code);
}
});
return resolved.size ? Array.from(resolved) : null;
};
export const formatDefaultLanguageLabel = (
languageCodes: string[],
supportedLanguages: Language[],
): string => {
if (!languageCodes || languageCodes.length === 0) {
return 'Default (env config)';
}
const languageNames = supportedLanguages
.filter(lang => languageCodes.includes(lang.code))
.map(lang => lang.language);
if (languageNames.length === 0) {
return 'Default (env config)';
}
const joined = languageNames.slice(0, 3).join(', ');
const suffix = languageNames.length > 3 ? '…' : '';
return `Default (${joined}${suffix})`;
};
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {},
},
plugins: [],
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
host: '0.0.0.0',
strictPort: true,
cors: true,
proxy: {
// Proxy API requests to the Docker backend
'/api': {
target: 'http://localhost:8084',
changeOrigin: true,
secure: false,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
});
-551
View File
@@ -1,551 +0,0 @@
/* Base styles and CSS reset */
:root {
/* Light theme variables */
--primary-color: #0073e6;
--primary-dark: #005bb5;
--text-color: #333;
--background-color: #f8f8f8;
--border-color: #e5e5e5;
--header-bg: #333;
--header-text: #fff;
--loading-overlay: rgba(0, 0, 0, 0.5);
--card-background: #fff;
--table-background: #fff;
--table-hover-background: #f8f8f8;
--table-border-color: #e5e5e5;
--input-background: #fff;
--heading-color: #333;
/* Modern UI alias tokens */
--bg: var(--background-color);
--text: var(--text-color);
--border-muted: var(--border-color);
--bg-soft: var(--card-background);
--footer-bg: var(--header-bg);
}
[data-theme="dark"] {
/* Dark theme variables with improved contrast */
--background-color: #121212;
--text-color: #ffffff;
--heading-color: #ffffff;
--card-background: #1e1e1e;
--border-color: #404040;
--table-background: #1e1e1e;
--table-hover-background: #2d2d2d;
--table-border-color: #404040;
--input-background: #2d2d2d;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Typography */
html, body {
min-height: 100vh;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: var(--text-color);
background: var(--background-color);
transition: background-color 0.3s ease, color 0.3s ease;
display: flex;
flex-direction: column;
}
main {
flex: 1;
min-height: 100%;
background: var(--background-color);
}
/* Layout */
header {
background: var(--header-bg);
color: var(--header-text);
padding: 1rem;
text-align: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
footer {
text-align: center;
padding: 1rem;
background: var(--header-bg);
color: var(--header-text);
margin-top: auto;
}
/* Search Section */
.search-section {
margin-bottom: 2rem;
}
.search-container {
max-width: 600px;
margin: 0 auto;
display: flex;
gap: 0.5rem;
}
.search-container input {
flex: 1;
padding: 0.75rem;
border: 2px solid var(--border-color);
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.3s ease;
}
.search-container input:focus {
border-color: var(--primary-color);
outline: none;
}
.search-filter {
max-width: 210px;
}
/* Table Styles */
.table-responsive {
margin-bottom: 1rem;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
table {
width: 100%;
border-collapse: collapse;
background: white;
}
th, td {
padding: 0.75rem;
border: 1px solid var(--border-color);
text-align: left;
justify-content:center;
}
th {
background: #f5f5f5;
font-weight: 600;
}
tbody tr:hover {
background: #f8f9fa;
}
.details-button {
padding: 0.75rem 1.5rem;
background: #27ae60;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.3s ease;
width: 100%;
margin-bottom: 0.25rem;
}
.details-button:hover {
background: #1f894b;
}
/* Results Section */
.results-section {
margin-bottom: 2rem;
background: white;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.results-heading {
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
}
.toggle-icon {
transition: transform 0.3s ease;
}
.collapsed .toggle-icon {
transform: rotate(-90deg);
}
.results-content {
transition: max-height 0.3s ease;
}
.collapsed .results-content {
max-height: 0;
overflow: hidden;
}
th[data-sort="index"] {
min-width: 60px;
}
/* Loading Indicator */
.loading-indicator {
display: none;
text-align: center;
padding: 1rem;
}
.spinner {
display: inline-block;
width: 2rem;
height: 2rem;
border: 3px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top-color: var(--primary-color);
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Modal Styles */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--loading-overlay);
z-index: 1000;
}
.modal-overlay.active {
display: flex;
justify-content: center;
align-items: center;
}
.details-container {
background: white;
padding: 0.25rem 0.25rem 2rem 3rem;
border-radius: 4px;
max-width: 800px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
position: relative;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.details-header {
display: grid;
grid-template-columns: auto 1fr;
gap: 2rem;
margin-bottom: 1.5rem;
}
.details-header img {
max-width: 200px;
height: auto;
border-radius: 4px;
}
.details-info h3 {
margin-bottom: 1rem;
color: var(--text-color);
}
.details-info p {
margin-bottom: 0.5rem;
}
.details-actions {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
justify-content: flex-end;
}
.details-actions button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.3s ease;
}
.details-actions button:first-child {
background: var(--primary-color);
color: white;
}
.details-actions button:last-child {
background: #f5f5f5;
color: var(--text-color);
}
.details-actions button:hover {
opacity: 0.9;
}
/* Responsive Design */
@media (max-width: 768px) {
.search-container {
flex-direction: column;
}
.details-header {
grid-template-columns: 1fr;
text-align: center;
}
.details-header img {
margin: 0 auto;
}
.details-actions {
flex-direction: column;
}
th, td {
padding: 0.5rem;
font-size: 0.875rem;
}
}
/* Accessibility */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Status indicators */
.status-queued {
color: #f39c12;
font-weight: 600;
text-align: center;
}
.status-downloading {
color: #3498db;
font-weight: 600;
text-align: center;
}
.status-available {
color: #27ae60;
font-weight: 600;
text-align: center;
}
.status-error {
color: #e74c3c;
font-weight: 600;
text-align: center;
}
.status-done {
color: black;
font-weight: 600;
text-align: center;
}
/* Status table specific styles */
#status-table img {
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
#status-table td {
vertical-align: middle;
}
.error-message {
color: #e74c3c;
text-align: center;
padding: 1rem !important;
}
/* Dark mode specific styles */
[data-theme="dark"] .uk-card,
[data-theme="dark"] .uk-modal-dialog {
background-color: var(--card-background);
color: var(--text-color);
}
[data-theme="dark"] .uk-table {
color: var(--text-color);
}
[data-theme="dark"] .uk-table-hover tbody tr:hover {
background-color: rgba(255, 255, 255, 0.1);
}
[data-theme="dark"] .uk-button-default {
background-color: var(--card-background);
color: var(--text-color);
border-color: var(--border-color);
}
[data-theme="dark"] .uk-search-input {
background-color: var(--card-background);
color: var(--text-color);
border-color: var(--border-color);
}
[data-theme="dark"] .uk-select {
background-color: var(--card-background);
color: var(--text-color);
border-color: var(--border-color);
}
/* Table styles */
.uk-table {
background-color: var(--table-background);
color: var(--text-color);
border-color: var(--table-border-color);
}
.uk-table th {
color: var(--text-color);
background-color: var(--table-background);
border-bottom-color: var(--table-border-color);
}
.uk-table td {
color: var(--text-color);
background-color: var(--table-background);
border-bottom-color: var(--table-border-color);
}
.uk-table-hover tbody tr:hover,
.uk-table-hover tbody tr:hover td {
background-color: var(--table-hover-background);
}
/* Input styles */
.uk-search-input,
.uk-input,
.uk-select,
.uk-textarea {
background-color: var(--input-background);
color: var(--text-color);
border-color: var(--border-color);
}
/* Button styles */
.uk-button-default {
background-color: var(--card-background);
color: var(--text-color);
border-color: var(--border-color);
}
.uk-button-default:hover {
background-color: var(--table-hover-background);
color: var(--text-color);
border-color: var(--border-color);
}
/* Dropdown styles */
.uk-dropdown {
background-color: var(--card-background);
color: var(--text-color);
border-color: var(--border-color);
}
.uk-dropdown-nav > li > a {
color: var(--text-color);
}
.uk-dropdown-nav > li > a:hover {
background-color: var(--table-hover-background);
}
/* Modal styles */
.modal-overlay {
background-color: rgba(0, 0, 0, 0.5);
}
.details-container {
background-color: var(--card-background);
color: var(--text-color);
border-color: var(--border-color);
}
/* Status colors - ensure they remain visible in dark mode */
.status-downloading {
color: #4CAF50 !important;
}
.status-available {
color: #2196F3 !important;
}
.status-error {
color: #f44336 !important;
}
/* Header specific button styles */
header .uk-button-default {
color: var(--header-text) !important;
background-color: transparent;
border-color: var(--header-text);
}
header .uk-button-default:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: var(--header-text);
color: var(--header-text) !important;
}
/* Ensure dropdown text is visible when opened */
header .uk-dropdown {
background-color: var(--card-background);
}
header .uk-dropdown-nav > li > a {
color: var(--text-color);
}
header .uk-dropdown-nav > li > a:hover {
color: var(--text-color);
background-color: var(--table-hover-background);
}
/* Update headings to use heading color */
h1, h2, h3, h4, h5, h6,
.uk-heading-small,
.uk-heading-medium,
.uk-heading-large,
.uk-heading-xlarge,
.uk-heading-2xlarge {
color: var(--heading-color) !important;
}
/* Ensure accordion titles are visible */
.uk-accordion-title {
color: var(--heading-color) !important;
}
/* Ensure search results heading is visible */
#results-section-accordion .uk-accordion-title h1 {
color: var(--heading-color) !important;
}
-383
View File
@@ -1,383 +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-44 object-cover rounded">` :
`<div class="w-full h-44 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-56 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 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>${utils.e(b.title || '-') }</strong></div>
${progress}
<div class="flex items-center gap-2">${actions}</div>
</li>`;
}).join('');
sections.push(`
<div>
<h4 class="font-semibold mb-2">${name.charAt(0).toUpperCase() + name.slice(1)}</h4>
<ul class="space-y-2">${rows}</ul>
</div>`);
}
el.statusList.innerHTML = sections.join('') || '<div class="text-sm opacity-80">No items.</div>';
// Bind cancel buttons
el.statusList.querySelectorAll('[data-cancel]')?.forEach((btn) => {
btn.addEventListener('click', () => queue.cancel(btn.getAttribute('data-cancel')));
});
},
// Render compact active downloads list near the search bar
renderTop(data) {
try {
const downloading = (data && data.downloading) ? Object.values(data.downloading) : [];
if (!el.activeTopSec || !el.activeTopList) return;
if (!downloading.length) {
el.activeTopList.innerHTML = '';
el.activeTopSec.classList.add('hidden');
return;
}
// Build compact rows with title and progress bar + cancel
const rows = downloading.map((b) => {
const prog = (typeof b.progress === 'number')
? `<div class="h-1.5 bg-black/10 rounded overflow-hidden"><div class="h-1.5 bg-blue-600" style="width:${Math.round(b.progress)}%"></div></div>`
: '';
const cancel = `<button class="px-2 py-0.5 rounded border text-xs" data-cancel="${utils.e(b.id)}" style="border-color: var(--border-muted);">Cancel</button>`;
return `<div class="p-3 rounded border" style="border-color: var(--border-muted); background: var(--bg-soft)">
<div class="flex items-center justify-between gap-3">
<div class="text-sm truncate"><strong>${utils.e(b.title || '-') }</strong></div>
<div class="shrink-0">${cancel}</div>
</div>
${prog}
</div>`;
}).join('');
el.activeTopList.innerHTML = rows;
el.activeTopSec.classList.remove('hidden');
// Bind cancel handlers for the top section
el.activeTopList.querySelectorAll('[data-cancel]')?.forEach((btn) => {
btn.addEventListener('click', () => queue.cancel(btn.getAttribute('data-cancel')));
});
} catch (_) {}
},
async updateActive() {
try {
const d = await utils.j(API.activeDownloads);
const n = Array.isArray(d.active_downloads) ? d.active_downloads.length : 0;
if (el.activeDownloadsCount) el.activeDownloadsCount.textContent = `Active: ${n}`;
} catch (_) {}
}
};
// ---- Queue ----
const queue = {
async cancel(id) {
try {
await fetch(`${API.cancelDownload}/${encodeURIComponent(id)}/cancel`, { method: 'DELETE' });
status.fetch();
} catch (_){}
}
};
// ---- Theme ----
const theme = {
KEY: 'preferred-theme',
init() {
const saved = localStorage.getItem(this.KEY) || 'auto';
this.apply(saved);
this.updateLabel(saved);
// toggle dropdown
el.themeToggle?.addEventListener('click', (e) => {
e.preventDefault();
if (!el.themeMenu) return;
el.themeMenu.classList.toggle('hidden');
});
// outside click to close
document.addEventListener('click', (ev) => {
if (!el.themeMenu || !el.themeToggle) return;
if (el.themeMenu.contains(ev.target) || el.themeToggle.contains(ev.target)) return;
el.themeMenu.classList.add('hidden');
});
// selection
el.themeMenu?.querySelectorAll('a[data-theme]')?.forEach((a) => {
a.addEventListener('click', (ev) => {
ev.preventDefault();
const pref = a.getAttribute('data-theme');
localStorage.setItem(theme.KEY, pref);
theme.apply(pref);
theme.updateLabel(pref);
el.themeMenu.classList.add('hidden');
});
});
// react to system change if auto
const mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener('change', (e) => {
if ((localStorage.getItem(theme.KEY) || 'auto') === 'auto') {
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
}
});
},
apply(pref) {
if (pref === 'auto') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
} else {
document.documentElement.setAttribute('data-theme', pref);
}
},
updateLabel(pref) { if (el.themeText) el.themeText.textContent = `Theme (${pref})`; }
};
// ---- Wire up ----
function initEvents() {
el.searchBtn?.addEventListener('click', () => search.run());
el.searchInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') search.run(); });
document.getElementById('adv-search-button')?.addEventListener('click', () => search.run());
if (el.advToggle && el.filtersForm) {
el.advToggle.addEventListener('click', (e) => {
e.preventDefault();
el.filtersForm.classList.toggle('hidden');
});
}
el.refreshStatusBtn?.addEventListener('click', () => status.fetch());
el.activeTopRefreshBtn?.addEventListener('click', () => status.fetch());
el.clearCompletedBtn?.addEventListener('click', async () => {
try { await fetch(API.clearCompleted, { method: 'DELETE' }); status.fetch(); } catch (_) {}
});
// Close modal on overlay click
el.modalOverlay?.addEventListener('click', (e) => { if (e.target === el.modalOverlay) modal.close(); });
}
// ---- Init ----
theme.init();
initEvents();
status.fetch();
})();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

-212
View File
@@ -1,212 +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"><input type="checkbox" id="format-pdf" value="pdf"> PDF</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" id="format-epub" value="epub" checked> EPUB</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" id="format-mobi" value="mobi" checked> MOBI</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" id="format-azw3" value="azw3" checked> AZW3</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" id="format-fb2" value="fb2" checked> FB2</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" id="format-djvu" value="djvu" checked> DJVU</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" id="format-cbz" value="cbz" checked> CBZ</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" id="format-cbr" value="cbr" checked> 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">
<p class="text-sm opacity-80">Calibre Web Book Downloader</p>
<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>
+9 -2
View File
@@ -13,6 +13,7 @@ port = SERVER_ENV.FLASK_PORT
server_url = f"http://localhost:{port}"
book_title = "077484a10743e5dd5d151013e8c732f4" # "Moby Dick"
# Directory where downloads should appear
download_paths = SERVER_ENV.DOWNLOAD_PATHS
download_dir = SERVER_ENV.INGEST_DIR
# Timeout for waiting for download
download_timeout_seconds = 60 * 5
@@ -38,7 +39,7 @@ def check_download_status(book_id):
continue
# Check success conditions based on download_path
for status_key in ["available", "done"]:
for status_key in ["available", "done", "complete"]:
if status_key in status_data and book_id in status_data[status_key]:
book_status_info = status_data[status_key].get(book_id)
# Check if the status info is a dictionary and has a non-empty download_path
@@ -117,6 +118,12 @@ if SERVER_ENV.USE_BOOK_TITLE:
else:
expected_filename = f"{book_id}.epub"
if book_details.get("content"):
content = book_details.get("content")
for key, path in SERVER_ENV.DOWNLOAD_PATHS.items():
if key in content:
download_dir = path
break
expected_filepath = os.path.join(download_dir, expected_filename)
assert os.path.exists(expected_filepath), f"Expected downloaded file not found at: {expected_filepath}"
@@ -124,7 +131,7 @@ print(f"Verified file exists: {expected_filepath}")
# Step 6 : Download the book
print(f"Step 6: Downloading book {book_id}...")
download_response = requests.get(f"{server_url}/request/api/localdownload?id={book_id}")
download_response = requests.get(f"{server_url}/api/localdownload?id={book_id}")
download_response.raise_for_status()
# Write book to temp file :
temp_file_path = os.path.join("/tmp", f"{book_id}.epub")
+36 -5
View File
@@ -11,9 +11,40 @@ echo "Log file: $LOG_FILE"
set +x
set -e
#!/bin/bash
# Check if EXT_BYPASSER_URL is defined
if [ -n "$EXT_BYPASSER_URL" ]; then
echo "Extracting hostname and ip from bypasser into /etc/hosts"
# Extract hostname
hostname=$(echo "$EXT_BYPASSER_URL" | cut -d'/' -f3 | cut -d':' -f1)
# Resolve to IP (using current DNS before switching to TOR)
ip=$(getent hosts "$hostname" 2>/dev/null | awk '{print $1}')
# If getent fails, try dig
if [ -z "$ip" ]; then
ip=$(dig +short "$hostname" 2>/dev/null | head -n1)
fi
# Only proceed if we got an IP and hostname is not already an IP
if [ -n "$ip" ] && [ "$ip" != "$hostname" ]; then
# Add to /etc/hosts (remove existing entry first to avoid duplicates)
sudo sed -i "/[[:space:]]$hostname$/d" /etc/hosts
echo "$ip $hostname" | sudo tee -a /etc/hosts > /dev/null
echo "Added to /etc/hosts: $ip $hostname"
else
echo "Skipping: $hostname is already an IP or could not be resolved"
fi
else
echo "EXT_BYPASSER_URL not defined, skipping /etc/hosts update"
fi
echo "[*] Running tor script..."
echo "Build version: $BUILD_VERSION"
echo "Release version: $RELEASE_VERSION"
echo "[*] Installing Tor and dependencies..."
echo "[*] Writing Tor transparent proxy config..."
@@ -70,7 +101,7 @@ echo "[✓] Transparent Tor routing enabled."
sleep 5
# Check if outgoing IP is using Tor
echo "[*] Verifying Tor connectivity..."
RESULT=$(pyrequests https://check.torproject.org/api/ip)
RESULT=$(curl -s https://check.torproject.org/api/ip)
echo "RESULT: $RESULT"
IS_TOR=$(echo "$RESULT" | grep -oP '"IsTor":\s*\K(true|false)')
IP=$(echo "$RESULT" | grep -oP '"IP":\s*"\K[^"]+')
@@ -87,10 +118,10 @@ fi
# Get timezone from IP
sleep 1
TIMEZONE=$(pyrequests https://ipapi.co/timezone) || \
TIMEZONE=$(pyrequests http://ip-api.com/line?fields=timezone) || \
TIMEZONE=$(pyrequests http://worldtimeapi.org/api/ip | grep -oP '"timezone":"\K[^"]+') || \
TIMEZONE=$(pyrequests https://ip2tz.isthe.link/v2 | grep -oP '"timezone": *"\K[^"]+') || \
TIMEZONE=$(curl -s https://ipapi.co/timezone) || \
TIMEZONE=$(curl -s http://ip-api.com/line?fields=timezone) || \
TIMEZONE=$(curl -s http://worldtimeapi.org/api/ip | grep -oP '"timezone":"\K[^"]+') || \
TIMEZONE=$(curl -s https://ip2tz.isthe.link/v2 | grep -oP '"timezone": *"\K[^"]+') || \
true
# If TIMEZONE is not set, use the default timezone
+72
View File
@@ -0,0 +1,72 @@
"""WebSocket manager for real-time status updates."""
import logging
from typing import Optional, Dict, Any
from flask_socketio import SocketIO, emit
logger = logging.getLogger(__name__)
class WebSocketManager:
"""Manages WebSocket connections and broadcasts."""
def __init__(self):
self.socketio: Optional[SocketIO] = None
self._enabled = False
def init_app(self, app, socketio: SocketIO):
"""Initialize the WebSocket manager with Flask-SocketIO instance."""
self.socketio = socketio
self._enabled = True
logger.info("WebSocket manager initialized")
def is_enabled(self) -> bool:
"""Check if WebSocket is enabled and ready."""
return self._enabled and self.socketio is not None
def broadcast_status_update(self, status_data: Dict[str, Any]):
"""Broadcast status update to all connected clients."""
if not self.is_enabled():
return
try:
# When calling socketio.emit() outside event handlers, it broadcasts by default
self.socketio.emit('status_update', status_data)
logger.debug(f"Broadcasted status update to all clients")
except Exception as e:
logger.error(f"Error broadcasting status update: {e}")
def broadcast_download_progress(self, book_id: str, progress: float, status: str):
"""Broadcast download progress update for a specific book."""
if not self.is_enabled():
return
try:
data = {
'book_id': book_id,
'progress': progress,
'status': status
}
# When calling socketio.emit() outside event handlers, it broadcasts by default
self.socketio.emit('download_progress', data)
logger.debug(f"Broadcasted progress for book {book_id}: {progress}%")
except Exception as e:
logger.error(f"Error broadcasting download progress: {e}")
def broadcast_notification(self, message: str, notification_type: str = 'info'):
"""Broadcast a notification message to all clients."""
if not self.is_enabled():
return
try:
data = {
'message': message,
'type': notification_type
}
# When calling socketio.emit() outside event handlers, it broadcasts by default
self.socketio.emit('notification', data)
logger.debug(f"Broadcasted notification: {message}")
except Exception as e:
logger.error(f"Error broadcasting notification: {e}")
# Global WebSocket manager instance
ws_manager = WebSocketManager()