mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 18:21:09 +01:00
Search UI revamp, series search and search suggestions (#712)
- Restructured search field options into the left-hand selector. Includes dynamic options for each provider. - Moved Hardcover list and manual search mode into the left hand selector - Added search mode and metadata provider into the search options area - Added new Hardcover series API query and live series suggestions - Added live Hardcover author and title suggestions
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
.PHONY: help install dev build preview typecheck clean up down docker-build refresh restart
|
||||
.PHONY: help install dev build preview typecheck test clean up down docker-build refresh restart
|
||||
|
||||
# Frontend directory
|
||||
FRONTEND_DIR := src/frontend
|
||||
@@ -16,6 +16,7 @@ help:
|
||||
@echo " build - Build frontend for production"
|
||||
@echo " preview - Preview production build"
|
||||
@echo " typecheck - Run TypeScript type checking"
|
||||
@echo " test - Run frontend unit tests"
|
||||
@echo " clean - Remove node_modules and build artifacts"
|
||||
@echo ""
|
||||
@echo "Backend (Docker):"
|
||||
@@ -50,6 +51,11 @@ typecheck:
|
||||
@echo "Running TypeScript type checking..."
|
||||
cd $(FRONTEND_DIR) && npm run typecheck
|
||||
|
||||
# Run frontend unit tests
|
||||
test:
|
||||
@echo "Running frontend unit tests..."
|
||||
cd $(FRONTEND_DIR) && npm run test:unit
|
||||
|
||||
# Clean build artifacts and dependencies
|
||||
clean:
|
||||
@echo "Cleaning build artifacts and dependencies..."
|
||||
|
||||
+1
File diff suppressed because one or more lines are too long
@@ -339,7 +339,7 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
# Ensure runtime reads see updated per-user overrides immediately.
|
||||
try:
|
||||
from shelfmark.core.config import config as app_config
|
||||
app_config.refresh()
|
||||
app_config.refresh(force=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from threading import Lock
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
@@ -68,6 +69,7 @@ class Config:
|
||||
self._user_db_load_attempted = False
|
||||
self._initialized = True
|
||||
self._loaded = False
|
||||
self._last_refresh_time: float = 0.0
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Ensure settings are loaded from the registry."""
|
||||
@@ -117,13 +119,22 @@ class Config:
|
||||
|
||||
self._loaded = True
|
||||
|
||||
def refresh(self) -> None:
|
||||
def refresh(self, force: bool = False) -> None:
|
||||
"""
|
||||
Refresh all cached settings from config files.
|
||||
|
||||
Call this after settings are updated via the UI to ensure
|
||||
the config singleton reflects the new values.
|
||||
|
||||
Multiple calls within a short window (50 ms) are coalesced to
|
||||
avoid redundant disk I/O when several helpers each call refresh()
|
||||
during the same request. Pass ``force=True`` to bypass the guard
|
||||
(e.g. after a settings write).
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if not force and (now - self._last_refresh_time) < 0.05:
|
||||
return
|
||||
|
||||
with self._cache_lock:
|
||||
self._loaded = False
|
||||
self._load_settings()
|
||||
@@ -131,6 +142,7 @@ class Config:
|
||||
self._user_settings_cache.clear()
|
||||
self._user_db = None
|
||||
self._user_db_load_attempted = False
|
||||
self._last_refresh_time = time.monotonic()
|
||||
|
||||
def _get_user_db(self):
|
||||
"""Get or initialize a UserDB handle if available."""
|
||||
|
||||
@@ -56,6 +56,15 @@ def coerce_bool(value: Any, default: bool = False) -> bool:
|
||||
return bool(value)
|
||||
|
||||
|
||||
def get_session_db_user_id(session_obj: Any) -> int | None:
|
||||
"""Extract and coerce `db_user_id` from a Flask session to ``int | None``."""
|
||||
raw = session_obj.get("db_user_id") if session_obj is not None else None
|
||||
try:
|
||||
return int(raw) if raw is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def coerce_int(value: Any, default: int) -> int:
|
||||
"""Best-effort integer coercion with fallback to default."""
|
||||
try:
|
||||
|
||||
@@ -339,7 +339,7 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
|
||||
try:
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
app_config.refresh()
|
||||
app_config.refresh(force=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
+112
-27
@@ -55,6 +55,7 @@ from shelfmark.core.notifications import NotificationContext, NotificationEvent,
|
||||
from shelfmark.core.request_helpers import (
|
||||
emit_ws_event,
|
||||
coerce_bool,
|
||||
get_session_db_user_id,
|
||||
load_users_request_policy_settings,
|
||||
normalize_optional_text,
|
||||
normalize_positive_int,
|
||||
@@ -968,11 +969,7 @@ def api_config() -> Union[Response, Tuple[Response, int]]:
|
||||
from shelfmark.config.env import _is_config_dir_writable
|
||||
from shelfmark.core.onboarding import is_onboarding_complete as _get_onboarding_complete
|
||||
|
||||
raw_db_user_id = session.get("db_user_id")
|
||||
try:
|
||||
db_user_id = int(raw_db_user_id) if raw_db_user_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
db_user_id = None
|
||||
db_user_id = get_session_db_user_id(session)
|
||||
|
||||
search_mode = app_config.get("SEARCH_MODE", "direct", user_id=db_user_id)
|
||||
default_release_source = app_config.get(
|
||||
@@ -1055,11 +1052,7 @@ def _resolve_status_scope(*, require_authenticated: bool = True) -> tuple[bool,
|
||||
if is_admin:
|
||||
return True, None, True
|
||||
|
||||
raw_db_user_id = session.get('db_user_id')
|
||||
try:
|
||||
db_user_id = int(raw_db_user_id) if raw_db_user_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
db_user_id = None
|
||||
db_user_id = get_session_db_user_id(session)
|
||||
|
||||
if db_user_id is None:
|
||||
return False, None, False
|
||||
@@ -1954,28 +1947,40 @@ def api_metadata_providers() -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
try:
|
||||
from shelfmark.metadata_providers import (
|
||||
get_configured_provider_name,
|
||||
list_providers,
|
||||
get_provider,
|
||||
get_provider_kwargs,
|
||||
)
|
||||
|
||||
configured_metadata_provider = app_config.get("METADATA_PROVIDER", "")
|
||||
app_config.refresh()
|
||||
db_user_id = get_session_db_user_id(session)
|
||||
|
||||
configured_metadata_provider = get_configured_provider_name(
|
||||
content_type="ebook",
|
||||
user_id=db_user_id,
|
||||
fallback_to_main=True,
|
||||
)
|
||||
configured_audiobook_metadata_provider = get_configured_provider_name(
|
||||
content_type="audiobook",
|
||||
user_id=db_user_id,
|
||||
fallback_to_main=False,
|
||||
)
|
||||
providers = []
|
||||
for info in list_providers():
|
||||
enabled_key = f"{info['name'].upper()}_ENABLED"
|
||||
provider_info = {
|
||||
"name": info["name"],
|
||||
"display_name": info["display_name"],
|
||||
"requires_auth": info["requires_auth"],
|
||||
"configured": False,
|
||||
"enabled": app_config.get(enabled_key, False) is True,
|
||||
"available": False,
|
||||
}
|
||||
|
||||
# Check if provider is configured and available
|
||||
try:
|
||||
kwargs = get_provider_kwargs(info["name"])
|
||||
provider = get_provider(info["name"], **kwargs)
|
||||
provider_info["available"] = provider.is_available()
|
||||
provider_info["configured"] = (info["name"] == configured_metadata_provider)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1983,13 +1988,79 @@ def api_metadata_providers() -> Union[Response, Tuple[Response, int]]:
|
||||
|
||||
return jsonify({
|
||||
"providers": providers,
|
||||
"configured_provider": configured_metadata_provider or None
|
||||
"configured_provider": configured_metadata_provider or None,
|
||||
"configured_provider_audiobook": configured_audiobook_metadata_provider or None,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Metadata providers error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/metadata/config', methods=['GET'])
|
||||
@login_required
|
||||
def api_metadata_config() -> Union[Response, Tuple[Response, int]]:
|
||||
"""Return provider-specific metadata search config for the active session."""
|
||||
try:
|
||||
from shelfmark.metadata_providers import (
|
||||
get_configured_provider_name,
|
||||
get_provider_capabilities,
|
||||
get_provider,
|
||||
get_provider_default_sort,
|
||||
get_provider_kwargs,
|
||||
get_provider_search_fields,
|
||||
get_provider_sort_options,
|
||||
is_provider_registered,
|
||||
)
|
||||
|
||||
app_config.refresh()
|
||||
content_type = request.args.get('content_type', 'ebook').strip()
|
||||
provider_name = request.args.get('provider', '').strip()
|
||||
|
||||
db_user_id = get_session_db_user_id(session)
|
||||
|
||||
if not provider_name:
|
||||
provider_name = get_configured_provider_name(
|
||||
content_type=content_type,
|
||||
user_id=db_user_id,
|
||||
fallback_to_main=True,
|
||||
)
|
||||
|
||||
if not provider_name:
|
||||
return jsonify({
|
||||
"provider": None,
|
||||
"display_name": None,
|
||||
"enabled": False,
|
||||
"available": False,
|
||||
"search_fields": [],
|
||||
"capabilities": [],
|
||||
"sort_options": [{"value": "relevance", "label": "Most relevant"}],
|
||||
"default_sort": "relevance",
|
||||
})
|
||||
|
||||
if not is_provider_registered(provider_name):
|
||||
return jsonify({"error": f"Unknown metadata provider: {provider_name}"}), 400
|
||||
|
||||
kwargs = get_provider_kwargs(provider_name)
|
||||
provider = get_provider(provider_name, **kwargs)
|
||||
enabled_key = f"{provider_name.upper()}_ENABLED"
|
||||
provider_enabled = app_config.get(enabled_key, False) is True
|
||||
provider_available = provider.is_available()
|
||||
|
||||
return jsonify({
|
||||
"provider": provider_name,
|
||||
"display_name": provider.display_name,
|
||||
"enabled": provider_enabled,
|
||||
"available": provider_available,
|
||||
"search_fields": get_provider_search_fields(provider_name),
|
||||
"capabilities": get_provider_capabilities(provider_name),
|
||||
"sort_options": get_provider_sort_options(provider_name),
|
||||
"default_sort": get_provider_default_sort(provider_name, user_id=db_user_id),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Metadata config error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/metadata/search', methods=['GET'])
|
||||
@login_required
|
||||
def api_metadata_search() -> Union[Response, Tuple[Response, int]]:
|
||||
@@ -2007,7 +2078,11 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
try:
|
||||
from shelfmark.metadata_providers import (
|
||||
get_provider,
|
||||
get_configured_provider,
|
||||
get_provider_kwargs,
|
||||
is_provider_enabled,
|
||||
is_provider_registered,
|
||||
MetadataSearchOptions,
|
||||
SortOrder,
|
||||
CheckboxSearchField,
|
||||
@@ -2017,6 +2092,7 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]:
|
||||
|
||||
query = request.args.get('query', '').strip()
|
||||
content_type = request.args.get('content_type', 'ebook').strip()
|
||||
provider_name = request.args.get('provider', '').strip()
|
||||
|
||||
try:
|
||||
limit = min(int(request.args.get('limit', 40)), 100)
|
||||
@@ -2035,13 +2111,25 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]:
|
||||
except ValueError:
|
||||
sort_order = SortOrder.RELEVANCE
|
||||
|
||||
raw_db_user_id = session.get("db_user_id")
|
||||
try:
|
||||
db_user_id = int(raw_db_user_id) if raw_db_user_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
db_user_id = None
|
||||
db_user_id = get_session_db_user_id(session)
|
||||
|
||||
if provider_name:
|
||||
if not is_provider_registered(provider_name):
|
||||
return jsonify({
|
||||
"error": f"Unknown metadata provider: {provider_name}",
|
||||
"message": f"Unknown metadata provider: {provider_name}",
|
||||
}), 400
|
||||
if not is_provider_enabled(provider_name):
|
||||
return jsonify({
|
||||
"error": f"Metadata provider '{provider_name}' is not enabled",
|
||||
"message": f"{provider_name} is not enabled. Enable it in Settings first.",
|
||||
}), 503
|
||||
|
||||
kwargs = get_provider_kwargs(provider_name)
|
||||
provider = get_provider(provider_name, **kwargs)
|
||||
else:
|
||||
provider = get_configured_provider(content_type=content_type, user_id=db_user_id)
|
||||
|
||||
provider = get_configured_provider(content_type=content_type, user_id=db_user_id)
|
||||
if not provider:
|
||||
return jsonify({
|
||||
"error": "No metadata provider configured",
|
||||
@@ -2118,15 +2206,12 @@ def api_metadata_field_options() -> Response:
|
||||
field_key = request.args.get('field', '').strip()
|
||||
provider_name = request.args.get('provider', '').strip()
|
||||
content_type = request.args.get('content_type', 'ebook').strip()
|
||||
query_text = request.args.get('query', '').strip()
|
||||
|
||||
if not field_key:
|
||||
return jsonify({"options": []})
|
||||
|
||||
raw_db_user_id = session.get("db_user_id")
|
||||
try:
|
||||
db_user_id = int(raw_db_user_id) if raw_db_user_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
db_user_id = None
|
||||
db_user_id = get_session_db_user_id(session)
|
||||
|
||||
provider = None
|
||||
if provider_name:
|
||||
@@ -2140,7 +2225,7 @@ def api_metadata_field_options() -> Response:
|
||||
if not provider or not provider.is_available():
|
||||
return jsonify({"options": []})
|
||||
|
||||
options = provider.get_search_field_options(field_key)
|
||||
options = provider.get_search_field_options(field_key, query=query_text or None)
|
||||
return jsonify({"options": options})
|
||||
except Exception as e:
|
||||
logger.warning(f"Metadata field options endpoint error: {e}")
|
||||
|
||||
@@ -35,6 +35,14 @@ SORT_LABELS: Dict[SortOrder, str] = {
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MetadataCapability:
|
||||
"""Declarative provider capability consumed by shared UI code."""
|
||||
key: str
|
||||
field_key: Optional[str] = None
|
||||
sort: Optional[SortOrder] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextSearchField:
|
||||
"""Text input search field."""
|
||||
@@ -42,6 +50,8 @@ class TextSearchField:
|
||||
label: str # Display label in UI
|
||||
placeholder: str = "" # Placeholder text
|
||||
description: str = "" # Help text
|
||||
suggestions_endpoint: Optional[str] = None # Remote suggestions endpoint for typeahead
|
||||
suggestions_min_query_length: int = 2 # Minimum chars before requesting suggestions
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -95,6 +105,21 @@ SearchField = Union[
|
||||
]
|
||||
|
||||
|
||||
def serialize_metadata_capability(capability: MetadataCapability) -> Dict[str, Any]:
|
||||
"""Serialize a provider capability for API responses."""
|
||||
result: Dict[str, Any] = {
|
||||
"key": capability.key,
|
||||
}
|
||||
|
||||
if capability.field_key:
|
||||
result["field_key"] = capability.field_key
|
||||
|
||||
if capability.sort:
|
||||
result["sort"] = capability.sort.value
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def serialize_search_field(search_field: SearchField) -> Dict[str, Any]:
|
||||
"""Serialize a search field to dict for API response."""
|
||||
result: Dict[str, Any] = {
|
||||
@@ -110,6 +135,10 @@ def serialize_search_field(search_field: SearchField) -> Dict[str, Any]:
|
||||
result["min"] = search_field.min_value
|
||||
result["max"] = search_field.max_value
|
||||
result["step"] = search_field.step
|
||||
elif isinstance(search_field, TextSearchField):
|
||||
if search_field.suggestions_endpoint:
|
||||
result["suggestions_endpoint"] = search_field.suggestions_endpoint
|
||||
result["suggestions_min_query_length"] = search_field.suggestions_min_query_length
|
||||
elif isinstance(search_field, SelectSearchField):
|
||||
result["options"] = search_field.options
|
||||
elif isinstance(search_field, CheckboxSearchField):
|
||||
@@ -169,6 +198,7 @@ class BookMetadata:
|
||||
display_fields: List[DisplayField] = field(default_factory=list)
|
||||
|
||||
# Series info (if book is part of a series)
|
||||
series_id: Optional[str] = None # Provider-specific series ID
|
||||
series_name: Optional[str] = None # Name of the series
|
||||
series_position: Optional[float] = None # This book's position (e.g., 3, 1.5 for novellas)
|
||||
series_count: Optional[int] = None # Total books in the series
|
||||
@@ -294,12 +324,14 @@ class MetadataProvider(ABC):
|
||||
requires_auth: True if API key/authentication is required
|
||||
supported_sorts: List of SortOrder values this provider supports
|
||||
search_fields: List of provider-specific search fields
|
||||
capabilities: Declarative capabilities exposed to shared UI code
|
||||
"""
|
||||
name: str
|
||||
display_name: str
|
||||
requires_auth: bool
|
||||
supported_sorts: List[SortOrder] = [SortOrder.RELEVANCE]
|
||||
search_fields: List[SearchField] = []
|
||||
capabilities: List[MetadataCapability] = []
|
||||
|
||||
@abstractmethod
|
||||
def search(self, options: MetadataSearchOptions) -> List[BookMetadata]:
|
||||
@@ -333,7 +365,11 @@ class MetadataProvider(ABC):
|
||||
has_more=has_more
|
||||
)
|
||||
|
||||
def get_search_field_options(self, field_key: str) -> List[Dict[str, str]]:
|
||||
def get_search_field_options(
|
||||
self,
|
||||
field_key: str,
|
||||
query: Optional[str] = None,
|
||||
) -> List[Dict[str, str]]:
|
||||
"""Get dynamic options for a provider-specific search field."""
|
||||
return []
|
||||
|
||||
@@ -447,10 +483,25 @@ def get_configured_provider(
|
||||
return get_provider(metadata_provider, **kwargs)
|
||||
|
||||
|
||||
def _get_configured_provider_name(user_id: Optional[int] = None) -> str:
|
||||
"""Get the currently configured metadata provider name from config."""
|
||||
def get_configured_provider_name(
|
||||
content_type: str = "ebook",
|
||||
user_id: Optional[int] = None,
|
||||
fallback_to_main: bool = True,
|
||||
) -> str:
|
||||
"""Get the configured metadata provider name for a content type."""
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
app_config.refresh()
|
||||
|
||||
if content_type == "audiobook":
|
||||
audiobook_provider = app_config.get(
|
||||
"METADATA_PROVIDER_AUDIOBOOK",
|
||||
"",
|
||||
user_id=user_id,
|
||||
)
|
||||
if audiobook_provider or not fallback_to_main:
|
||||
return audiobook_provider
|
||||
|
||||
return app_config.get("METADATA_PROVIDER", "", user_id=user_id)
|
||||
|
||||
|
||||
@@ -460,7 +511,7 @@ def get_provider_sort_options(
|
||||
) -> List[Dict[str, str]]:
|
||||
"""Get sort options for a metadata provider as {value, label} dicts."""
|
||||
if provider_name is None:
|
||||
provider_name = _get_configured_provider_name(user_id=user_id)
|
||||
provider_name = get_configured_provider_name(user_id=user_id)
|
||||
|
||||
if provider_name and provider_name in _PROVIDERS:
|
||||
provider_class = _PROVIDERS[provider_name]
|
||||
@@ -480,7 +531,7 @@ def get_provider_search_fields(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get search fields for a metadata provider as serialized dicts."""
|
||||
if provider_name is None:
|
||||
provider_name = _get_configured_provider_name(user_id=user_id)
|
||||
provider_name = get_configured_provider_name(user_id=user_id)
|
||||
|
||||
if provider_name and provider_name in _PROVIDERS:
|
||||
provider_class = _PROVIDERS[provider_name]
|
||||
@@ -491,6 +542,23 @@ def get_provider_search_fields(
|
||||
return [serialize_search_field(f) for f in fields]
|
||||
|
||||
|
||||
def get_provider_capabilities(
|
||||
provider_name: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get declarative capabilities for a metadata provider."""
|
||||
if provider_name is None:
|
||||
provider_name = get_configured_provider_name(user_id=user_id)
|
||||
|
||||
if provider_name and provider_name in _PROVIDERS:
|
||||
provider_class = _PROVIDERS[provider_name]
|
||||
capabilities = getattr(provider_class, "capabilities", [])
|
||||
else:
|
||||
capabilities = []
|
||||
|
||||
return [serialize_metadata_capability(capability) for capability in capabilities]
|
||||
|
||||
|
||||
def get_provider_default_sort(
|
||||
provider_name: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
@@ -499,7 +567,7 @@ def get_provider_default_sort(
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
if provider_name is None:
|
||||
provider_name = _get_configured_provider_name(user_id=user_id)
|
||||
provider_name = get_configured_provider_name(user_id=user_id)
|
||||
|
||||
if not provider_name:
|
||||
return "relevance"
|
||||
@@ -536,7 +604,7 @@ def sync_metadata_provider_selection() -> None:
|
||||
general_config = load_config_file("general")
|
||||
general_config["METADATA_PROVIDER"] = new_provider
|
||||
save_config_file("general", general_config)
|
||||
app_config.refresh()
|
||||
app_config.refresh(force=True)
|
||||
|
||||
|
||||
# Import provider implementations to trigger registration
|
||||
|
||||
@@ -17,10 +17,12 @@ from shelfmark.core.settings_registry import (
|
||||
HeadingField,
|
||||
)
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.request_helpers import coerce_int
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
from shelfmark.metadata_providers import (
|
||||
BookMetadata,
|
||||
DisplayField,
|
||||
MetadataCapability,
|
||||
MetadataProvider,
|
||||
MetadataSearchOptions,
|
||||
SearchResult,
|
||||
@@ -80,6 +82,7 @@ query GetListBooksById($id: Int!, $limit: Int!, $offset: Int!) {
|
||||
featured_book_series {
|
||||
position
|
||||
series {
|
||||
id
|
||||
name
|
||||
primary_books_count
|
||||
}
|
||||
@@ -153,6 +156,7 @@ query GetCurrentUserBooksByStatus($statusId: Int!, $limit: Int!, $offset: Int!)
|
||||
featured_book_series {
|
||||
position
|
||||
series {
|
||||
id
|
||||
name
|
||||
primary_books_count
|
||||
}
|
||||
@@ -168,6 +172,103 @@ query GetCurrentUserBooksByStatus($statusId: Int!, $limit: Int!, $offset: Int!)
|
||||
}
|
||||
"""
|
||||
|
||||
SEARCH_FIELD_OPTIONS_QUERY = """
|
||||
query SearchFieldOptions(
|
||||
$query: String!,
|
||||
$queryType: String!,
|
||||
$limit: Int!,
|
||||
$page: Int!,
|
||||
$sort: String,
|
||||
$fields: String,
|
||||
$weights: String
|
||||
) {
|
||||
search(
|
||||
query: $query,
|
||||
query_type: $queryType,
|
||||
per_page: $limit,
|
||||
page: $page,
|
||||
sort: $sort,
|
||||
fields: $fields,
|
||||
weights: $weights
|
||||
) {
|
||||
results
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
SERIES_BY_AUTHOR_IDS_QUERY = """
|
||||
query SeriesByAuthorIds($authorIds: [Int!], $limit: Int!) {
|
||||
series(
|
||||
where: {
|
||||
author_id: {_in: $authorIds},
|
||||
canonical_id: {_is_null: true},
|
||||
state: {_eq: "active"}
|
||||
},
|
||||
limit: $limit,
|
||||
order_by: [{primary_books_count: desc_nulls_last}, {books_count: desc}, {name: asc}]
|
||||
) {
|
||||
id
|
||||
name
|
||||
primary_books_count
|
||||
books_count
|
||||
author {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
SERIES_BOOKS_BY_ID_QUERY = """
|
||||
query GetSeriesBooks($seriesId: Int!) {
|
||||
series(where: {id: {_eq: $seriesId}}, limit: 1) {
|
||||
id
|
||||
name
|
||||
primary_books_count
|
||||
book_series(
|
||||
where: {
|
||||
book: {
|
||||
canonical_id: {_is_null: true},
|
||||
state: {_in: ["normalized", "normalizing"]}
|
||||
}
|
||||
}
|
||||
order_by: [{position: asc_nulls_last}, {book_id: asc}]
|
||||
) {
|
||||
position
|
||||
book {
|
||||
id
|
||||
title
|
||||
subtitle
|
||||
slug
|
||||
release_date
|
||||
headline
|
||||
description
|
||||
pages
|
||||
rating
|
||||
ratings_count
|
||||
users_count
|
||||
compilation
|
||||
editions_count
|
||||
cached_image
|
||||
cached_contributors
|
||||
contributions(where: {contribution: {_eq: "Author"}}) {
|
||||
author {
|
||||
name
|
||||
}
|
||||
}
|
||||
featured_book_series {
|
||||
position
|
||||
series {
|
||||
id
|
||||
name
|
||||
primary_books_count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
HARDCOVER_WANT_TO_READ_STATUS_ID = 1
|
||||
HARDCOVER_STATUS_PREFIX = "status:"
|
||||
|
||||
@@ -190,6 +291,16 @@ SEARCH_TYPE_FIELDS: Dict[SearchType, str] = {
|
||||
# ISBN is handled separately via search_by_isbn()
|
||||
}
|
||||
|
||||
SERIES_SEARCH_FIELDS = "name,books,author_name"
|
||||
SERIES_SEARCH_WEIGHTS = "2,1,1"
|
||||
SERIES_SEARCH_SORT = "_text_match:desc,readers_count:desc"
|
||||
AUTHOR_SUGGESTION_FIELDS = "name,name_personal,alternate_names"
|
||||
AUTHOR_SUGGESTION_WEIGHTS = "4,3,2"
|
||||
AUTHOR_SUGGESTION_SORT = "_text_match:desc,books_count:desc"
|
||||
TITLE_SUGGESTION_FIELDS = "title,alternative_titles"
|
||||
TITLE_SUGGESTION_WEIGHTS = "5,2"
|
||||
TITLE_SUGGESTION_SORT = "_text_match:desc,users_count:desc"
|
||||
|
||||
|
||||
def _combine_headline_description(headline: Optional[str], description: Optional[str]) -> Optional[str]:
|
||||
"""Combine headline (tagline) and description into a single description."""
|
||||
@@ -228,6 +339,117 @@ def _extract_publish_year(data: Dict) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_release_date(value: Any) -> Optional[datetime]:
|
||||
"""Parse Hardcover release dates stored as YYYY-MM-DD strings."""
|
||||
if not value:
|
||||
return None
|
||||
|
||||
normalized_value = str(value).strip()
|
||||
if not normalized_value:
|
||||
return None
|
||||
|
||||
try:
|
||||
return datetime.fromisoformat(normalized_value[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_series_position(value: Any) -> Optional[float]:
|
||||
"""Normalize a series position to a float for sorting and grouping."""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_search_text(value: str) -> str:
|
||||
"""Normalize free-text search input for matching and caching."""
|
||||
return " ".join(value.split()).strip()
|
||||
|
||||
|
||||
def _unwrap_hit_document(hit: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Extract the document dict from a Typesense hit, or return None."""
|
||||
if not isinstance(hit, dict):
|
||||
return None
|
||||
item = hit.get("document", hit)
|
||||
return item if isinstance(item, dict) else None
|
||||
|
||||
|
||||
def _search_tokens(value: str) -> List[str]:
|
||||
"""Tokenize search text for lightweight prefix matching."""
|
||||
return re.findall(r"[a-z0-9']+", value.casefold())
|
||||
|
||||
|
||||
def _query_matches_author_name(query: str, author_name: str) -> bool:
|
||||
"""Return True when the query looks like an author-name search."""
|
||||
normalized_query = _normalize_search_text(query)
|
||||
normalized_author_name = _normalize_search_text(author_name)
|
||||
if not normalized_query or not normalized_author_name:
|
||||
return False
|
||||
|
||||
query_folded = normalized_query.casefold()
|
||||
author_folded = normalized_author_name.casefold()
|
||||
if query_folded in author_folded:
|
||||
return True
|
||||
|
||||
query_tokens = _search_tokens(normalized_query)
|
||||
author_tokens = _search_tokens(normalized_author_name)
|
||||
if not query_tokens or not author_tokens:
|
||||
return False
|
||||
|
||||
return all(
|
||||
any(author_token.startswith(query_token) for author_token in author_tokens)
|
||||
for query_token in query_tokens
|
||||
)
|
||||
|
||||
|
||||
def _split_part_base_title(title: str) -> Optional[str]:
|
||||
"""Extract the base title from segmented part releases like ', Part 2'."""
|
||||
normalized_title = _normalize_search_text(title)
|
||||
if not normalized_title:
|
||||
return None
|
||||
|
||||
match = re.match(r"^(?P<base>.+?),\s*Part\s+\d+$", normalized_title, re.IGNORECASE)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
base_title = str(match.group("base") or "").strip()
|
||||
return base_title or None
|
||||
|
||||
|
||||
def _series_allows_split_parts(series_name: str) -> bool:
|
||||
"""Return True for series that intentionally organize split-part releases."""
|
||||
normalized_name = _normalize_search_text(series_name).casefold()
|
||||
if not normalized_name:
|
||||
return False
|
||||
|
||||
markers = (
|
||||
"dramatized adaptation",
|
||||
"graphicaudio",
|
||||
"graphic audio",
|
||||
"(3 parts)",
|
||||
"(2 parts)",
|
||||
"(4 parts)",
|
||||
)
|
||||
return any(marker in normalized_name for marker in markers)
|
||||
|
||||
|
||||
def _extract_typesense_hits(result: Dict[str, Any]) -> tuple[List[Dict[str, Any]], int]:
|
||||
"""Extract hit documents + total count from Hardcover search output."""
|
||||
root = result.get("search", result) if isinstance(result, dict) else {}
|
||||
results_obj = root.get("results", {}) if isinstance(root, dict) else {}
|
||||
if isinstance(results_obj, dict):
|
||||
hits = results_obj.get("hits", [])
|
||||
found_count = results_obj.get("found", 0)
|
||||
else:
|
||||
hits = results_obj if isinstance(results_obj, list) else []
|
||||
found_count = 0
|
||||
return hits, found_count
|
||||
|
||||
|
||||
def _build_source_url(slug: str) -> Optional[str]:
|
||||
"""Build Hardcover source URL from book slug."""
|
||||
return f"https://hardcover.app/books/{slug}" if slug else None
|
||||
@@ -427,21 +649,34 @@ class HardcoverProvider(MetadataProvider):
|
||||
SortOrder.OLDEST,
|
||||
SortOrder.SERIES_ORDER,
|
||||
]
|
||||
capabilities = [
|
||||
MetadataCapability(
|
||||
key="view_series",
|
||||
field_key="series",
|
||||
sort=SortOrder.SERIES_ORDER,
|
||||
),
|
||||
]
|
||||
search_fields = [
|
||||
TextSearchField(
|
||||
key="author",
|
||||
label="Author",
|
||||
placeholder="Search author...",
|
||||
description="Search by author name",
|
||||
suggestions_endpoint="/api/metadata/field-options?provider=hardcover&field=author",
|
||||
),
|
||||
TextSearchField(
|
||||
key="title",
|
||||
label="Title",
|
||||
placeholder="Search title...",
|
||||
description="Search by book title",
|
||||
suggestions_endpoint="/api/metadata/field-options?provider=hardcover&field=title",
|
||||
),
|
||||
TextSearchField(
|
||||
key="series",
|
||||
label="Series",
|
||||
placeholder="Search series...",
|
||||
description="Search by series name",
|
||||
suggestions_endpoint="/api/metadata/field-options?provider=hardcover&field=series",
|
||||
),
|
||||
DynamicSelectSearchField(
|
||||
key="hardcover_list",
|
||||
@@ -475,17 +710,12 @@ class HardcoverProvider(MetadataProvider):
|
||||
|
||||
Returns (query, fields, weights) tuple. Fields/weights are None for general search.
|
||||
"""
|
||||
if series and not author and not title:
|
||||
return series, "series_names", "1"
|
||||
if author and not title and not series:
|
||||
return author, "author_names", "1"
|
||||
if title and not author and not series:
|
||||
return title, "title,alternative_titles", "5,1"
|
||||
if author and title and not series:
|
||||
return f"{title} {author}", "title,alternative_titles,author_names", "5,1,3"
|
||||
if series:
|
||||
query = " ".join(p for p in [series, title, author] if p)
|
||||
return query, "series_names,title,alternative_titles,author_names", "5,3,1,2"
|
||||
return default_query, None, None
|
||||
|
||||
def _detect_list_url(self, query: str) -> Optional[tuple[Optional[str], str]]:
|
||||
@@ -642,12 +872,417 @@ class HardcoverProvider(MetadataProvider):
|
||||
|
||||
return self._get_user_lists_cached(connected_user_id)
|
||||
|
||||
def get_search_field_options(self, field_key: str) -> List[Dict[str, str]]:
|
||||
def get_search_field_options(
|
||||
self,
|
||||
field_key: str,
|
||||
query: Optional[str] = None,
|
||||
) -> List[Dict[str, str]]:
|
||||
"""Provide dynamic options for Hardcover-specific advanced fields."""
|
||||
if field_key == "author":
|
||||
return self._search_author_options(query or "")
|
||||
if field_key == "title":
|
||||
return self._search_title_options(query or "")
|
||||
if field_key == "series":
|
||||
return self._search_series_options(query or "")
|
||||
if field_key == "hardcover_list":
|
||||
return self.get_user_lists()
|
||||
return []
|
||||
|
||||
def _search_field_hits(
|
||||
self,
|
||||
*,
|
||||
query: str,
|
||||
query_type: str,
|
||||
limit: int,
|
||||
sort: Optional[str],
|
||||
fields: Optional[str],
|
||||
weights: Optional[str],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Run a Hardcover search request for field-level typeahead options."""
|
||||
normalized_query = _normalize_search_text(query)
|
||||
if not self.api_key or len(normalized_query) < 2:
|
||||
return []
|
||||
|
||||
result = self._execute_query(
|
||||
SEARCH_FIELD_OPTIONS_QUERY,
|
||||
{
|
||||
"query": normalized_query,
|
||||
"queryType": query_type,
|
||||
"limit": limit,
|
||||
"page": 1,
|
||||
"sort": sort,
|
||||
"fields": fields,
|
||||
"weights": weights,
|
||||
},
|
||||
)
|
||||
if not result:
|
||||
return []
|
||||
|
||||
hits, _found_count = _extract_typesense_hits(result)
|
||||
return hits
|
||||
|
||||
def _search_series_by_matching_author(self, query: str) -> List[Dict[str, Any]]:
|
||||
"""Return direct series rows when the query clearly matches an author."""
|
||||
author_hits = self._search_field_hits(
|
||||
query=query,
|
||||
query_type="Author",
|
||||
limit=2,
|
||||
sort=AUTHOR_SUGGESTION_SORT,
|
||||
fields=AUTHOR_SUGGESTION_FIELDS,
|
||||
weights=AUTHOR_SUGGESTION_WEIGHTS,
|
||||
)
|
||||
|
||||
author_ids: List[int] = []
|
||||
for hit in author_hits:
|
||||
item = _unwrap_hit_document(hit)
|
||||
if item is None:
|
||||
continue
|
||||
|
||||
author_name = str(item.get("name") or "").strip()
|
||||
if not _query_matches_author_name(query, author_name):
|
||||
continue
|
||||
|
||||
try:
|
||||
author_id = int(item.get("id"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
if author_id not in author_ids:
|
||||
author_ids.append(author_id)
|
||||
|
||||
if not author_ids:
|
||||
return []
|
||||
|
||||
result = self._execute_query(
|
||||
SERIES_BY_AUTHOR_IDS_QUERY,
|
||||
{
|
||||
"authorIds": author_ids,
|
||||
"limit": 7,
|
||||
},
|
||||
)
|
||||
if not result:
|
||||
return []
|
||||
|
||||
series_rows = result.get("series", [])
|
||||
return [row for row in series_rows if isinstance(row, dict)]
|
||||
|
||||
@cacheable(ttl=120, key_prefix="hardcover:author:options")
|
||||
def _search_author_options(self, query: str) -> List[Dict[str, str]]:
|
||||
"""Return typeahead options for Hardcover author search."""
|
||||
hits = self._search_field_hits(
|
||||
query=query,
|
||||
query_type="Author",
|
||||
limit=7,
|
||||
sort=AUTHOR_SUGGESTION_SORT,
|
||||
fields=AUTHOR_SUGGESTION_FIELDS,
|
||||
weights=AUTHOR_SUGGESTION_WEIGHTS,
|
||||
)
|
||||
options: List[Dict[str, str]] = []
|
||||
seen_labels: set[str] = set()
|
||||
|
||||
for hit in hits:
|
||||
item = _unwrap_hit_document(hit)
|
||||
if item is None:
|
||||
continue
|
||||
|
||||
label = str(item.get("name") or "").strip()
|
||||
normalized_label = label.casefold()
|
||||
if not label or normalized_label in seen_labels:
|
||||
continue
|
||||
|
||||
seen_labels.add(normalized_label)
|
||||
options.append({"value": label, "label": label})
|
||||
|
||||
return options
|
||||
|
||||
@cacheable(ttl=120, key_prefix="hardcover:title:options")
|
||||
def _search_title_options(self, query: str) -> List[Dict[str, str]]:
|
||||
"""Return typeahead options for Hardcover title search."""
|
||||
hits = self._search_field_hits(
|
||||
query=query,
|
||||
query_type="Book",
|
||||
limit=7,
|
||||
sort=TITLE_SUGGESTION_SORT,
|
||||
fields=TITLE_SUGGESTION_FIELDS,
|
||||
weights=TITLE_SUGGESTION_WEIGHTS,
|
||||
)
|
||||
|
||||
exclude_compilations = app_config.get("HARDCOVER_EXCLUDE_COMPILATIONS", False)
|
||||
exclude_unreleased = app_config.get("HARDCOVER_EXCLUDE_UNRELEASED", False)
|
||||
current_year = datetime.now().year
|
||||
|
||||
options: List[Dict[str, str]] = []
|
||||
seen_labels: set[str] = set()
|
||||
|
||||
for hit in hits:
|
||||
item = _unwrap_hit_document(hit)
|
||||
if item is None:
|
||||
continue
|
||||
|
||||
if exclude_compilations and item.get("compilation"):
|
||||
continue
|
||||
|
||||
if exclude_unreleased:
|
||||
release_year = item.get("release_year")
|
||||
try:
|
||||
if release_year is not None and int(release_year) > current_year:
|
||||
continue
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
label = str(item.get("title") or "").strip()
|
||||
normalized_label = label.casefold()
|
||||
if not label or normalized_label in seen_labels:
|
||||
continue
|
||||
|
||||
seen_labels.add(normalized_label)
|
||||
options.append({"value": label, "label": label})
|
||||
|
||||
return options
|
||||
|
||||
def _format_series_option_description(self, item: Dict[str, Any]) -> Optional[str]:
|
||||
"""Build a short description for a series suggestion option."""
|
||||
author_name = item.get("author_name")
|
||||
if not author_name:
|
||||
author_data = item.get("author")
|
||||
if isinstance(author_data, dict):
|
||||
author_name = author_data.get("name")
|
||||
|
||||
parts: List[str] = []
|
||||
if author_name:
|
||||
parts.append(f"by {author_name}")
|
||||
|
||||
books_count = item.get("primary_books_count")
|
||||
if books_count is None:
|
||||
books_count = item.get("books_count")
|
||||
|
||||
try:
|
||||
if books_count is not None:
|
||||
books_count_int = int(books_count)
|
||||
parts.append(f"{books_count_int} book{'s' if books_count_int != 1 else ''}")
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
return " • ".join(parts) if parts else None
|
||||
|
||||
@cacheable(ttl=120, key_prefix="hardcover:series:options")
|
||||
def _search_series_options(self, query: str) -> List[Dict[str, str]]:
|
||||
"""Return typeahead options for Hardcover series search."""
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
author_future = executor.submit(self._search_series_by_matching_author, query)
|
||||
series_future = executor.submit(
|
||||
self._search_field_hits,
|
||||
query=query,
|
||||
query_type="Series",
|
||||
limit=7,
|
||||
sort=SERIES_SEARCH_SORT,
|
||||
fields=SERIES_SEARCH_FIELDS,
|
||||
weights=SERIES_SEARCH_WEIGHTS,
|
||||
)
|
||||
|
||||
author_series = author_future.result()
|
||||
hits = series_future.result()
|
||||
options: List[Dict[str, str]] = []
|
||||
seen_values: set[str] = set()
|
||||
|
||||
series_items: List[Dict[str, Any]] = []
|
||||
series_items.extend(author_series)
|
||||
series_items.extend(
|
||||
doc for hit in hits
|
||||
if (doc := _unwrap_hit_document(hit)) is not None
|
||||
)
|
||||
|
||||
for item in series_items:
|
||||
|
||||
series_id = item.get("id")
|
||||
name = str(item.get("name") or "").strip()
|
||||
if series_id is None or not name:
|
||||
continue
|
||||
|
||||
value = f"id:{series_id}"
|
||||
if value in seen_values:
|
||||
continue
|
||||
seen_values.add(value)
|
||||
|
||||
option: Dict[str, str] = {
|
||||
"value": value,
|
||||
"label": name,
|
||||
}
|
||||
description = self._format_series_option_description(item)
|
||||
if description:
|
||||
option["description"] = description
|
||||
options.append(option)
|
||||
if len(options) >= 7:
|
||||
break
|
||||
|
||||
return options
|
||||
|
||||
def _resolve_series_search_value(self, series_value: str) -> Optional[Dict[str, Any]]:
|
||||
"""Resolve a series field value to a canonical Hardcover series."""
|
||||
normalized_value = _normalize_search_text(series_value)
|
||||
if not normalized_value:
|
||||
return None
|
||||
|
||||
if normalized_value.startswith("id:"):
|
||||
try:
|
||||
return {"id": int(normalized_value.split(":", 1)[1])}
|
||||
except (IndexError, ValueError):
|
||||
logger.debug(f"Invalid Hardcover series id field value: {normalized_value}")
|
||||
return None
|
||||
|
||||
result = self._execute_query(
|
||||
SEARCH_FIELD_OPTIONS_QUERY,
|
||||
{
|
||||
"query": normalized_value,
|
||||
"queryType": "Series",
|
||||
"limit": 10,
|
||||
"page": 1,
|
||||
"sort": SERIES_SEARCH_SORT,
|
||||
"fields": SERIES_SEARCH_FIELDS,
|
||||
"weights": SERIES_SEARCH_WEIGHTS,
|
||||
},
|
||||
)
|
||||
if not result:
|
||||
return None
|
||||
|
||||
hits, _found_count = _extract_typesense_hits(result)
|
||||
if not hits:
|
||||
return None
|
||||
|
||||
normalized_lookup = normalized_value.lower()
|
||||
candidates: List[Dict[str, Any]] = []
|
||||
for hit in hits:
|
||||
item = _unwrap_hit_document(hit)
|
||||
if item is None:
|
||||
continue
|
||||
try:
|
||||
series_id = int(item.get("id"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
name = str(item.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
candidates.append({"id": series_id, "name": name})
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
exact_match = next(
|
||||
(candidate for candidate in candidates if candidate["name"].lower() == normalized_lookup),
|
||||
None,
|
||||
)
|
||||
return exact_match or candidates[0]
|
||||
|
||||
@cacheable(ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="hardcover:series:rows:v4")
|
||||
def _fetch_series_ordered_rows(
|
||||
self,
|
||||
series_id: int,
|
||||
exclude_compilations: bool,
|
||||
exclude_unreleased: bool,
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch and process all books for a series (cached independently of page)."""
|
||||
empty: Dict[str, Any] = {"rows": [], "series_name": "", "total": 0}
|
||||
if not self.api_key:
|
||||
return empty
|
||||
|
||||
result = self._execute_query(
|
||||
SERIES_BOOKS_BY_ID_QUERY,
|
||||
{"seriesId": series_id},
|
||||
)
|
||||
if not result:
|
||||
return empty
|
||||
|
||||
series_items = result.get("series", [])
|
||||
if not isinstance(series_items, list) or not series_items:
|
||||
return empty
|
||||
|
||||
series_data = series_items[0] if isinstance(series_items[0], dict) else {}
|
||||
series_name = str(series_data.get("name") or "").strip() if isinstance(series_data, dict) else ""
|
||||
allow_split_parts = _series_allows_split_parts(series_name)
|
||||
today = datetime.now().date()
|
||||
|
||||
book_series_rows = series_data.get("book_series", []) if isinstance(series_data, dict) else []
|
||||
rows_by_position: Dict[float, Dict[str, Any]] = {}
|
||||
for row in book_series_rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
book_data = row.get("book", {})
|
||||
if not isinstance(book_data, dict) or not book_data:
|
||||
continue
|
||||
if exclude_compilations and book_data.get("compilation"):
|
||||
continue
|
||||
if not allow_split_parts and _split_part_base_title(str(book_data.get("title") or "")):
|
||||
continue
|
||||
|
||||
position = _normalize_series_position(row.get("position"))
|
||||
if position is None:
|
||||
continue
|
||||
|
||||
release_date = _parse_release_date(book_data.get("release_date"))
|
||||
if exclude_unreleased and (release_date is None or release_date.date() > today):
|
||||
continue
|
||||
|
||||
sort_key = (
|
||||
1 if release_date and release_date.date() <= today else 0,
|
||||
0 if book_data.get("compilation") else 1,
|
||||
coerce_int(book_data.get("users_count"), 0),
|
||||
coerce_int(book_data.get("ratings_count"), 0),
|
||||
coerce_int(book_data.get("editions_count"), 0),
|
||||
-coerce_int(book_data.get("id"), 0),
|
||||
)
|
||||
existing_row = rows_by_position.get(position)
|
||||
if existing_row is None:
|
||||
rows_by_position[position] = {"row": row, "sort_key": sort_key}
|
||||
continue
|
||||
if sort_key > existing_row["sort_key"]:
|
||||
rows_by_position[position] = {"row": row, "sort_key": sort_key}
|
||||
|
||||
ordered_rows = [
|
||||
entry["row"]
|
||||
for _position, entry in sorted(rows_by_position.items(), key=lambda item: item[0])
|
||||
]
|
||||
return {"rows": ordered_rows, "series_name": series_name, "total": len(ordered_rows)}
|
||||
|
||||
def _fetch_series_books_by_id(
|
||||
self,
|
||||
series_id: int,
|
||||
page: int,
|
||||
limit: int,
|
||||
exclude_compilations: bool,
|
||||
exclude_unreleased: bool,
|
||||
) -> SearchResult:
|
||||
"""Fetch books for a Hardcover series in canonical series order."""
|
||||
cached = self._fetch_series_ordered_rows(series_id, exclude_compilations, exclude_unreleased)
|
||||
ordered_rows = cached["rows"]
|
||||
series_name = cached["series_name"]
|
||||
total_found = cached["total"]
|
||||
|
||||
offset = (page - 1) * limit
|
||||
page_rows = ordered_rows[offset:offset + limit]
|
||||
|
||||
books: List[BookMetadata] = []
|
||||
for row in page_rows:
|
||||
book_data = row.get("book", {})
|
||||
if not isinstance(book_data, dict) or not book_data:
|
||||
continue
|
||||
try:
|
||||
parsed_book = self._parse_book(book_data)
|
||||
if not parsed_book:
|
||||
continue
|
||||
parsed_book.series_id = str(series_id)
|
||||
if series_name:
|
||||
parsed_book.series_name = series_name
|
||||
parsed_book.series_position = row.get("position")
|
||||
parsed_book.series_count = total_found
|
||||
books.append(parsed_book)
|
||||
except Exception as exc:
|
||||
logger.debug(f"Failed to parse Hardcover series book for series_id={series_id}: {exc}")
|
||||
|
||||
has_more = offset + len(page_rows) < total_found
|
||||
return SearchResult(books=books, page=page, total_found=total_found, has_more=has_more)
|
||||
|
||||
@cacheable(ttl=120, key_prefix="hardcover:user_lists")
|
||||
def _get_user_lists_cached(self, _cache_user_id: str) -> List[Dict[str, str]]:
|
||||
"""Cached wrapper keyed by Hardcover user id to avoid cross-user cache leakage."""
|
||||
@@ -853,6 +1488,21 @@ class HardcoverProvider(MetadataProvider):
|
||||
return SearchResult(books=[], page=options.page, total_found=0, has_more=False)
|
||||
return self._fetch_list_books(list_value_from_field, None, options.page, options.limit)
|
||||
|
||||
series_value_from_field = str(options.fields.get("series", "")).strip()
|
||||
if series_value_from_field:
|
||||
resolved_series = self._resolve_series_search_value(series_value_from_field)
|
||||
if not resolved_series:
|
||||
return SearchResult(books=[], page=options.page, total_found=0, has_more=False)
|
||||
exclude_compilations = app_config.get("HARDCOVER_EXCLUDE_COMPILATIONS", False)
|
||||
exclude_unreleased = app_config.get("HARDCOVER_EXCLUDE_UNRELEASED", False)
|
||||
return self._fetch_series_books_by_id(
|
||||
int(resolved_series["id"]),
|
||||
options.page,
|
||||
options.limit,
|
||||
exclude_compilations,
|
||||
exclude_unreleased,
|
||||
)
|
||||
|
||||
# Handle ISBN search separately
|
||||
if options.search_type == SearchType.ISBN:
|
||||
result = self.search_by_isbn(options.query)
|
||||
@@ -873,11 +1523,10 @@ class HardcoverProvider(MetadataProvider):
|
||||
# Note: Hardcover API requires 'weights' when using 'fields' parameter
|
||||
author_value = options.fields.get("author", "").strip()
|
||||
title_value = options.fields.get("title", "").strip()
|
||||
series_value = options.fields.get("series", "").strip()
|
||||
|
||||
# Build query and field configuration based on which fields are provided
|
||||
query, search_fields, search_weights = self._build_search_params(
|
||||
options.query, author_value, title_value, series_value
|
||||
options.query, author_value, title_value, ""
|
||||
)
|
||||
|
||||
# Build GraphQL query - include fields/weights parameters only when needed
|
||||
@@ -919,13 +1568,7 @@ class HardcoverProvider(MetadataProvider):
|
||||
return SearchResult(books=[], page=options.page, total_found=0, has_more=False)
|
||||
|
||||
# Extract hits from Typesense response
|
||||
results_obj = result.get("search", {}).get("results", {})
|
||||
if isinstance(results_obj, dict):
|
||||
hits = results_obj.get("hits", [])
|
||||
found_count = results_obj.get("found", 0)
|
||||
else:
|
||||
hits = results_obj if isinstance(results_obj, list) else []
|
||||
found_count = 0
|
||||
hits, found_count = _extract_typesense_hits(result)
|
||||
|
||||
# Parse hits, filtering compilations and unreleased books if enabled
|
||||
exclude_compilations = app_config.get("HARDCOVER_EXCLUDE_COMPILATIONS", False)
|
||||
@@ -933,8 +1576,8 @@ class HardcoverProvider(MetadataProvider):
|
||||
current_year = datetime.now().year
|
||||
books = []
|
||||
for hit in hits:
|
||||
item = hit.get("document", hit) if isinstance(hit, dict) else hit
|
||||
if not isinstance(item, dict):
|
||||
item = _unwrap_hit_document(hit)
|
||||
if item is None:
|
||||
continue
|
||||
if exclude_compilations and item.get("compilation"):
|
||||
continue
|
||||
@@ -946,11 +1589,6 @@ class HardcoverProvider(MetadataProvider):
|
||||
if book:
|
||||
books.append(book)
|
||||
|
||||
# If series order sort is selected and series field is provided,
|
||||
# filter to exact matches and sort by position
|
||||
if options.sort == SortOrder.SERIES_ORDER and series_value and books:
|
||||
books = self._apply_series_ordering(books, series_value)
|
||||
|
||||
logger.info(f"Hardcover search '{query}' (fields={search_fields}) returned {len(books)} results")
|
||||
|
||||
# Calculate if there are more results
|
||||
@@ -968,36 +1606,6 @@ class HardcoverProvider(MetadataProvider):
|
||||
logger.error(f"Hardcover search error: {e}")
|
||||
return SearchResult(books=[], page=options.page, total_found=0, has_more=False)
|
||||
|
||||
def _apply_series_ordering(self, books: List[BookMetadata], series_name: str) -> List[BookMetadata]:
|
||||
"""Filter books to exact series match and sort by series position."""
|
||||
series_name_lower = series_name.lower()
|
||||
books_with_position = []
|
||||
|
||||
for book in books:
|
||||
# Fetch full book details to get series info
|
||||
full_book = self.get_book(book.provider_id)
|
||||
if not full_book or not full_book.series_name:
|
||||
continue
|
||||
|
||||
# Exact match on series name
|
||||
if full_book.series_name.lower() != series_name_lower:
|
||||
continue
|
||||
|
||||
# Merge series info into the search result book
|
||||
book.series_name = full_book.series_name
|
||||
book.series_position = full_book.series_position
|
||||
book.series_count = full_book.series_count
|
||||
# Also grab description if search didn't have it
|
||||
if not book.description and full_book.description:
|
||||
book.description = full_book.description
|
||||
books_with_position.append(book)
|
||||
|
||||
# Sort by series position (books without position go last)
|
||||
books_with_position.sort(key=lambda b: (b.series_position is None, b.series_position or 0))
|
||||
|
||||
logger.debug(f"Series ordering: filtered {len(books)} -> {len(books_with_position)} books for '{series_name}'")
|
||||
return books_with_position
|
||||
|
||||
@cacheable(ttl_key="METADATA_CACHE_BOOK_TTL", ttl_default=600, key_prefix="hardcover:book")
|
||||
def get_book(self, book_id: str) -> Optional[BookMetadata]:
|
||||
"""Get book details by Hardcover ID."""
|
||||
@@ -1036,6 +1644,7 @@ class HardcoverProvider(MetadataProvider):
|
||||
featured_book_series {
|
||||
position
|
||||
series {
|
||||
id
|
||||
name
|
||||
primary_books_count
|
||||
}
|
||||
@@ -1334,6 +1943,7 @@ class HardcoverProvider(MetadataProvider):
|
||||
full_description = _combine_headline_description(headline, description)
|
||||
|
||||
# Extract series info from featured_book_series
|
||||
series_id = None
|
||||
series_name = None
|
||||
series_position = None
|
||||
series_count = None
|
||||
@@ -1342,6 +1952,8 @@ class HardcoverProvider(MetadataProvider):
|
||||
series_position = featured_series.get("position")
|
||||
series_data = featured_series.get("series")
|
||||
if series_data:
|
||||
if series_data.get("id") is not None:
|
||||
series_id = str(series_data.get("id"))
|
||||
series_name = series_data.get("name")
|
||||
series_count = series_data.get("primary_books_count")
|
||||
|
||||
@@ -1412,6 +2024,7 @@ class HardcoverProvider(MetadataProvider):
|
||||
publish_year=publish_year,
|
||||
genres=genres,
|
||||
source_url=source_url,
|
||||
series_id=series_id,
|
||||
series_name=series_name,
|
||||
series_position=series_position,
|
||||
series_count=series_count,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:unit": "npm run test:unit:build && node --experimental-specifier-resolution=node --test ../../.local/frontend-test-dist/tests/**/*.node.test.js",
|
||||
"test:unit:build": "tsc -p tsconfig.tests.json"
|
||||
"test:unit:build": "rm -rf ../../.local/frontend-test-dist && tsc -p tsconfig.tests.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
|
||||
+502
-123
@@ -11,6 +11,10 @@ import {
|
||||
RequestPolicyMode,
|
||||
CreateRequestPayload,
|
||||
ActingAsUserSelection,
|
||||
MetadataProviderSummary,
|
||||
MetadataSearchConfig,
|
||||
QueryTargetOption,
|
||||
SearchMode,
|
||||
isMetadataBook,
|
||||
} from './types';
|
||||
import {
|
||||
@@ -20,8 +24,11 @@ import {
|
||||
cancelDownload,
|
||||
retryDownload,
|
||||
getConfig,
|
||||
getMetadataProviders,
|
||||
getMetadataSearchConfig,
|
||||
createRequest,
|
||||
isApiResponseError,
|
||||
updateSelfUser,
|
||||
type DownloadReleasePayload,
|
||||
} from './services/api';
|
||||
import { useToast } from './hooks/useToast';
|
||||
@@ -70,6 +77,7 @@ import { bookFromRequestData } from './utils/requestFulfil';
|
||||
import { policyTrace } from './utils/policyTrace';
|
||||
import { SearchModeProvider } from './contexts/SearchModeContext';
|
||||
import { useSocket } from './contexts/SocketContext';
|
||||
import { buildQueryTargets, getDefaultQueryTargetKey } from './utils/queryTargets';
|
||||
import './styles.css';
|
||||
|
||||
const CONTENT_TYPE_STORAGE_KEY = 'preferred-content-type';
|
||||
@@ -366,7 +374,6 @@ function App() {
|
||||
updateAdvancedFilters,
|
||||
handleSearch,
|
||||
handleResetSearch,
|
||||
handleSortChange,
|
||||
searchFieldValues,
|
||||
updateSearchFieldValue,
|
||||
searchFieldLabels,
|
||||
@@ -397,6 +404,7 @@ function App() {
|
||||
await handleLogout();
|
||||
setBooks([]);
|
||||
clearTracking();
|
||||
setActiveQueryTarget('general');
|
||||
setPendingRequestPayload(null);
|
||||
setActingAsUser(null);
|
||||
setPendingOnBehalfDownload(null);
|
||||
@@ -418,6 +426,11 @@ function App() {
|
||||
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
|
||||
const [releaseBook, setReleaseBook] = useState<Book | null>(null);
|
||||
const [config, setConfig] = useState<AppConfig | null>(null);
|
||||
const [metadataProviders, setMetadataProviders] = useState<MetadataProviderSummary[]>([]);
|
||||
const [configuredMetadataProvider, setConfiguredMetadataProvider] = useState<string | null>(null);
|
||||
const [configuredAudiobookMetadataProvider, setConfiguredAudiobookMetadataProvider] = useState<string | null>(null);
|
||||
const [activeMetadataConfig, setActiveMetadataConfig] = useState<MetadataSearchConfig | null>(null);
|
||||
const [activeQueryTarget, setActiveQueryTarget] = useState<string>('general');
|
||||
const [downloadsSidebarOpen, setDownloadsSidebarOpen] = useState(false);
|
||||
const [sidebarPinnedOpen, setSidebarPinnedOpen] = useState(false);
|
||||
const [headerHeight, setHeaderHeight] = useState(0);
|
||||
@@ -442,7 +455,6 @@ function App() {
|
||||
observer.observe(el);
|
||||
headerObserverRef.current = observer;
|
||||
}, []);
|
||||
const [isManualSearch, setIsManualSearch] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [selfSettingsOpen, setSelfSettingsOpen] = useState(false);
|
||||
const [configBannerOpen, setConfigBannerOpen] = useState(false);
|
||||
@@ -592,7 +604,10 @@ function App() {
|
||||
// Load config function
|
||||
const loadConfig = useCallback(async (mode: 'initial' | 'settings-saved' = 'initial') => {
|
||||
try {
|
||||
const cfg = await getConfig();
|
||||
const [cfg, metadataProviderState] = await Promise.all([
|
||||
getConfig(),
|
||||
getMetadataProviders(),
|
||||
]);
|
||||
|
||||
// Check if search mode changed (only on settings save)
|
||||
if (mode === 'settings-saved' && prevSearchModeRef.current !== cfg.search_mode) {
|
||||
@@ -603,6 +618,9 @@ function App() {
|
||||
|
||||
prevSearchModeRef.current = cfg.search_mode;
|
||||
setConfig(cfg);
|
||||
setMetadataProviders(metadataProviderState.providers);
|
||||
setConfiguredMetadataProvider(metadataProviderState.configured_provider);
|
||||
setConfiguredAudiobookMetadataProvider(metadataProviderState.configured_provider_audiobook);
|
||||
|
||||
// Show onboarding modal on first run (settings enabled but not completed yet)
|
||||
if (mode === 'initial' && cfg.settings_enabled && !cfg.onboarding_complete) {
|
||||
@@ -642,16 +660,108 @@ function App() {
|
||||
}
|
||||
}, [isAuthenticated, loadConfig]);
|
||||
|
||||
const effectiveSearchMode: SearchMode = config?.search_mode ?? 'direct';
|
||||
const defaultMetadataProviderForContentType = contentType === 'audiobook'
|
||||
? (configuredAudiobookMetadataProvider || configuredMetadataProvider)
|
||||
: configuredMetadataProvider;
|
||||
const effectiveMetadataProvider = effectiveSearchMode === 'universal'
|
||||
? (defaultMetadataProviderForContentType || null)
|
||||
: null;
|
||||
|
||||
// Non-admins in universal mode have nothing in the advanced panel
|
||||
const hasAdvancedContent = requestRoleIsAdmin || effectiveSearchMode === 'direct';
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAdvancedContent && showAdvanced) {
|
||||
setShowAdvanced(false);
|
||||
}
|
||||
}, [hasAdvancedContent, showAdvanced, setShowAdvanced]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
if (!isAuthenticated || effectiveSearchMode !== 'universal') {
|
||||
setActiveMetadataConfig(null);
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}
|
||||
|
||||
const loadMetadataConfig = async () => {
|
||||
try {
|
||||
const nextConfig = await getMetadataSearchConfig(
|
||||
contentType,
|
||||
effectiveMetadataProvider ?? undefined,
|
||||
);
|
||||
if (isMounted) {
|
||||
setActiveMetadataConfig(nextConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load metadata search config:', error);
|
||||
if (isMounted) {
|
||||
setActiveMetadataConfig(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadMetadataConfig();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [isAuthenticated, effectiveSearchMode, contentType, effectiveMetadataProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (effectiveSearchMode !== 'universal') {
|
||||
return;
|
||||
}
|
||||
|
||||
const supportedSorts = activeMetadataConfig?.sort_options ?? config?.metadata_sort_options ?? [];
|
||||
const currentSort = advancedFilters.sort;
|
||||
const hasCurrentSort = supportedSorts.some((option) => option.value === currentSort);
|
||||
if (!hasCurrentSort) {
|
||||
const nextSort = activeMetadataConfig?.default_sort || config?.metadata_default_sort || 'relevance';
|
||||
setAdvancedFilters((prev) => ({ ...prev, sort: nextSort }));
|
||||
}
|
||||
}, [
|
||||
activeMetadataConfig,
|
||||
advancedFilters.sort,
|
||||
config?.metadata_default_sort,
|
||||
config?.metadata_sort_options,
|
||||
effectiveSearchMode,
|
||||
setAdvancedFilters,
|
||||
]);
|
||||
|
||||
const prevEffectiveSearchModeRef = useRef<SearchMode>(effectiveSearchMode);
|
||||
useEffect(() => {
|
||||
if (prevEffectiveSearchModeRef.current !== effectiveSearchMode) {
|
||||
setBooks([]);
|
||||
setSelectedBook(null);
|
||||
setReleaseBook(null);
|
||||
clearTracking();
|
||||
prevEffectiveSearchModeRef.current = effectiveSearchMode;
|
||||
}
|
||||
}, [effectiveSearchMode, setBooks, clearTracking]);
|
||||
|
||||
const runSearchWithPolicyRefresh = useCallback(
|
||||
(
|
||||
query: string,
|
||||
fields = searchFieldValues,
|
||||
contentTypeOverride?: ContentType
|
||||
) => {
|
||||
(opts: {
|
||||
query: string;
|
||||
fieldValues?: Record<string, string | number | boolean>;
|
||||
contentTypeOverride?: ContentType;
|
||||
searchModeOverride?: SearchMode;
|
||||
providerOverride?: string;
|
||||
}) => {
|
||||
void refreshRequestPolicy();
|
||||
handleSearch(query, config, fields, contentTypeOverride);
|
||||
void handleSearch({
|
||||
query: opts.query,
|
||||
config,
|
||||
fieldValues: opts.fieldValues,
|
||||
contentTypeOverride: opts.contentTypeOverride,
|
||||
searchMode: opts.searchModeOverride,
|
||||
providerOverride: opts.providerOverride,
|
||||
});
|
||||
},
|
||||
[refreshRequestPolicy, handleSearch, config, searchFieldValues]
|
||||
[refreshRequestPolicy, handleSearch, config]
|
||||
);
|
||||
|
||||
// Execute URL-based search when params are present
|
||||
@@ -664,9 +774,9 @@ function App() {
|
||||
) {
|
||||
urlSearchExecutedRef.current = true;
|
||||
|
||||
const searchMode = config.search_mode || 'direct';
|
||||
const parsedSearchMode = config.search_mode || 'direct';
|
||||
const urlContentTypeOverride =
|
||||
searchMode === 'universal' ? parsedParams.contentType : undefined;
|
||||
parsedSearchMode === 'universal' ? parsedParams.contentType : undefined;
|
||||
|
||||
if (urlContentTypeOverride && urlContentTypeOverride !== contentType) {
|
||||
setContentType(urlContentTypeOverride);
|
||||
@@ -686,6 +796,18 @@ function App() {
|
||||
setSearchInput(parsedParams.searchInput);
|
||||
}
|
||||
|
||||
let nextQueryTarget = 'general';
|
||||
if (parsedSearchMode === 'direct') {
|
||||
if (parsedParams.advancedFilters.isbn) {
|
||||
nextQueryTarget = 'isbn';
|
||||
} else if (parsedParams.advancedFilters.author) {
|
||||
nextQueryTarget = 'author';
|
||||
} else if (parsedParams.advancedFilters.title) {
|
||||
nextQueryTarget = 'title';
|
||||
}
|
||||
}
|
||||
setActiveQueryTarget(nextQueryTarget);
|
||||
|
||||
// Apply advanced filters from URL
|
||||
if (Object.keys(parsedParams.advancedFilters).length > 0) {
|
||||
setAdvancedFilters(prev => ({
|
||||
@@ -693,8 +815,7 @@ function App() {
|
||||
...parsedParams.advancedFilters,
|
||||
}));
|
||||
|
||||
// Show advanced panel if we have filter values (not just query/sort)
|
||||
const hasAdvancedValues = ['isbn', 'author', 'title', 'content'].some(
|
||||
const hasAdvancedValues = ['content', 'lang', 'formats'].some(
|
||||
key => parsedParams.advancedFilters[key as keyof typeof parsedParams.advancedFilters]
|
||||
);
|
||||
if (hasAdvancedValues) {
|
||||
@@ -709,15 +830,27 @@ function App() {
|
||||
};
|
||||
|
||||
const query = buildSearchQuery({
|
||||
searchInput: parsedParams.searchInput,
|
||||
searchInput:
|
||||
parsedSearchMode === 'direct' && nextQueryTarget !== 'general'
|
||||
? ''
|
||||
: parsedParams.searchInput,
|
||||
showAdvanced: true,
|
||||
advancedFilters: mergedFilters as typeof advancedFilters,
|
||||
advancedFilters: {
|
||||
...(mergedFilters as typeof advancedFilters),
|
||||
isbn: nextQueryTarget === 'isbn' ? String(parsedParams.advancedFilters.isbn || '') : '',
|
||||
author: nextQueryTarget === 'author' ? String(parsedParams.advancedFilters.author || '') : '',
|
||||
title: nextQueryTarget === 'title' ? String(parsedParams.advancedFilters.title || '') : '',
|
||||
},
|
||||
bookLanguages,
|
||||
defaultLanguage: defaultLanguageCodes,
|
||||
searchMode,
|
||||
searchMode: parsedSearchMode,
|
||||
});
|
||||
|
||||
runSearchWithPolicyRefresh(query, searchFieldValues, urlContentTypeOverride);
|
||||
runSearchWithPolicyRefresh({
|
||||
query,
|
||||
contentTypeOverride: urlContentTypeOverride,
|
||||
searchModeOverride: parsedSearchMode,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
wasProcessed,
|
||||
@@ -725,11 +858,11 @@ function App() {
|
||||
contentType,
|
||||
config,
|
||||
advancedFilters,
|
||||
searchFieldValues,
|
||||
runSearchWithPolicyRefresh,
|
||||
setSearchInput,
|
||||
setAdvancedFilters,
|
||||
setShowAdvanced,
|
||||
setActiveQueryTarget,
|
||||
]);
|
||||
|
||||
const handleSettingsSaved = useCallback(() => {
|
||||
@@ -761,6 +894,7 @@ function App() {
|
||||
setSelectedBook({
|
||||
...metadataBook,
|
||||
description: fullBook.description || metadataBook.description,
|
||||
series_id: fullBook.series_id || metadataBook.series_id,
|
||||
series_name: fullBook.series_name,
|
||||
series_position: fullBook.series_position,
|
||||
series_count: fullBook.series_count,
|
||||
@@ -1131,6 +1265,7 @@ function App() {
|
||||
setReleaseBook({
|
||||
...book,
|
||||
description: fullBook.description || book.description,
|
||||
series_id: fullBook.series_id || book.series_id,
|
||||
series_name: fullBook.series_name,
|
||||
series_position: fullBook.series_position,
|
||||
series_count: fullBook.series_count,
|
||||
@@ -1365,32 +1500,295 @@ function App() {
|
||||
? config.default_language
|
||||
: [bookLanguages[0]?.code || 'en'];
|
||||
|
||||
const searchMode = config?.search_mode || 'direct';
|
||||
const logoUrl = withBasePath('/logo.png');
|
||||
|
||||
// Manual search is only allowed when the default policy permits browsing releases
|
||||
const universalDefaultMode = getUniversalDefaultPolicyMode();
|
||||
const manualSearchAllowed = effectiveSearchMode === 'universal'
|
||||
&& (universalDefaultMode === 'download' || universalDefaultMode === 'request_release');
|
||||
|
||||
const queryTargets = useMemo<QueryTargetOption[]>(
|
||||
() => buildQueryTargets({
|
||||
searchMode: effectiveSearchMode,
|
||||
metadataSearchFields: activeMetadataConfig?.search_fields ?? [],
|
||||
manualSearchAllowed,
|
||||
}),
|
||||
[effectiveSearchMode, activeMetadataConfig?.search_fields, manualSearchAllowed],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveQueryTarget((prev) => {
|
||||
if (queryTargets.some((target) => target.key === prev)) return prev;
|
||||
return getDefaultQueryTargetKey(queryTargets);
|
||||
});
|
||||
}, [queryTargets]);
|
||||
|
||||
const activeQueryOption = useMemo(
|
||||
() => queryTargets.find((target) => target.key === activeQueryTarget) ?? queryTargets[0],
|
||||
[queryTargets, activeQueryTarget],
|
||||
);
|
||||
|
||||
const activeQueryField = activeQueryOption?.field ?? null;
|
||||
const seriesBrowseCapability = useMemo(
|
||||
() => activeMetadataConfig?.capabilities.find((capability) =>
|
||||
capability.key === 'view_series'
|
||||
&& capability.field_key
|
||||
) ?? null,
|
||||
[activeMetadataConfig?.capabilities],
|
||||
);
|
||||
const seriesBrowseTarget = useMemo(
|
||||
() => seriesBrowseCapability?.field_key
|
||||
? queryTargets.find((target) => target.field?.key === seriesBrowseCapability.field_key) ?? null
|
||||
: null,
|
||||
[queryTargets, seriesBrowseCapability?.field_key],
|
||||
);
|
||||
|
||||
const activeQueryValue = useMemo(() => {
|
||||
if (!activeQueryOption || activeQueryOption.source === 'general' || activeQueryOption.source === 'manual') {
|
||||
return searchInput;
|
||||
}
|
||||
|
||||
if (activeQueryOption.source === 'direct-field') {
|
||||
if (activeQueryOption.key === 'isbn') return advancedFilters.isbn;
|
||||
if (activeQueryOption.key === 'author') return advancedFilters.author;
|
||||
if (activeQueryOption.key === 'title') return advancedFilters.title;
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!activeQueryOption.field) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (activeQueryOption.field.type === 'CheckboxSearchField') {
|
||||
return searchFieldValues[activeQueryOption.field.key] ?? activeQueryOption.field.default ?? false;
|
||||
}
|
||||
|
||||
return searchFieldValues[activeQueryOption.field.key] ?? '';
|
||||
}, [activeQueryOption, searchInput, advancedFilters, searchFieldValues]);
|
||||
|
||||
const activeQueryValueLabel = useMemo(() => {
|
||||
if (!activeQueryOption?.field) {
|
||||
return undefined;
|
||||
}
|
||||
return searchFieldLabels[activeQueryOption.field.key];
|
||||
}, [activeQueryOption, searchFieldLabels]);
|
||||
const activeQueryUsesSeriesBrowse = Boolean(
|
||||
seriesBrowseCapability?.field_key
|
||||
&& activeQueryOption?.source === 'provider-field'
|
||||
&& activeQueryOption.field?.key === seriesBrowseCapability.field_key
|
||||
&& activeQueryValue !== ''
|
||||
&& activeQueryValue !== false,
|
||||
);
|
||||
|
||||
const getAppliedUniversalSort = useCallback((sortOverride?: string) => {
|
||||
const requestedSort = sortOverride ?? advancedFilters.sort;
|
||||
const seriesBrowseSort = seriesBrowseCapability?.sort ?? '';
|
||||
|
||||
if (activeQueryUsesSeriesBrowse && seriesBrowseSort) {
|
||||
return seriesBrowseSort;
|
||||
}
|
||||
|
||||
if (requestedSort === seriesBrowseSort) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return requestedSort;
|
||||
}, [activeQueryUsesSeriesBrowse, advancedFilters.sort, seriesBrowseCapability?.sort]);
|
||||
|
||||
const handleActiveQueryValueChange = useCallback((value: string | number | boolean, label?: string) => {
|
||||
if (!activeQueryOption || activeQueryOption.source === 'general' || activeQueryOption.source === 'manual') {
|
||||
setSearchInput(typeof value === 'string' ? value : String(value ?? ''));
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeQueryOption.source === 'direct-field') {
|
||||
const nextValue = typeof value === 'string' ? value : String(value ?? '');
|
||||
if (activeQueryOption.key === 'isbn') {
|
||||
updateAdvancedFilters({ isbn: nextValue });
|
||||
} else if (activeQueryOption.key === 'author') {
|
||||
updateAdvancedFilters({ author: nextValue });
|
||||
} else if (activeQueryOption.key === 'title') {
|
||||
updateAdvancedFilters({ title: nextValue });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeQueryOption.field) {
|
||||
updateSearchFieldValue(activeQueryOption.field.key, value, label);
|
||||
}
|
||||
}, [activeQueryOption, setSearchInput, updateAdvancedFilters, updateSearchFieldValue]);
|
||||
|
||||
const handleSearchModeChange = useCallback((nextMode: SearchMode) => {
|
||||
setConfig((prev) => prev ? { ...prev, search_mode: nextMode } : prev);
|
||||
updateSelfUser({ settings: { SEARCH_MODE: nextMode } })
|
||||
.then(() => loadConfig('settings-saved'))
|
||||
.catch((err) => console.error('Failed to save search mode:', err));
|
||||
}, [loadConfig]);
|
||||
|
||||
const handleMetadataProviderChange = useCallback((provider: string) => {
|
||||
if (contentType === 'audiobook') {
|
||||
setConfiguredAudiobookMetadataProvider(provider);
|
||||
} else {
|
||||
setConfiguredMetadataProvider(provider);
|
||||
}
|
||||
const key = contentType === 'audiobook' ? 'METADATA_PROVIDER_AUDIOBOOK' : 'METADATA_PROVIDER';
|
||||
updateSelfUser({ settings: { [key]: provider } })
|
||||
.then(() => loadConfig('settings-saved'))
|
||||
.catch((err) => console.error('Failed to save metadata provider:', err));
|
||||
}, [contentType, loadConfig]);
|
||||
|
||||
const buildCurrentSearchRequest = useCallback((sortOverride?: string) => {
|
||||
const appliedSort = effectiveSearchMode === 'universal'
|
||||
? getAppliedUniversalSort(sortOverride)
|
||||
: (sortOverride ?? advancedFilters.sort);
|
||||
const nextFilters = appliedSort === advancedFilters.sort && sortOverride === undefined
|
||||
? advancedFilters
|
||||
: { ...advancedFilters, sort: appliedSort };
|
||||
|
||||
if (effectiveSearchMode === 'direct') {
|
||||
const directFilters = {
|
||||
...nextFilters,
|
||||
isbn: '',
|
||||
author: '',
|
||||
title: '',
|
||||
};
|
||||
|
||||
if (activeQueryOption?.source === 'direct-field') {
|
||||
const nextValue = typeof activeQueryValue === 'string' ? activeQueryValue : String(activeQueryValue ?? '');
|
||||
if (activeQueryOption.key === 'isbn') {
|
||||
directFilters.isbn = nextValue;
|
||||
} else if (activeQueryOption.key === 'author') {
|
||||
directFilters.author = nextValue;
|
||||
} else if (activeQueryOption.key === 'title') {
|
||||
directFilters.title = nextValue;
|
||||
}
|
||||
}
|
||||
|
||||
const query = buildSearchQuery({
|
||||
searchInput: activeQueryOption?.source === 'general' ? searchInput : '',
|
||||
showAdvanced: true,
|
||||
advancedFilters: directFilters,
|
||||
bookLanguages,
|
||||
defaultLanguage: defaultLanguageCodes,
|
||||
searchMode: effectiveSearchMode,
|
||||
});
|
||||
|
||||
return {
|
||||
query,
|
||||
fieldValues: {},
|
||||
providerOverride: undefined,
|
||||
appliedSort,
|
||||
};
|
||||
}
|
||||
|
||||
const fieldValues =
|
||||
activeQueryOption?.source === 'provider-field'
|
||||
&& activeQueryOption.field
|
||||
&& activeQueryValue !== ''
|
||||
&& activeQueryValue !== false
|
||||
? { [activeQueryOption.field.key]: activeQueryValue }
|
||||
: {};
|
||||
|
||||
const query = buildSearchQuery({
|
||||
searchInput:
|
||||
activeQueryOption?.source === 'general' || activeQueryOption?.source === 'manual'
|
||||
? searchInput
|
||||
: '',
|
||||
showAdvanced: true,
|
||||
advancedFilters: nextFilters,
|
||||
bookLanguages,
|
||||
defaultLanguage: defaultLanguageCodes,
|
||||
searchMode: effectiveSearchMode,
|
||||
});
|
||||
|
||||
return {
|
||||
query,
|
||||
fieldValues,
|
||||
providerOverride: effectiveMetadataProvider ?? undefined,
|
||||
appliedSort,
|
||||
};
|
||||
}, [
|
||||
activeQueryOption,
|
||||
activeQueryValue,
|
||||
advancedFilters,
|
||||
bookLanguages,
|
||||
defaultLanguageCodes,
|
||||
effectiveMetadataProvider,
|
||||
effectiveSearchMode,
|
||||
getAppliedUniversalSort,
|
||||
searchInput,
|
||||
]);
|
||||
|
||||
// Handle "View Series" - trigger search with series field and series order sort
|
||||
const handleSearchSeries = useCallback((seriesName: string) => {
|
||||
const handleSearchSeries = useCallback((seriesName: string, seriesId?: string) => {
|
||||
const seriesTarget = seriesBrowseTarget;
|
||||
const seriesFieldKey = seriesTarget?.field?.key;
|
||||
const seriesSort = seriesBrowseCapability?.sort;
|
||||
if (!seriesTarget || !seriesFieldKey || !seriesSort) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear UI state
|
||||
setSearchInput('');
|
||||
setSelectedBook(null);
|
||||
setReleaseBook(null);
|
||||
clearTracking();
|
||||
|
||||
// Set sort to series_order (but don't show advanced panel or persist series value)
|
||||
const newFilters = { ...advancedFilters, sort: 'series_order' };
|
||||
const newFilters = { ...advancedFilters, sort: seriesSort };
|
||||
setAdvancedFilters(newFilters);
|
||||
|
||||
// Trigger search with series field (passed directly, not persisted in UI)
|
||||
setActiveQueryTarget(seriesTarget.key);
|
||||
updateSearchFieldValue(
|
||||
seriesFieldKey,
|
||||
seriesId ? `id:${seriesId}` : seriesName,
|
||||
seriesName,
|
||||
);
|
||||
|
||||
const query = buildSearchQuery({
|
||||
searchInput: '',
|
||||
showAdvanced: true,
|
||||
advancedFilters: newFilters,
|
||||
bookLanguages,
|
||||
defaultLanguage: defaultLanguageCodes,
|
||||
searchMode,
|
||||
searchMode: effectiveSearchMode,
|
||||
});
|
||||
runSearchWithPolicyRefresh(query, { ...searchFieldValues, series: seriesName });
|
||||
}, [setSearchInput, clearTracking, searchFieldValues, advancedFilters, setAdvancedFilters, bookLanguages, defaultLanguageCodes, searchMode, runSearchWithPolicyRefresh]);
|
||||
|
||||
runSearchWithPolicyRefresh({
|
||||
query,
|
||||
fieldValues: { [seriesFieldKey]: seriesId ? `id:${seriesId}` : seriesName },
|
||||
searchModeOverride: effectiveSearchMode,
|
||||
providerOverride: effectiveMetadataProvider ?? undefined,
|
||||
});
|
||||
}, [
|
||||
advancedFilters,
|
||||
bookLanguages,
|
||||
clearTracking,
|
||||
defaultLanguageCodes,
|
||||
effectiveMetadataProvider,
|
||||
effectiveSearchMode,
|
||||
runSearchWithPolicyRefresh,
|
||||
setAdvancedFilters,
|
||||
setSearchInput,
|
||||
seriesBrowseCapability?.sort,
|
||||
seriesBrowseTarget,
|
||||
updateSearchFieldValue,
|
||||
]);
|
||||
|
||||
const canSearchSeriesForBook = useCallback((book: Book | null): boolean => {
|
||||
if (!book?.provider || !book.series_name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!seriesBrowseCapability?.sort || !seriesBrowseTarget?.field || !activeMetadataConfig?.provider) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return book.provider === activeMetadataConfig.provider;
|
||||
}, [
|
||||
activeMetadataConfig?.provider,
|
||||
seriesBrowseCapability?.sort,
|
||||
seriesBrowseTarget?.field,
|
||||
]);
|
||||
|
||||
const handleManualSearch = useCallback(() => {
|
||||
const trimmed = searchInput.trim();
|
||||
@@ -1407,73 +1805,37 @@ function App() {
|
||||
setReleaseBook(syntheticBook);
|
||||
}, [searchInput]);
|
||||
|
||||
// Manual search is only allowed when the default policy permits browsing releases
|
||||
const universalDefaultMode = getUniversalDefaultPolicyMode();
|
||||
const manualSearchAllowed = searchMode === 'universal'
|
||||
&& (universalDefaultMode === 'download' || universalDefaultMode === 'request_release');
|
||||
const isListBrowsing = useMemo(() => {
|
||||
const dynamicFieldKeys = (config?.metadata_search_fields ?? [])
|
||||
.filter((field) => field.type === 'DynamicSelectSearchField')
|
||||
.map((field) => field.key);
|
||||
|
||||
if (dynamicFieldKeys.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return dynamicFieldKeys.some((key) => {
|
||||
const value = searchFieldValues[key];
|
||||
if (typeof value === 'string') {
|
||||
return value.trim() !== '';
|
||||
}
|
||||
return value !== undefined && value !== null && value !== false;
|
||||
});
|
||||
}, [config?.metadata_search_fields, searchFieldValues]);
|
||||
|
||||
const activeListLabel = useMemo(() => {
|
||||
if (!isListBrowsing) return '';
|
||||
const field = (config?.metadata_search_fields ?? [])
|
||||
.find((f) => f.type === 'DynamicSelectSearchField' && searchFieldValues[f.key]);
|
||||
return field ? (searchFieldLabels[field.key] || '') : '';
|
||||
}, [isListBrowsing, config?.metadata_search_fields, searchFieldValues, searchFieldLabels]);
|
||||
|
||||
// Reset manual search if policy changes to disallow it
|
||||
useEffect(() => {
|
||||
if (!manualSearchAllowed && isManualSearch) {
|
||||
setIsManualSearch(false);
|
||||
if (!manualSearchAllowed && activeQueryTarget === 'manual') {
|
||||
setActiveQueryTarget(getDefaultQueryTargetKey(queryTargets));
|
||||
}
|
||||
}, [manualSearchAllowed, isManualSearch]);
|
||||
|
||||
const handleManualSearchToggle = useCallback(() => {
|
||||
setIsManualSearch(prev => {
|
||||
if (!prev) {
|
||||
// Turning on: clear any dynamic select field values (e.g. list selection)
|
||||
const dynamicKeys = (config?.metadata_search_fields ?? [])
|
||||
.filter((f) => f.type === 'DynamicSelectSearchField')
|
||||
.map((f) => f.key);
|
||||
for (const key of dynamicKeys) {
|
||||
updateSearchFieldValue(key, '');
|
||||
}
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
}, [config?.metadata_search_fields, updateSearchFieldValue]);
|
||||
}, [manualSearchAllowed, activeQueryTarget, queryTargets]);
|
||||
|
||||
// Unified search dispatch: intercepts manual search mode, otherwise runs normal search
|
||||
const handleSearchDispatch = useCallback(() => {
|
||||
if (isManualSearch) {
|
||||
if (activeQueryOption?.source === 'manual') {
|
||||
handleManualSearch();
|
||||
return;
|
||||
}
|
||||
const query = buildSearchQuery({
|
||||
searchInput,
|
||||
showAdvanced,
|
||||
advancedFilters,
|
||||
bookLanguages,
|
||||
defaultLanguage: defaultLanguageCodes,
|
||||
searchMode,
|
||||
const request = buildCurrentSearchRequest();
|
||||
if (request.appliedSort !== advancedFilters.sort) {
|
||||
updateAdvancedFilters({ sort: request.appliedSort });
|
||||
}
|
||||
runSearchWithPolicyRefresh({
|
||||
query: request.query,
|
||||
fieldValues: request.fieldValues,
|
||||
searchModeOverride: effectiveSearchMode,
|
||||
providerOverride: request.providerOverride,
|
||||
});
|
||||
runSearchWithPolicyRefresh(query);
|
||||
}, [isManualSearch, handleManualSearch, searchInput, showAdvanced, advancedFilters, bookLanguages, defaultLanguageCodes, searchMode, runSearchWithPolicyRefresh]);
|
||||
}, [
|
||||
activeQueryOption,
|
||||
advancedFilters.sort,
|
||||
buildCurrentSearchRequest,
|
||||
effectiveSearchMode,
|
||||
handleManualSearch,
|
||||
runSearchWithPolicyRefresh,
|
||||
updateAdvancedFilters,
|
||||
]);
|
||||
|
||||
const isBrowseFulfilMode = fulfillingRequest !== null;
|
||||
const activeReleaseBook = fulfillingRequest?.book ?? releaseBook;
|
||||
@@ -1500,7 +1862,7 @@ function App() {
|
||||
: '';
|
||||
|
||||
const mainAppContent = (
|
||||
<SearchModeProvider searchMode={searchMode}>
|
||||
<SearchModeProvider searchMode={effectiveSearchMode}>
|
||||
<div ref={headerRef} className="fixed top-0 left-0 right-0 z-40">
|
||||
<Header
|
||||
calibreWebUrl={config?.calibre_web_url || ''}
|
||||
@@ -1508,8 +1870,9 @@ function App() {
|
||||
debug={config?.debug || false}
|
||||
logoUrl={logoUrl}
|
||||
showSearch={!isInitialState}
|
||||
searchInput={searchInput}
|
||||
onSearchChange={setSearchInput}
|
||||
searchInput={activeQueryValue}
|
||||
searchInputLabel={activeQueryValueLabel}
|
||||
onSearchChange={handleActiveQueryValueChange}
|
||||
onDownloadsClick={() => setDownloadsSidebarOpen((prev) => !prev)}
|
||||
onSettingsClick={() => {
|
||||
if (config?.settings_enabled) {
|
||||
@@ -1529,21 +1892,25 @@ function App() {
|
||||
actingAsUser={actingAsUser}
|
||||
onActingAsUserChange={setActingAsUser}
|
||||
statusCounts={statusCounts}
|
||||
onLogoClick={() => { handleResetSearch(config); setIsManualSearch(false); }}
|
||||
onLogoClick={() => {
|
||||
handleResetSearch(config);
|
||||
setActiveQueryTarget('general');
|
||||
}}
|
||||
authRequired={authRequired}
|
||||
isAuthenticated={isAuthenticated}
|
||||
onLogout={handleLogoutWithCleanup}
|
||||
onSearch={handleSearchDispatch}
|
||||
onAdvancedToggle={() => setShowAdvanced(!showAdvanced)}
|
||||
onAdvancedToggle={hasAdvancedContent ? () => setShowAdvanced(!showAdvanced) : undefined}
|
||||
isLoading={isSearching}
|
||||
onShowToast={showToast}
|
||||
onRemoveToast={removeToast}
|
||||
contentType={contentType}
|
||||
onContentTypeChange={setContentType}
|
||||
allowedContentTypes={allowedContentTypes}
|
||||
isManualSearch={isManualSearch}
|
||||
searchDisabled={isListBrowsing}
|
||||
activeListLabel={activeListLabel}
|
||||
queryTargets={queryTargets}
|
||||
activeQueryTarget={activeQueryTarget}
|
||||
onQueryTargetChange={setActiveQueryTarget}
|
||||
activeQueryField={activeQueryField}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1567,19 +1934,19 @@ function App() {
|
||||
}
|
||||
>
|
||||
<AdvancedFilters
|
||||
visible={showAdvanced && !isInitialState}
|
||||
bookLanguages={bookLanguages}
|
||||
defaultLanguage={defaultLanguageCodes}
|
||||
supportedFormats={supportedFormats}
|
||||
filters={advancedFilters}
|
||||
onFiltersChange={updateAdvancedFilters}
|
||||
metadataSearchFields={config?.metadata_search_fields}
|
||||
searchFieldValues={searchFieldValues}
|
||||
onSearchFieldChange={updateSearchFieldValue}
|
||||
onSubmit={handleSearchDispatch}
|
||||
isManualSearch={isManualSearch}
|
||||
onManualSearchToggle={manualSearchAllowed ? handleManualSearchToggle : undefined}
|
||||
/>
|
||||
visible={showAdvanced && !isInitialState}
|
||||
bookLanguages={bookLanguages}
|
||||
defaultLanguage={defaultLanguageCodes}
|
||||
filters={advancedFilters}
|
||||
onFiltersChange={updateAdvancedFilters}
|
||||
searchMode={effectiveSearchMode}
|
||||
onSearchModeChange={handleSearchModeChange}
|
||||
metadataProviders={metadataProviders}
|
||||
activeMetadataProvider={effectiveMetadataProvider}
|
||||
onMetadataProviderChange={handleMetadataProviderChange}
|
||||
contentType={contentType}
|
||||
isAdmin={requestRoleIsAdmin}
|
||||
/>
|
||||
|
||||
<main
|
||||
className="relative w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-6"
|
||||
@@ -1590,29 +1957,32 @@ function App() {
|
||||
}
|
||||
>
|
||||
<SearchSection
|
||||
onSearch={() => handleSearchDispatch()}
|
||||
onSearch={handleSearchDispatch}
|
||||
isLoading={isSearching}
|
||||
isInitialState={isInitialState}
|
||||
bookLanguages={bookLanguages}
|
||||
defaultLanguage={defaultLanguageCodes}
|
||||
supportedFormats={config?.supported_formats || DEFAULT_SUPPORTED_FORMATS}
|
||||
logoUrl={logoUrl}
|
||||
searchInput={searchInput}
|
||||
onSearchInputChange={setSearchInput}
|
||||
queryValue={activeQueryValue}
|
||||
queryValueLabel={activeQueryValueLabel}
|
||||
onQueryValueChange={handleActiveQueryValueChange}
|
||||
queryTargets={queryTargets}
|
||||
activeQueryTarget={activeQueryTarget}
|
||||
onQueryTargetChange={setActiveQueryTarget}
|
||||
showAdvanced={showAdvanced}
|
||||
onAdvancedToggle={() => setShowAdvanced(!showAdvanced)}
|
||||
onAdvancedToggle={hasAdvancedContent ? () => setShowAdvanced(!showAdvanced) : undefined}
|
||||
advancedFilters={advancedFilters}
|
||||
onAdvancedFiltersChange={updateAdvancedFilters}
|
||||
metadataSearchFields={config?.metadata_search_fields}
|
||||
searchFieldValues={searchFieldValues}
|
||||
onSearchFieldChange={updateSearchFieldValue}
|
||||
contentType={contentType}
|
||||
onContentTypeChange={setContentType}
|
||||
allowedContentTypes={allowedContentTypes}
|
||||
isManualSearch={isManualSearch}
|
||||
onManualSearchToggle={manualSearchAllowed ? handleManualSearchToggle : undefined}
|
||||
searchDisabled={isListBrowsing}
|
||||
activeListLabel={activeListLabel}
|
||||
activeQueryField={activeQueryField}
|
||||
searchMode={effectiveSearchMode}
|
||||
onSearchModeChange={handleSearchModeChange}
|
||||
metadataProviders={metadataProviders}
|
||||
activeMetadataProvider={effectiveMetadataProvider}
|
||||
onMetadataProviderChange={handleMetadataProviderChange}
|
||||
isAdmin={requestRoleIsAdmin}
|
||||
/>
|
||||
|
||||
<ResultsSection
|
||||
@@ -1624,11 +1994,20 @@ function App() {
|
||||
getButtonState={getDirectActionButtonState}
|
||||
getUniversalButtonState={getUniversalActionButtonState}
|
||||
sortValue={advancedFilters.sort}
|
||||
onSortChange={(value) => handleSortChange(value, config)}
|
||||
metadataSortOptions={config?.metadata_sort_options}
|
||||
onSortChange={(value) => {
|
||||
const request = buildCurrentSearchRequest(value);
|
||||
updateAdvancedFilters({ sort: request.appliedSort });
|
||||
runSearchWithPolicyRefresh({
|
||||
query: request.query,
|
||||
fieldValues: request.fieldValues,
|
||||
searchModeOverride: effectiveSearchMode,
|
||||
providerOverride: request.providerOverride,
|
||||
});
|
||||
}}
|
||||
metadataSortOptions={activeMetadataConfig?.sort_options}
|
||||
hasMore={hasMore}
|
||||
isLoadingMore={isLoadingMore}
|
||||
onLoadMore={() => loadMore(config)}
|
||||
onLoadMore={() => loadMore(config, effectiveSearchMode)}
|
||||
totalFound={totalFound}
|
||||
/>
|
||||
|
||||
@@ -1641,7 +2020,7 @@ function App() {
|
||||
setSelectedBook(null);
|
||||
void handleGetReleases(book);
|
||||
}}
|
||||
onSearchSeries={handleSearchSeries}
|
||||
onSearchSeries={canSearchSeriesForBook(selectedBook) ? handleSearchSeries : undefined}
|
||||
buttonState={
|
||||
isMetadataBook(selectedBook)
|
||||
? getUniversalActionButtonState(selectedBook.id)
|
||||
@@ -1670,7 +2049,7 @@ function App() {
|
||||
bookLanguages={bookLanguages}
|
||||
currentStatus={statusForButtonState}
|
||||
defaultReleaseSource={config?.default_release_source}
|
||||
onSearchSeries={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual' ? undefined : handleSearchSeries}
|
||||
onSearchSeries={isBrowseFulfilMode || !canSearchSeriesForBook(activeReleaseBook) ? undefined : handleSearchSeries}
|
||||
defaultShowManualQuery={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'}
|
||||
isRequestMode={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'}
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { ReactNode, KeyboardEvent } from 'react';
|
||||
import { AdvancedFilterState, Language, MetadataSearchField } from '../types';
|
||||
import { ReactNode } from 'react';
|
||||
import {
|
||||
AdvancedFilterState,
|
||||
ContentType,
|
||||
Language,
|
||||
MetadataProviderSummary,
|
||||
SearchMode,
|
||||
} from '../types';
|
||||
import { normalizeLanguageSelection } from '../utils/languageFilters';
|
||||
import { useSearchMode } from '../contexts/SearchModeContext';
|
||||
import { LanguageMultiSelect } from './LanguageMultiSelect';
|
||||
import { DropdownList } from './DropdownList';
|
||||
import { CONTENT_OPTIONS } from '../data/filterOptions';
|
||||
import { SearchFieldRenderer, ToggleSwitch } from './shared';
|
||||
|
||||
const FORMAT_TYPES = ['pdf', 'epub', 'mobi', 'azw3', 'fb2', 'djvu', 'cbz', 'cbr', 'zip', 'rar'] as const;
|
||||
|
||||
@@ -13,47 +17,41 @@ interface AdvancedFiltersProps {
|
||||
visible: boolean;
|
||||
bookLanguages: Language[];
|
||||
defaultLanguage: string[];
|
||||
supportedFormats: string[];
|
||||
filters: AdvancedFilterState;
|
||||
onFiltersChange: (updates: Partial<AdvancedFilterState>) => void;
|
||||
formClassName?: string;
|
||||
renderWrapper?: (form: ReactNode) => ReactNode;
|
||||
// Universal mode props
|
||||
metadataSearchFields?: MetadataSearchField[];
|
||||
searchFieldValues?: Record<string, string | number | boolean>;
|
||||
onSearchFieldChange?: (key: string, value: string | number | boolean, label?: string) => void;
|
||||
// Submit handler for Enter key
|
||||
onSubmit?: () => void;
|
||||
// Manual search mode (universal only)
|
||||
isManualSearch?: boolean;
|
||||
onManualSearchToggle?: () => void;
|
||||
searchMode: SearchMode;
|
||||
onSearchModeChange: (mode: SearchMode) => void;
|
||||
metadataProviders?: MetadataProviderSummary[];
|
||||
activeMetadataProvider?: string | null;
|
||||
onMetadataProviderChange?: (provider: string) => void;
|
||||
contentType?: ContentType;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
const SEARCH_MODE_OPTIONS = [
|
||||
{ value: 'direct', label: 'Direct', description: 'Search web sources for books and download directly. Works out of the box.' },
|
||||
{ value: 'universal', label: 'Universal', description: 'Metadata-based search with downloads from all sources. Book and Audiobook support.' },
|
||||
];
|
||||
|
||||
export const AdvancedFilters = ({
|
||||
visible,
|
||||
bookLanguages,
|
||||
defaultLanguage,
|
||||
supportedFormats,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
formClassName,
|
||||
renderWrapper,
|
||||
metadataSearchFields = [],
|
||||
searchFieldValues = {},
|
||||
onSearchFieldChange,
|
||||
onSubmit,
|
||||
isManualSearch = false,
|
||||
onManualSearchToggle,
|
||||
searchMode,
|
||||
onSearchModeChange,
|
||||
metadataProviders = [],
|
||||
activeMetadataProvider,
|
||||
onMetadataProviderChange,
|
||||
contentType = 'ebook',
|
||||
isAdmin = false,
|
||||
}: AdvancedFiltersProps) => {
|
||||
const { searchMode } = useSearchMode();
|
||||
const { isbn, author, title, lang, content, formats } = filters;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && onSubmit) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
const { lang, content, formats } = filters;
|
||||
|
||||
const handleLangChange = (next: string[]) => {
|
||||
const normalized = normalizeLanguageSelection(next);
|
||||
@@ -73,45 +71,64 @@ export const AdvancedFilters = ({
|
||||
const formatOptions = FORMAT_TYPES.map(format => ({
|
||||
value: format,
|
||||
label: format.toUpperCase(),
|
||||
disabled: !supportedFormats.includes(format),
|
||||
}));
|
||||
|
||||
const providerOptions = metadataProviders.map((provider) => {
|
||||
const details: string[] = [];
|
||||
if (!provider.enabled) details.push('Disabled in Settings');
|
||||
if (provider.enabled && !provider.available) details.push('Not configured');
|
||||
if (provider.requires_auth) details.push('API key required');
|
||||
|
||||
return {
|
||||
value: provider.name,
|
||||
label: provider.display_name,
|
||||
description: details.length > 0 ? details.join(' • ') : undefined,
|
||||
disabled: !provider.enabled || !provider.available,
|
||||
};
|
||||
});
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
// Universal search mode: render dynamic provider fields + manual search toggle
|
||||
if (searchMode === 'universal') {
|
||||
const hasProviderFields = metadataSearchFields.length > 0;
|
||||
const wrapperClassName = formClassName
|
||||
? 'px-2'
|
||||
: 'px-2 lg:ml-[calc(3rem+1rem)] lg:w-[calc(50vw+4rem)]';
|
||||
|
||||
// If no fields and no toggle available, don't show the section
|
||||
if (!hasProviderFields && !onManualSearchToggle) return null;
|
||||
const settingsForm = (
|
||||
<div className={wrapperClassName}>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<DropdownList
|
||||
label="Search Mode"
|
||||
options={SEARCH_MODE_OPTIONS}
|
||||
value={searchMode}
|
||||
onChange={(value) => {
|
||||
const next = Array.isArray(value) ? value[0] ?? 'direct' : value;
|
||||
onSearchModeChange(next === 'universal' ? 'universal' : 'direct');
|
||||
}}
|
||||
placeholder="Choose a mode"
|
||||
widthClassName="w-full"
|
||||
/>
|
||||
|
||||
// When formClassName is provided (initial state), the form carries its own padding;
|
||||
// otherwise use the default positioning classes for the header-bar state.
|
||||
const wrapperClassName = formClassName
|
||||
? 'px-2'
|
||||
: 'px-2 lg:ml-[calc(3rem+1rem)] lg:w-[calc(50vw+4rem)]';
|
||||
|
||||
const universalForm = (
|
||||
<div className={wrapperClassName}>
|
||||
{onManualSearchToggle && (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium opacity-70">Search Options</span>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<span className="text-xs opacity-60">Manual search</span>
|
||||
<ToggleSwitch
|
||||
checked={isManualSearch}
|
||||
onChange={() => onManualSearchToggle()}
|
||||
color="emerald"
|
||||
{searchMode === 'universal' && (
|
||||
<DropdownList
|
||||
label={contentType === 'audiobook' ? 'Audiobook Metadata Provider' : 'Book Metadata Provider'}
|
||||
options={providerOptions}
|
||||
value={activeMetadataProvider ?? ''}
|
||||
onChange={(value) => {
|
||||
const next = Array.isArray(value) ? value[0] ?? '' : value;
|
||||
onMetadataProviderChange?.(next);
|
||||
}}
|
||||
placeholder="Choose a provider"
|
||||
widthClassName="w-full"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isManualSearch && (
|
||||
<p className="text-xs opacity-50 mb-3">
|
||||
Manual search queries release sources directly. Some sources may return limited metadata, which can affect file naming templates.
|
||||
</p>
|
||||
)}
|
||||
{!isManualSearch && metadataSearchFields.length > 0 && (
|
||||
</>
|
||||
)}
|
||||
|
||||
{searchMode === 'direct' && (
|
||||
<div className="space-y-4">
|
||||
<form
|
||||
id="search-filters"
|
||||
className={
|
||||
@@ -119,144 +136,20 @@ export const AdvancedFilters = ({
|
||||
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'
|
||||
}
|
||||
>
|
||||
{metadataSearchFields.map((field) => (
|
||||
<div key={field.key}>
|
||||
{field.type !== 'CheckboxSearchField' && (
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label htmlFor={`${field.key}-input`} className="text-sm opacity-80">
|
||||
{field.label}
|
||||
</label>
|
||||
{field.type === 'DynamicSelectSearchField' && searchFieldValues[field.key] && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchFieldChange?.(field.key, '')}
|
||||
className="text-xs font-medium text-sky-500 hover:text-sky-400 transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<SearchFieldRenderer
|
||||
field={field}
|
||||
value={searchFieldValues[field.key] ?? (field.type === 'CheckboxSearchField' ? false : '')}
|
||||
onChange={(value, label) => onSearchFieldChange?.(field.key, value, label)}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
{field.description && (
|
||||
<p className="text-xs mt-1 opacity-60">{field.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const wrappedUniversalForm = renderWrapper ? (
|
||||
renderWrapper(universalForm)
|
||||
) : (
|
||||
<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">{universalForm}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return wrappedUniversalForm;
|
||||
}
|
||||
|
||||
// Direct download mode: render existing hardcoded filters
|
||||
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-[calc(50vw+4rem)]'
|
||||
}
|
||||
>
|
||||
<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"
|
||||
enterKeyHint="search"
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border"
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
color: 'var(--text)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
value={isbn}
|
||||
onChange={e => {
|
||||
onFiltersChange({ isbn: e.target.value });
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
<LanguageMultiSelect
|
||||
options={bookLanguages}
|
||||
value={lang}
|
||||
onChange={handleLangChange}
|
||||
defaultLanguageCodes={defaultLanguage}
|
||||
label="Language"
|
||||
/>
|
||||
</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"
|
||||
enterKeyHint="search"
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border"
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
color: 'var(--text)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
value={author}
|
||||
onChange={e => {
|
||||
onFiltersChange({ author: e.target.value });
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
<DropdownList
|
||||
label="Content"
|
||||
options={CONTENT_OPTIONS}
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
placeholder="All"
|
||||
/>
|
||||
</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"
|
||||
enterKeyHint="search"
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border"
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
color: 'var(--text)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
value={title}
|
||||
onChange={e => {
|
||||
onFiltersChange({ title: e.target.value });
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</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"
|
||||
@@ -267,17 +160,17 @@ export const AdvancedFilters = ({
|
||||
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>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return wrappedForm;
|
||||
return renderWrapper ? (
|
||||
renderWrapper(settingsForm)
|
||||
) : (
|
||||
<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">{settingsForm}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ interface DetailsModalProps {
|
||||
onClose: () => void;
|
||||
onDownload: (book: Book) => Promise<void>;
|
||||
onFindDownloads?: (book: Book) => void; // For Universal mode
|
||||
onSearchSeries?: (seriesName: string) => void; // Callback to search for series
|
||||
onSearchSeries?: (seriesName: string, seriesId?: string) => void; // Callback to search for series
|
||||
buttonState: ButtonStateInfo;
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSearchSeries(book.series_name!);
|
||||
onSearchSeries(book.series_name!, book.series_id);
|
||||
handleClose();
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors flex-shrink-0"
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { StatusData, Book } from '../types';
|
||||
import { withBasePath } from '../utils/basePath';
|
||||
|
||||
interface DownloadsSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
status: StatusData;
|
||||
onClearCompleted: () => void;
|
||||
onCancel: (id: string) => void;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string; waveColor: string }> = {
|
||||
queued: { bg: 'bg-amber-500/20', text: 'text-amber-700 dark:text-amber-300', label: 'Queued', waveColor: 'rgba(217, 119, 6, 0.3)' },
|
||||
resolving: { bg: 'bg-indigo-500/20', text: 'text-indigo-700 dark:text-indigo-300', label: 'Resolving', waveColor: 'rgba(79, 70, 229, 0.3)' },
|
||||
downloading: { bg: 'bg-sky-500/20', text: 'text-sky-700 dark:text-sky-300', label: 'Downloading', waveColor: 'rgba(2, 132, 199, 0.3)' },
|
||||
locating: { bg: 'bg-teal-500/20', text: 'text-teal-700 dark:text-teal-300', label: 'Locating files', waveColor: 'rgba(13, 148, 136, 0.3)' },
|
||||
complete: { bg: 'bg-green-500/20', text: 'text-green-700 dark:text-green-300', label: 'Complete', waveColor: '' },
|
||||
error: { bg: 'bg-red-500/20', text: 'text-red-700 dark:text-red-300', label: 'Error', waveColor: '' },
|
||||
cancelled: { bg: 'bg-gray-500/20', text: 'text-gray-700 dark:text-gray-300', label: 'Cancelled', waveColor: '' },
|
||||
};
|
||||
|
||||
// Book thumbnail component with fallback
|
||||
const BookThumbnail = ({ preview, title }: { preview?: string; title?: string }) => {
|
||||
if (!preview) {
|
||||
return (
|
||||
<div
|
||||
className="w-16 h-24 rounded-tl bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] font-medium text-gray-500 dark:text-gray-400"
|
||||
style={{ aspectRatio: '2/3' }}
|
||||
>
|
||||
No Cover
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={preview}
|
||||
alt={title || 'Book cover'}
|
||||
className="w-16 h-24 object-cover rounded-tl shadow-sm"
|
||||
style={{ aspectRatio: '2/3' }}
|
||||
onError={(e) => {
|
||||
// Replace with placeholder on error
|
||||
const target = e.target as HTMLImageElement;
|
||||
const placeholder = document.createElement('div');
|
||||
placeholder.className = 'w-16 h-24 rounded-tl bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] font-medium text-gray-500 dark:text-gray-400';
|
||||
placeholder.style.aspectRatio = '2/3';
|
||||
placeholder.textContent = 'No Cover';
|
||||
target.replaceWith(placeholder);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to get progress percentage based on status
|
||||
const getStatusProgress = (statusName: string, bookProgress?: number): number => {
|
||||
switch (statusName) {
|
||||
case 'queued':
|
||||
return 5;
|
||||
case 'resolving':
|
||||
return 15;
|
||||
case 'downloading':
|
||||
// Map actual progress (0-100) to 20-100 range
|
||||
if (typeof bookProgress === 'number') {
|
||||
return 20 + (bookProgress * 0.8);
|
||||
}
|
||||
return 20;
|
||||
case 'locating':
|
||||
return 90;
|
||||
case 'complete':
|
||||
case 'error':
|
||||
return 100;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to get progress bar color based on status
|
||||
const getProgressBarColor = (statusName: string): string => {
|
||||
if (statusName === 'complete') return 'bg-green-600';
|
||||
if (statusName === 'error') return 'bg-red-600';
|
||||
if (statusName === 'queued') return 'bg-amber-600';
|
||||
if (statusName === 'resolving') return 'bg-indigo-600';
|
||||
if (statusName === 'downloading') return 'bg-sky-600';
|
||||
if (statusName === 'locating') return 'bg-teal-600';
|
||||
return 'bg-sky-600';
|
||||
};
|
||||
|
||||
|
||||
export const DownloadsSidebar = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
status,
|
||||
onClearCompleted,
|
||||
onCancel,
|
||||
}: 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 }> = [];
|
||||
|
||||
const statusTypes = ['downloading', 'locating', 'resolving', 'queued', 'error', 'complete', 'cancelled'];
|
||||
|
||||
statusTypes.forEach((statusName) => {
|
||||
const items = (status as any)[statusName];
|
||||
if (items && Object.keys(items).length > 0) {
|
||||
Object.values(items).forEach((book: any) => {
|
||||
allDownloadItems.push({ book, status: statusName });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by added_time descending (newest first)
|
||||
allDownloadItems.sort((a, b) => (b.book.added_time || 0) - (a.book.added_time || 0));
|
||||
|
||||
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', 'locating', 'downloading'].includes(statusName);
|
||||
const isQueued = statusName === 'queued';
|
||||
const isActive = statusName === 'resolving' || statusName === 'locating' || statusName === 'downloading';
|
||||
const isCompleted = statusName === 'complete';
|
||||
const hasError = statusName === 'error';
|
||||
|
||||
// Get progress information
|
||||
const progress = getStatusProgress(statusName, book.progress);
|
||||
const progressBarColor = getProgressBarColor(statusName);
|
||||
|
||||
// Format progress text - use status_message from backend if available
|
||||
let progressText = book.status_message || statusStyle.label;
|
||||
if (statusName === 'downloading' && !book.status_message && book.progress && book.size) {
|
||||
// Fallback: calculate size progress only if backend didn't provide a message
|
||||
const sizeValue = parseFloat(book.size.replace(/[^\d.]/g, ''));
|
||||
const sizeUnit = book.size.replace(/[\d.\s]/g, '');
|
||||
const downloadedSize = (book.progress / 100) * sizeValue;
|
||||
progressText = `${downloadedSize.toFixed(1)}${sizeUnit} / ${book.size}`;
|
||||
} else if (isCompleted) {
|
||||
progressText = book.status_message || 'Complete';
|
||||
} else if (hasError) {
|
||||
progressText = book.status_message || '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)' }}
|
||||
>
|
||||
{/* Action Button - top right corner */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCancel(book.id);
|
||||
}}
|
||||
className={`absolute top-1 right-1 z-10 flex h-8 w-8 items-center justify-center rounded-full transition-colors ${
|
||||
isActive || isQueued
|
||||
? 'text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30'
|
||||
: 'text-gray-500 hover:text-red-600 hover:bg-red-100 dark:hover:bg-red-900/30'
|
||||
}`}
|
||||
title={isActive ? 'Stop download' : isQueued ? 'Remove from queue' : 'Clear from list'}
|
||||
aria-label={isActive ? 'Stop download' : isQueued ? 'Remove from queue' : 'Clear from list'}
|
||||
>
|
||||
{isActive ? (
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex gap-2">
|
||||
{/* Book Thumbnail - left side */}
|
||||
<div className="flex-shrink-0">
|
||||
<BookThumbnail preview={book.preview} title={book.title} />
|
||||
</div>
|
||||
|
||||
{/* Book Info - right side */}
|
||||
<div className="flex-1 min-w-0 flex flex-col pl-1.5 pr-3 pt-2 pb-2">
|
||||
{/* Title & Author - with safe area for cancel/clear button */}
|
||||
<div className="pr-6">
|
||||
<h3 className="font-semibold text-sm truncate" title={book.title}>
|
||||
{isCompleted && book.download_path ? (
|
||||
<a
|
||||
href={withBasePath(`/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>
|
||||
|
||||
{/* Format, Size, Source */}
|
||||
<div className="text-xs opacity-70 mt-1">
|
||||
{book.format && <span className="uppercase">{book.format}</span>}
|
||||
{book.format && book.size && <span> • </span>}
|
||||
{book.size && <span>{book.size}</span>}
|
||||
{book.source_display_name && (
|
||||
<>
|
||||
<span> • </span>
|
||||
<span>{book.source_display_name}</span>
|
||||
</>
|
||||
)}
|
||||
{book.username && (
|
||||
<>
|
||||
<span> • </span>
|
||||
<span>{book.username}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="flex justify-end mt-auto pt-1">
|
||||
<span
|
||||
className={`relative px-2 py-0.5 rounded-lg text-xs font-medium ${statusStyle.bg} ${statusStyle.text}`}
|
||||
>
|
||||
{/* Wave animation overlay for in-progress states */}
|
||||
{isInProgress && statusStyle.waveColor && (
|
||||
<span
|
||||
key={statusName}
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: `linear-gradient(90deg, transparent 0%, ${statusStyle.waveColor} 50%, transparent 100%)`,
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'wave 2s linear infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="relative">{progressText}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar - at bottom */}
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/50 z-40 transition-opacity duration-300 ${
|
||||
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-full w-full sm:w-96 z-50 flex flex-col shadow-2xl transition-transform duration-300 ${
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
style={{ background: 'var(--bg)' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between p-4 border-b"
|
||||
style={{ paddingTop: 'calc(1rem + env(safe-area-inset-top))', borderColor: 'var(--border-muted)' }}
|
||||
>
|
||||
<h2 className="text-lg font-semibold">
|
||||
Downloads{allDownloadItems.length > 0 && ` (${allDownloadItems.length})`}
|
||||
</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>
|
||||
|
||||
{/* 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 */}
|
||||
<div
|
||||
className="p-3 border-t flex items-center justify-center"
|
||||
style={{
|
||||
borderColor: 'var(--border-muted)',
|
||||
paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearCompleted}
|
||||
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
Clear Completed
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -49,6 +49,7 @@ interface DropdownProps {
|
||||
renderTrigger?: (props: { isOpen: boolean; toggle: () => void }) => ReactNode;
|
||||
/** Disable max-height and overflow scrolling (for panels with nested dropdowns) */
|
||||
noScrollLimit?: boolean;
|
||||
triggerChrome?: 'default' | 'minimal';
|
||||
}
|
||||
|
||||
export const Dropdown = ({
|
||||
@@ -62,6 +63,7 @@ export const Dropdown = ({
|
||||
disabled = false,
|
||||
renderTrigger,
|
||||
noScrollLimit = false,
|
||||
triggerChrome = 'default',
|
||||
}: DropdownProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -155,23 +157,28 @@ export const Dropdown = ({
|
||||
type="button"
|
||||
onClick={toggleOpen}
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-sm border flex items-center justify-between text-left focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 transition-[border-radius] duration-150 ${buttonClassName}`}
|
||||
className={`w-full px-3 py-2 text-sm border flex items-center justify-between gap-2 text-left focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 transition-[border-radius] duration-150 ${buttonClassName}`}
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
background: triggerChrome === 'minimal' ? 'transparent' : 'var(--bg-soft)',
|
||||
color: 'var(--text)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
borderColor: triggerChrome === 'minimal' ? 'transparent' : 'var(--border-muted)',
|
||||
borderWidth: triggerChrome === 'minimal' ? 0 : undefined,
|
||||
borderRadius: isOpen
|
||||
? panelDirection === 'down'
|
||||
? '0.5rem 0.5rem 0 0'
|
||||
: '0 0 0.5rem 0.5rem'
|
||||
: '0.5rem',
|
||||
? triggerChrome === 'minimal'
|
||||
? '0'
|
||||
: panelDirection === 'down'
|
||||
? '0.5rem 0.5rem 0 0'
|
||||
: '0 0 0.5rem 0.5rem'
|
||||
: triggerChrome === 'minimal'
|
||||
? '0'
|
||||
: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<span className="truncate">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{summary ?? <span className="opacity-60">Select an option</span>}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
className={`h-4 w-4 flex-shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -211,4 +218,3 @@ export const Dropdown = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ interface DropdownListProps {
|
||||
buttonClassName?: string;
|
||||
summaryFormatter?: (selected: DropdownListOption[], placeholder: string) => ReactNode;
|
||||
keepOpenOnSelect?: boolean;
|
||||
triggerChrome?: 'default' | 'minimal';
|
||||
}
|
||||
|
||||
export const DropdownList = ({
|
||||
@@ -37,6 +38,7 @@ export const DropdownList = ({
|
||||
buttonClassName,
|
||||
summaryFormatter,
|
||||
keepOpenOnSelect,
|
||||
triggerChrome = 'default',
|
||||
}: DropdownListProps) => {
|
||||
const selectedValues = normalizeValue(value, multiple);
|
||||
const selectedOptions = options.filter(opt => selectedValues.includes(opt.value));
|
||||
@@ -102,6 +104,7 @@ export const DropdownList = ({
|
||||
align={align}
|
||||
widthClassName={widthClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
triggerChrome={triggerChrome}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div role="listbox" aria-multiselectable={multiple}>
|
||||
@@ -159,4 +162,3 @@ const normalizeValue = (value: string[] | string | null | undefined, multiple: b
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback, useMemo, forwardRef, useImper
|
||||
import { SearchBar, SearchBarHandle } from './SearchBar';
|
||||
import { DropdownList } from './DropdownList';
|
||||
import { getAdminUsers } from '../services/api';
|
||||
import { ContentType, ActingAsUserSelection } from '../types';
|
||||
import { ContentType, ActingAsUserSelection, MetadataSearchField, QueryTargetOption } from '../types';
|
||||
import { ActivityStatusCounts, getActivityBadgeState } from '../utils/activityBadge';
|
||||
import { formatActingAsUserName } from '../utils/actingAsUser';
|
||||
import { withBasePath } from '../utils/basePath';
|
||||
@@ -17,8 +17,9 @@ interface HeaderProps {
|
||||
debug?: boolean;
|
||||
logoUrl?: string;
|
||||
showSearch?: boolean;
|
||||
searchInput?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
searchInput?: string | number | boolean;
|
||||
searchInputLabel?: string;
|
||||
onSearchChange?: (value: string | number | boolean, label?: string) => void;
|
||||
onSearch?: () => void;
|
||||
onAdvancedToggle?: () => void;
|
||||
isLoading?: boolean;
|
||||
@@ -40,9 +41,10 @@ interface HeaderProps {
|
||||
contentType?: ContentType;
|
||||
onContentTypeChange?: (type: ContentType) => void;
|
||||
allowedContentTypes?: ContentType[];
|
||||
isManualSearch?: boolean;
|
||||
searchDisabled?: boolean;
|
||||
activeListLabel?: string;
|
||||
queryTargets?: QueryTargetOption[];
|
||||
activeQueryTarget?: string;
|
||||
onQueryTargetChange?: (target: string) => void;
|
||||
activeQueryField?: MetadataSearchField | null;
|
||||
}
|
||||
|
||||
export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
@@ -52,6 +54,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
logoUrl,
|
||||
showSearch = false,
|
||||
searchInput = '',
|
||||
searchInputLabel,
|
||||
onSearchChange,
|
||||
onSearch,
|
||||
onAdvancedToggle,
|
||||
@@ -74,9 +77,10 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
contentType = 'ebook',
|
||||
onContentTypeChange,
|
||||
allowedContentTypes,
|
||||
isManualSearch = false,
|
||||
searchDisabled = false,
|
||||
activeListLabel,
|
||||
queryTargets = [],
|
||||
activeQueryTarget = 'general',
|
||||
onQueryTargetChange,
|
||||
activeQueryField = null,
|
||||
}, ref) => {
|
||||
const activityBadge = getActivityBadgeState(statusCounts, isAdmin);
|
||||
const settingsEnabled = canAccessSettings ?? isAdmin;
|
||||
@@ -265,8 +269,8 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
onSearch?.();
|
||||
};
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
onSearchChange?.(value);
|
||||
const handleSearchChange = (value: string | number | boolean, label?: string) => {
|
||||
onSearchChange?.(value, label);
|
||||
};
|
||||
|
||||
const handleActingAsChange = (nextValue: string[] | string) => {
|
||||
@@ -651,9 +655,9 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
)}
|
||||
<SearchBar
|
||||
ref={searchBarRef}
|
||||
className="flex-1 lg:flex-initial"
|
||||
inputClassName="lg:w-[50vw]"
|
||||
className="flex-1 lg:w-[calc(50vw+5rem)] lg:flex-none"
|
||||
value={searchInput}
|
||||
valueLabel={searchInputLabel}
|
||||
onChange={handleSearchChange}
|
||||
onSubmit={handleHeaderSearch}
|
||||
onAdvancedToggle={onAdvancedToggle}
|
||||
@@ -661,9 +665,10 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
contentType={contentType}
|
||||
onContentTypeChange={onContentTypeChange}
|
||||
allowedContentTypes={allowedContentTypes}
|
||||
isManualSearch={isManualSearch}
|
||||
disabled={searchDisabled}
|
||||
activeListLabel={activeListLabel}
|
||||
queryTargets={queryTargets}
|
||||
activeQueryTarget={activeQueryTarget}
|
||||
onQueryTargetChange={onQueryTargetChange}
|
||||
activeQueryField={activeQueryField}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -92,7 +92,7 @@ interface ReleaseModalProps {
|
||||
bookLanguages: Language[];
|
||||
currentStatus: StatusData;
|
||||
defaultReleaseSource?: string; // Default tab to show (e.g., 'direct_download')
|
||||
onSearchSeries?: (seriesName: string) => void; // Callback to search for series
|
||||
onSearchSeries?: (seriesName: string, seriesId?: string) => void; // Callback to search for series
|
||||
defaultShowManualQuery?: boolean;
|
||||
isRequestMode?: boolean;
|
||||
}
|
||||
@@ -383,7 +383,7 @@ const ReleaseRow = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Shimmer block with wave animation - same as DownloadsSidebar
|
||||
// Shimmer block with wave animation
|
||||
function ShimmerBlock({ className }: { className: string }) {
|
||||
return (
|
||||
<div
|
||||
@@ -1412,7 +1412,7 @@ export const ReleaseModal = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSearchSeries(book.series_name!);
|
||||
onSearchSeries(book.series_name!, book.series_id);
|
||||
handleClose();
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors"
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
import { KeyboardEvent, InputHTMLAttributes, useRef, forwardRef, useImperativeHandle, useState, useEffect } from 'react';
|
||||
import {
|
||||
InputHTMLAttributes,
|
||||
RefObject,
|
||||
forwardRef,
|
||||
startTransition,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useSearchMode } from '../contexts/SearchModeContext';
|
||||
import { ContentType } from '../types';
|
||||
import {
|
||||
ContentType,
|
||||
MetadataSearchField,
|
||||
QueryTargetOption,
|
||||
SortOption,
|
||||
} from '../types';
|
||||
import { DynamicFieldOption, fetchFieldOptions } from '../services/api';
|
||||
|
||||
interface SearchBarProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
value: string | number | boolean;
|
||||
valueLabel?: string;
|
||||
onChange: (value: string | number | boolean, label?: string) => void;
|
||||
onSubmit: () => void;
|
||||
isLoading?: boolean;
|
||||
onAdvancedToggle?: () => void;
|
||||
@@ -21,35 +39,123 @@ interface SearchBarProps {
|
||||
searchButtonTitle?: string;
|
||||
autoComplete?: string;
|
||||
enterKeyHint?: InputHTMLAttributes<HTMLInputElement>['enterKeyHint'];
|
||||
// Content type selector props
|
||||
contentType?: ContentType;
|
||||
onContentTypeChange?: (type: ContentType) => void;
|
||||
allowedContentTypes?: ContentType[];
|
||||
// Manual search mode
|
||||
isManualSearch?: boolean;
|
||||
queryTargets?: QueryTargetOption[];
|
||||
activeQueryTarget?: string;
|
||||
onQueryTargetChange?: (target: string) => void;
|
||||
activeQueryField?: MetadataSearchField | null;
|
||||
disabled?: boolean;
|
||||
activeListLabel?: string;
|
||||
}
|
||||
|
||||
export interface SearchBarHandle {
|
||||
submit: () => void;
|
||||
}
|
||||
|
||||
const useDismiss = (
|
||||
isOpen: boolean,
|
||||
refs: RefObject<HTMLElement | null>[],
|
||||
onClose: () => void,
|
||||
) => {
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
const refsRef = useRef(refs);
|
||||
refsRef.current = refs;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (refsRef.current.some((r) => r.current?.contains(target))) return;
|
||||
onCloseRef.current();
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onCloseRef.current();
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isOpen]);
|
||||
};
|
||||
|
||||
const autocompleteOptionsCache = new Map<string, DynamicFieldOption[]>();
|
||||
const AUTOCOMPLETE_CACHE_MAX = 100;
|
||||
|
||||
const BookIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AudiobookIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const getDefaultPlaceholder = (
|
||||
contentType: ContentType,
|
||||
activeQueryTarget: QueryTargetOption | undefined,
|
||||
fallback?: string,
|
||||
): string => {
|
||||
if (fallback) return fallback;
|
||||
|
||||
if (!activeQueryTarget || activeQueryTarget.source === 'general') {
|
||||
return contentType === 'ebook' ? 'Search Books' : 'Search Audiobooks';
|
||||
}
|
||||
|
||||
if (activeQueryTarget.source === 'manual') {
|
||||
return 'Search releases directly…';
|
||||
}
|
||||
|
||||
const field = activeQueryTarget.field;
|
||||
if (field?.placeholder) {
|
||||
return field.placeholder;
|
||||
}
|
||||
|
||||
return `Search by ${activeQueryTarget.label.toLowerCase()}…`;
|
||||
};
|
||||
|
||||
const hasActiveValue = (value: string | number | boolean): boolean => {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim().length > 0;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return true;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const getClearedValue = (field?: MetadataSearchField | null): string | boolean => {
|
||||
if (field?.type === 'CheckboxSearchField') {
|
||||
return false;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
value,
|
||||
valueLabel,
|
||||
onChange,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
onAdvancedToggle,
|
||||
placeholder = 'Search by ISBN, title, author...',
|
||||
placeholder,
|
||||
inputAriaLabel = 'Search books',
|
||||
className = '',
|
||||
inputClassName = '',
|
||||
controlsClassName = '',
|
||||
clearButtonLabel = 'Clear search input',
|
||||
clearButtonTitle = 'Clear search',
|
||||
advancedButtonLabel = 'Advanced Search',
|
||||
advancedButtonTitle = 'Advanced Search',
|
||||
advancedButtonLabel = 'Search settings',
|
||||
advancedButtonTitle = 'Search settings',
|
||||
searchButtonLabel = 'Search books',
|
||||
searchButtonTitle = 'Search',
|
||||
autoComplete = 'off',
|
||||
@@ -57,60 +163,186 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
contentType = 'ebook',
|
||||
onContentTypeChange,
|
||||
allowedContentTypes,
|
||||
isManualSearch = false,
|
||||
queryTargets = [],
|
||||
activeQueryTarget = 'general',
|
||||
onQueryTargetChange,
|
||||
activeQueryField,
|
||||
disabled = false,
|
||||
activeListLabel,
|
||||
}, ref) => {
|
||||
const { searchMode, isUniversalMode } = useSearchMode();
|
||||
const { searchMode } = useSearchMode();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const hasSearchQuery = value.trim().length > 0;
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
onSubmitRef.current = onSubmit;
|
||||
const selectorRef = useRef<HTMLDivElement>(null);
|
||||
const hasSearchQuery = hasActiveValue(value);
|
||||
const [isSelectorOpen, setIsSelectorOpen] = useState(false);
|
||||
const [dynamicOptions, setDynamicOptions] = useState<SortOption[]>([]);
|
||||
const [isDynamicLoading, setIsDynamicLoading] = useState(false);
|
||||
const [isSelectOpen, setIsSelectOpen] = useState(false);
|
||||
const [autocompleteOptions, setAutocompleteOptions] = useState<DynamicFieldOption[]>([]);
|
||||
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
|
||||
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false);
|
||||
const [textInputValue, setTextInputValue] = useState('');
|
||||
const selectTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const selectPanelRef = useRef<HTMLDivElement>(null);
|
||||
const autocompletePanelRef = useRef<HTMLDivElement>(null);
|
||||
const deferredTextInputValue = useDeferredValue(textInputValue);
|
||||
|
||||
// Content type dropdown state
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const hasMultipleContentTypes = !allowedContentTypes || allowedContentTypes.length !== 1;
|
||||
const showContentTypeSelector = isUniversalMode && !!onContentTypeChange && hasMultipleContentTypes;
|
||||
const showContentTypeSelector = searchMode !== 'direct' && !!onContentTypeChange && hasMultipleContentTypes;
|
||||
const showQueryTargetSelector = showContentTypeSelector || queryTargets.length > 1;
|
||||
const inputPaddingClass = showQueryTargetSelector
|
||||
? 'pl-3 rounded-r-full'
|
||||
: 'pl-4 rounded-full';
|
||||
const searchInputClass = [
|
||||
'w-full min-w-0 py-3 border-0 outline-none search-input bg-transparent',
|
||||
inputPaddingClass,
|
||||
].join(' ');
|
||||
|
||||
// Dynamic placeholder based on content type, manual search, and list browsing
|
||||
const isContentTypeAware = isUniversalMode && !!onContentTypeChange;
|
||||
const effectivePlaceholder = activeListLabel
|
||||
? `${activeListLabel} selected`
|
||||
: isManualSearch
|
||||
? 'Search releases directly...'
|
||||
: isContentTypeAware
|
||||
? (contentType === 'ebook' ? 'Search Books' : 'Search Audiobooks')
|
||||
: placeholder;
|
||||
const activeTarget = useMemo(
|
||||
() => queryTargets.find((target) => target.key === activeQueryTarget) ?? queryTargets[0],
|
||||
[queryTargets, activeQueryTarget],
|
||||
);
|
||||
const showActiveTargetLabel = Boolean(activeTarget && activeTarget.source !== 'general');
|
||||
|
||||
// Close dropdown on click outside or escape
|
||||
useDismiss(isSelectorOpen, [selectorRef], () => setIsSelectorOpen(false));
|
||||
useDismiss(isSelectOpen, [selectPanelRef, selectTriggerRef], () => setIsSelectOpen(false));
|
||||
useDismiss(isAutocompleteOpen, [autocompletePanelRef, inputRef], () => setIsAutocompleteOpen(false));
|
||||
|
||||
// Close select dropdown when active field changes
|
||||
useEffect(() => {
|
||||
if (!isDropdownOpen) return;
|
||||
setIsSelectOpen(false);
|
||||
setIsAutocompleteOpen(false);
|
||||
setAutocompleteOptions([]);
|
||||
}, [activeQueryField?.key, activeQueryField?.type]);
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
// Load dynamic options when a DynamicSelectSearchField is active
|
||||
const dynamicEndpoint =
|
||||
activeQueryField?.type === 'DynamicSelectSearchField'
|
||||
? activeQueryField.options_endpoint
|
||||
: null;
|
||||
const autocompleteEndpoint =
|
||||
activeQueryField?.type === 'TextSearchField'
|
||||
? activeQueryField.suggestions_endpoint ?? null
|
||||
: null;
|
||||
const autocompleteMinQueryLength =
|
||||
activeQueryField?.type === 'TextSearchField'
|
||||
? activeQueryField.suggestions_min_query_length ?? 2
|
||||
: 2;
|
||||
const autocompleteEmptyMessage =
|
||||
activeQueryField?.key === 'author'
|
||||
? 'No authors found'
|
||||
: activeQueryField?.key === 'title'
|
||||
? 'No titles found'
|
||||
: activeQueryField?.key === 'series'
|
||||
? 'No series found'
|
||||
: 'No suggestions found';
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!dynamicEndpoint) {
|
||||
setDynamicOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape as unknown as EventListener);
|
||||
let cancelled = false;
|
||||
setIsDynamicLoading(true);
|
||||
|
||||
fetchFieldOptions(dynamicEndpoint).then((loaded) => {
|
||||
if (cancelled) return;
|
||||
setDynamicOptions(loaded.map((o) => ({ value: o.value, label: o.label })));
|
||||
setIsDynamicLoading(false);
|
||||
}).catch(() => {
|
||||
if (cancelled) return;
|
||||
setDynamicOptions([]);
|
||||
setIsDynamicLoading(false);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [dynamicEndpoint]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autocompleteEndpoint && activeQueryField?.type !== 'TextSearchField') {
|
||||
setTextInputValue(typeof value === 'string' ? value : String(value ?? ''));
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValue =
|
||||
autocompleteEndpoint && valueLabel && typeof value === 'string' && value.trim() !== ''
|
||||
? valueLabel
|
||||
: typeof value === 'string'
|
||||
? value
|
||||
: String(value ?? '');
|
||||
|
||||
setTextInputValue(nextValue);
|
||||
}, [activeQueryField?.key, activeQueryField?.type, autocompleteEndpoint, value, valueLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autocompleteEndpoint || !isAutocompleteOpen) {
|
||||
setAutocompleteOptions([]);
|
||||
setIsAutocompleteLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedQuery = deferredTextInputValue.trim();
|
||||
if (normalizedQuery.length < autocompleteMinQueryLength) {
|
||||
setAutocompleteOptions([]);
|
||||
setIsAutocompleteLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = `${autocompleteEndpoint}::${normalizedQuery.toLowerCase()}`;
|
||||
if (autocompleteOptionsCache.has(cacheKey)) {
|
||||
startTransition(() => {
|
||||
setAutocompleteOptions(autocompleteOptionsCache.get(cacheKey) ?? []);
|
||||
});
|
||||
setIsAutocompleteLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setIsAutocompleteLoading(true);
|
||||
fetchFieldOptions(autocompleteEndpoint, normalizedQuery)
|
||||
.then((loaded) => {
|
||||
if (cancelled) return;
|
||||
if (autocompleteOptionsCache.size >= AUTOCOMPLETE_CACHE_MAX) {
|
||||
const oldest = autocompleteOptionsCache.keys().next().value;
|
||||
if (oldest !== undefined) autocompleteOptionsCache.delete(oldest);
|
||||
}
|
||||
autocompleteOptionsCache.set(cacheKey, loaded);
|
||||
startTransition(() => {
|
||||
setAutocompleteOptions(loaded);
|
||||
});
|
||||
setIsAutocompleteLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
startTransition(() => {
|
||||
setAutocompleteOptions([]);
|
||||
});
|
||||
setIsAutocompleteLoading(false);
|
||||
});
|
||||
}, 260);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape as unknown as EventListener);
|
||||
cancelled = true;
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [isDropdownOpen]);
|
||||
}, [autocompleteEndpoint, autocompleteMinQueryLength, deferredTextInputValue, isAutocompleteOpen]);
|
||||
|
||||
const handleContentTypeSelect = (type: ContentType) => {
|
||||
onContentTypeChange?.(type);
|
||||
setIsDropdownOpen(false);
|
||||
};
|
||||
// Resolve options for any select-type field
|
||||
const selectOptions: SortOption[] = useMemo(() => {
|
||||
if (activeQueryField?.type === 'SelectSearchField') {
|
||||
return activeQueryField.options;
|
||||
}
|
||||
if (activeQueryField?.type === 'DynamicSelectSearchField') {
|
||||
return dynamicOptions;
|
||||
}
|
||||
return [];
|
||||
}, [activeQueryField, dynamicOptions]);
|
||||
|
||||
const isSelectField = activeQueryField?.type === 'SelectSearchField' || activeQueryField?.type === 'DynamicSelectSearchField';
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
submit: () => {
|
||||
@@ -118,7 +350,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
},
|
||||
}));
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
@@ -130,161 +362,320 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
onChange('');
|
||||
onChange(getClearedValue(activeQueryField));
|
||||
setIsSelectOpen(false);
|
||||
setIsAutocompleteOpen(false);
|
||||
setAutocompleteOptions([]);
|
||||
setTextInputValue('');
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const wrapperClasses = ['relative', className].filter(Boolean).join(' ').trim();
|
||||
const inputClasses = [
|
||||
'w-full pr-40 py-3 border outline-none search-input',
|
||||
showContentTypeSelector ? 'pl-3 rounded-r-full' : 'pl-4 rounded-full',
|
||||
disabled ? 'opacity-60 cursor-not-allowed' : '',
|
||||
inputClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim();
|
||||
const handleContentTypeSelect = (type: ContentType) => {
|
||||
onContentTypeChange?.(type);
|
||||
setIsSelectorOpen(false);
|
||||
};
|
||||
|
||||
const handleQueryTargetSelect = (targetKey: string) => {
|
||||
onQueryTargetChange?.(targetKey);
|
||||
setIsSelectorOpen(false);
|
||||
};
|
||||
|
||||
const effectivePlaceholder = getDefaultPlaceholder(contentType, activeTarget, placeholder);
|
||||
const effectiveInputAriaLabel = activeTarget
|
||||
? `${inputAriaLabel}: ${activeTarget.label}`
|
||||
: inputAriaLabel;
|
||||
|
||||
const selectDropdownOpen = isSelectField && isSelectOpen && selectOptions.length > 0;
|
||||
const autocompleteDropdownOpen =
|
||||
Boolean(autocompleteEndpoint)
|
||||
&& isAutocompleteOpen
|
||||
&& textInputValue.trim().length >= autocompleteMinQueryLength;
|
||||
const wrapperClasses = ['relative flex items-center rounded-full border', className].filter(Boolean).join(' ').trim();
|
||||
const controlsClasses = [
|
||||
'absolute inset-y-0 right-0 flex items-center gap-1 pr-2',
|
||||
'flex items-center gap-1 pr-2 flex-shrink-0',
|
||||
controlsClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim();
|
||||
|
||||
// Content type icons
|
||||
const BookIcon = () => (
|
||||
<svg className="w-5 h-5" 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 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
);
|
||||
const renderActiveInput = () => {
|
||||
if (!activeQueryField || activeQueryField.type === 'TextSearchField') {
|
||||
const inputName = activeQueryField ? `${activeQueryField.key}-search` : 'search-input';
|
||||
return (
|
||||
<input
|
||||
type="search"
|
||||
name={inputName}
|
||||
placeholder={effectivePlaceholder}
|
||||
aria-label={effectiveInputAriaLabel}
|
||||
disabled={disabled}
|
||||
autoComplete={autoComplete}
|
||||
enterKeyHint={enterKeyHint}
|
||||
className={[
|
||||
searchInputClass,
|
||||
'search-input',
|
||||
disabled ? 'opacity-60 cursor-not-allowed' : '',
|
||||
inputClassName,
|
||||
].filter(Boolean).join(' ')}
|
||||
style={{ color: 'var(--text)' }}
|
||||
value={autocompleteEndpoint ? textInputValue : (typeof value === 'string' ? value : String(value ?? ''))}
|
||||
onChange={(e) => {
|
||||
const nextValue = e.target.value;
|
||||
if (autocompleteEndpoint) {
|
||||
setTextInputValue(nextValue);
|
||||
setIsAutocompleteOpen(nextValue.trim().length >= autocompleteMinQueryLength);
|
||||
setIsSelectOpen(false);
|
||||
setIsSelectorOpen(false);
|
||||
onChange(nextValue);
|
||||
return;
|
||||
}
|
||||
onChange(nextValue);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (autocompleteEndpoint && textInputValue.trim().length >= autocompleteMinQueryLength) {
|
||||
setIsAutocompleteOpen(true);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={inputRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const AudiobookIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
|
||||
</svg>
|
||||
);
|
||||
switch (activeQueryField.type) {
|
||||
case 'NumberSearchField':
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
name={`${activeQueryField.key}-search`}
|
||||
placeholder={effectivePlaceholder}
|
||||
aria-label={effectiveInputAriaLabel}
|
||||
disabled={disabled}
|
||||
enterKeyHint={enterKeyHint}
|
||||
min={activeQueryField.min}
|
||||
max={activeQueryField.max}
|
||||
step={activeQueryField.step}
|
||||
className={[
|
||||
searchInputClass,
|
||||
disabled ? 'opacity-60 cursor-not-allowed' : '',
|
||||
inputClassName,
|
||||
].filter(Boolean).join(' ')}
|
||||
style={{ color: 'var(--text)' }}
|
||||
value={
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string'
|
||||
? value
|
||||
: ''
|
||||
}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (!raw) {
|
||||
onChange('');
|
||||
return;
|
||||
}
|
||||
const nextValue = Number.parseInt(raw, 10);
|
||||
if (!Number.isNaN(nextValue)) {
|
||||
onChange(nextValue);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={inputRef}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'SelectSearchField':
|
||||
case 'DynamicSelectSearchField': {
|
||||
const currentValue = typeof value === 'string' ? value : String(value ?? '');
|
||||
const selectedOption = selectOptions.find((o) => o.value === currentValue);
|
||||
return (
|
||||
<button
|
||||
ref={selectTriggerRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!disabled && !isDynamicLoading) {
|
||||
setIsSelectOpen((prev) => !prev);
|
||||
setIsSelectorOpen(false);
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={[
|
||||
'w-full text-left py-3 flex items-center gap-2',
|
||||
showQueryTargetSelector ? 'pl-3' : 'pl-4',
|
||||
'pr-2',
|
||||
disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
|
||||
].filter(Boolean).join(' ')}
|
||||
style={{ color: 'var(--text)' }}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isSelectOpen}
|
||||
>
|
||||
{isDynamicLoading ? (
|
||||
<span className="opacity-50 truncate">Loading…</span>
|
||||
) : selectedOption ? (
|
||||
<span className="truncate">{selectedOption.label}</span>
|
||||
) : (
|
||||
<span className="opacity-50 truncate">{effectivePlaceholder}</span>
|
||||
)}
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 opacity-40 flex-shrink-0 transition-transform duration-200 ${isSelectOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
case 'CheckboxSearchField':
|
||||
return (
|
||||
<label className="flex min-w-0 items-center gap-3 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-[var(--border-muted)] text-emerald-500 focus:ring-emerald-500/50"
|
||||
/>
|
||||
<span className="truncate text-sm" style={{ color: 'var(--text)' }}>
|
||||
{activeQueryField.label}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={wrapperClasses}>
|
||||
<div
|
||||
className="flex items-stretch rounded-full border"
|
||||
style={{
|
||||
background: disabled ? 'var(--bg)' : 'var(--bg-soft)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
>
|
||||
{/* Content Type Selector */}
|
||||
{showContentTypeSelector && (
|
||||
<div className="relative flex-shrink-0 flex" ref={dropdownRef}>
|
||||
<div
|
||||
className={wrapperClasses}
|
||||
style={{
|
||||
background: disabled ? 'var(--bg)' : 'var(--bg-soft)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
>
|
||||
{showQueryTargetSelector && (
|
||||
<div className="relative flex-shrink-0 flex self-stretch" ref={selectorRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
onClick={() => { setIsSelectorOpen((prev) => !prev); setIsSelectOpen(false); }}
|
||||
className="flex items-center gap-1.5 pl-5 pr-2 rounded-l-full transition-colors hover-action"
|
||||
style={{ color: 'var(--text)' }}
|
||||
aria-label={`Searching ${contentType === 'ebook' ? 'books' : 'audiobooks'}. Click to change.`}
|
||||
aria-expanded={isDropdownOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-label={`Searching ${contentType === 'ebook' ? 'books' : 'audiobooks'} by ${activeTarget?.label ?? 'general'}. Click to change.`}
|
||||
aria-expanded={isSelectorOpen}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
{contentType === 'ebook' ? <BookIcon /> : <AudiobookIcon />}
|
||||
{showActiveTargetLabel && (
|
||||
<span className="hidden max-w-24 truncate text-sm font-medium sm:inline">
|
||||
{activeTarget?.label}
|
||||
</span>
|
||||
)}
|
||||
<svg
|
||||
className={`w-3 h-3 opacity-50 transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''}`}
|
||||
className={`w-3 h-3 opacity-50 transition-transform duration-200 ${isSelectorOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 w-px h-6"
|
||||
style={{ background: 'var(--border-muted)' }}
|
||||
/>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isDropdownOpen && (
|
||||
{isSelectorOpen && (
|
||||
<div
|
||||
className="absolute left-0 top-full mt-2 w-40 rounded-lg border shadow-lg z-50 overflow-hidden animate-fade-in-down"
|
||||
className="absolute left-0 top-full z-50 mt-2 w-[min(20rem,calc(100vw-2rem))] overflow-hidden rounded-2xl border shadow-2xl animate-fade-in-down"
|
||||
style={{
|
||||
background: 'var(--bg)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
role="listbox"
|
||||
aria-label="Content type options"
|
||||
role="dialog"
|
||||
aria-label="Search context"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleContentTypeSelect('ebook')}
|
||||
className={`w-full px-3 py-2.5 text-sm font-medium flex items-center gap-2.5 transition-colors ${
|
||||
contentType === 'ebook'
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'hover-surface'
|
||||
}`}
|
||||
style={contentType !== 'ebook' ? { color: 'var(--text)' } : undefined}
|
||||
role="option"
|
||||
aria-selected={contentType === 'ebook'}
|
||||
>
|
||||
<BookIcon />
|
||||
Books
|
||||
{contentType === 'ebook' && (
|
||||
<svg className="w-4 h-4 ml-auto" fill="none" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
<div className="max-h-[min(24rem,calc(100vh-8rem))] overflow-y-auto p-3">
|
||||
{showContentTypeSelector && (
|
||||
<div className="border-b pb-3" style={{ borderColor: 'var(--border-muted)' }}>
|
||||
<div className="px-1 pb-2 text-xs font-medium uppercase tracking-wide opacity-60">
|
||||
Content
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleContentTypeSelect('ebook')}
|
||||
className={`flex items-center gap-2 rounded-xl border px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||
contentType === 'ebook' ? 'bg-emerald-600 text-white' : 'hover-surface'
|
||||
}`}
|
||||
style={contentType !== 'ebook'
|
||||
? { color: 'var(--text)', borderColor: 'var(--border-muted)' }
|
||||
: { borderColor: 'rgb(16 185 129 / 0.7)' }}
|
||||
>
|
||||
<BookIcon />
|
||||
<span>Books</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleContentTypeSelect('audiobook')}
|
||||
className={`flex items-center gap-2 rounded-xl border px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||
contentType === 'audiobook' ? 'bg-emerald-600 text-white' : 'hover-surface'
|
||||
}`}
|
||||
style={contentType !== 'audiobook'
|
||||
? { color: 'var(--text)', borderColor: 'var(--border-muted)' }
|
||||
: { borderColor: 'rgb(16 185 129 / 0.7)' }}
|
||||
>
|
||||
<AudiobookIcon />
|
||||
<span>Audiobooks</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleContentTypeSelect('audiobook')}
|
||||
className={`w-full px-3 py-2.5 text-sm font-medium flex items-center gap-2.5 transition-colors border-t ${
|
||||
contentType === 'audiobook'
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'hover-surface'
|
||||
}`}
|
||||
style={{
|
||||
borderColor: 'var(--border-muted)',
|
||||
...(contentType !== 'audiobook' ? { color: 'var(--text)' } : {}),
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={contentType === 'audiobook'}
|
||||
>
|
||||
<AudiobookIcon />
|
||||
Audiobooks
|
||||
{contentType === 'audiobook' && (
|
||||
<svg className="w-4 h-4 ml-auto" fill="none" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className={showContentTypeSelector ? 'pt-3' : ''}>
|
||||
<div className="px-1 pb-2 text-xs font-medium uppercase tracking-wide opacity-60">
|
||||
Search By
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{queryTargets.map((target) => {
|
||||
const isActive = target.key === activeTarget?.key;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={target.key}
|
||||
onClick={() => handleQueryTargetSelect(target.key)}
|
||||
title={target.description || target.label}
|
||||
aria-label={target.label}
|
||||
className={`min-w-0 rounded-xl border px-3 py-2.5 text-left text-sm font-medium transition-colors ${
|
||||
isActive ? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white` : 'hover-surface'
|
||||
}`}
|
||||
style={isActive
|
||||
? { borderColor: searchMode === 'direct' ? 'rgb(3 105 161 / 0.7)' : 'rgb(16 185 129 / 0.7)' }
|
||||
: { color: 'var(--text)', borderColor: 'var(--border-muted)' }}
|
||||
>
|
||||
<span className="block truncate">{target.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Input */}
|
||||
<input
|
||||
type="search"
|
||||
placeholder={effectivePlaceholder}
|
||||
aria-label={inputAriaLabel}
|
||||
disabled={disabled}
|
||||
autoComplete={autoComplete}
|
||||
enterKeyHint={enterKeyHint}
|
||||
className={inputClasses}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
color: 'var(--text)',
|
||||
border: 'none',
|
||||
}}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={inputRef}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{renderActiveInput()}
|
||||
</div>
|
||||
|
||||
{/* Right-side controls */}
|
||||
<div className={controlsClasses}>
|
||||
{hasSearchQuery && (
|
||||
<button
|
||||
@@ -302,6 +693,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
stroke="currentColor"
|
||||
className="w-5 h-5"
|
||||
style={{ color: 'var(--text)' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
@@ -323,6 +715,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
style={{ color: 'var(--text)' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
@@ -336,7 +729,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
className={`p-2 rounded-full text-white disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center transition-colors search-bar-button ${
|
||||
className={`p-2 my-2 rounded-full text-white disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center transition-colors search-bar-button ${
|
||||
searchMode === 'universal'
|
||||
? 'bg-emerald-600 hover:bg-emerald-700'
|
||||
: 'bg-sky-700 hover:bg-sky-800'
|
||||
@@ -353,6 +746,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
@@ -366,6 +760,94 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectDropdownOpen && (
|
||||
<div
|
||||
ref={selectPanelRef}
|
||||
className="absolute top-full left-0 right-0 z-50 mt-2 rounded-2xl border shadow-xl overflow-hidden animate-fade-in-down"
|
||||
style={{ background: 'var(--bg)', borderColor: 'var(--border-muted)' }}
|
||||
role="listbox"
|
||||
aria-label={effectiveInputAriaLabel}
|
||||
>
|
||||
<div className="max-h-64 overflow-y-auto py-1.5">
|
||||
{selectOptions.map((option) => {
|
||||
const currentValue = typeof value === 'string' ? value : String(value ?? '');
|
||||
const isSelected = option.value === currentValue;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={option.value}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => {
|
||||
onChange(option.value, option.label);
|
||||
setIsSelectOpen(false);
|
||||
setTimeout(() => onSubmitRef.current(), 0);
|
||||
}}
|
||||
className={`w-full px-5 py-2.5 text-left text-sm flex items-center gap-3 transition-colors ${
|
||||
isSelected ? '' : 'hover-surface'
|
||||
}`}
|
||||
style={{ color: 'var(--text)' }}
|
||||
>
|
||||
<span className={`flex-1 truncate ${isSelected ? 'font-medium' : ''}`}>
|
||||
{option.label}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<svg className="w-4 h-4 text-emerald-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="2.5" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{autocompleteDropdownOpen && (
|
||||
<div
|
||||
ref={autocompletePanelRef}
|
||||
className="absolute top-full left-0 right-0 z-50 mt-2 rounded-2xl border shadow-xl overflow-hidden animate-fade-in-down"
|
||||
style={{ background: 'var(--bg)', borderColor: 'var(--border-muted)' }}
|
||||
role="listbox"
|
||||
aria-label={`${effectiveInputAriaLabel} suggestions`}
|
||||
>
|
||||
<div className="max-h-72 overflow-y-auto py-1.5">
|
||||
{isAutocompleteLoading && (
|
||||
<div className="px-5 py-3 text-sm opacity-70" style={{ color: 'var(--text)' }}>
|
||||
Searching…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAutocompleteLoading && autocompleteOptions.length === 0 && (
|
||||
<div className="px-5 py-3 text-sm opacity-70" style={{ color: 'var(--text)' }}>
|
||||
{autocompleteEmptyMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAutocompleteLoading && autocompleteOptions.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
key={option.value}
|
||||
role="option"
|
||||
onClick={() => {
|
||||
setTextInputValue(option.label);
|
||||
onChange(option.value, option.label);
|
||||
setIsAutocompleteOpen(false);
|
||||
setTimeout(() => onSubmitRef.current(), 0);
|
||||
}}
|
||||
className="w-full px-5 py-3 text-left text-sm transition-colors hover-surface"
|
||||
style={{ color: 'var(--text)' }}
|
||||
>
|
||||
<div className="truncate font-medium">{option.label}</div>
|
||||
{option.description && (
|
||||
<div className="truncate text-xs opacity-70 mt-0.5">{option.description}</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,35 +1,42 @@
|
||||
import { AdvancedFilterState, Language, MetadataSearchField, ContentType } from '../types';
|
||||
import { buildSearchQuery } from '../utils/buildSearchQuery';
|
||||
import { useSearchMode } from '../contexts/SearchModeContext';
|
||||
import {
|
||||
AdvancedFilterState,
|
||||
Language,
|
||||
MetadataSearchField,
|
||||
ContentType,
|
||||
QueryTargetOption,
|
||||
SearchMode,
|
||||
MetadataProviderSummary,
|
||||
} from '../types';
|
||||
import { AdvancedFilters } from './AdvancedFilters';
|
||||
import { SearchBar } from './SearchBar';
|
||||
|
||||
interface SearchSectionProps {
|
||||
onSearch: (query: string) => void;
|
||||
onSearch: () => void;
|
||||
isLoading: boolean;
|
||||
isInitialState: boolean;
|
||||
bookLanguages: Language[];
|
||||
defaultLanguage: string[];
|
||||
supportedFormats: string[];
|
||||
logoUrl: string;
|
||||
searchInput: string;
|
||||
onSearchInputChange: (value: string) => void;
|
||||
queryValue: string | number | boolean;
|
||||
queryValueLabel?: string;
|
||||
onQueryValueChange: (value: string | number | boolean, label?: string) => void;
|
||||
queryTargets: QueryTargetOption[];
|
||||
activeQueryTarget: string;
|
||||
onQueryTargetChange: (key: string) => void;
|
||||
showAdvanced: boolean;
|
||||
onAdvancedToggle: () => void;
|
||||
onAdvancedToggle?: () => void;
|
||||
advancedFilters: AdvancedFilterState;
|
||||
onAdvancedFiltersChange: (updates: Partial<AdvancedFilterState>) => void;
|
||||
// Universal mode props
|
||||
metadataSearchFields?: MetadataSearchField[];
|
||||
searchFieldValues?: Record<string, string | number | boolean>;
|
||||
onSearchFieldChange?: (key: string, value: string | number | boolean, label?: string) => void;
|
||||
contentType?: ContentType;
|
||||
onContentTypeChange?: (type: ContentType) => void;
|
||||
allowedContentTypes?: ContentType[];
|
||||
// Manual search mode (universal only)
|
||||
isManualSearch?: boolean;
|
||||
onManualSearchToggle?: () => void;
|
||||
searchDisabled?: boolean;
|
||||
activeListLabel?: string;
|
||||
activeQueryField?: MetadataSearchField | null;
|
||||
searchMode: SearchMode;
|
||||
onSearchModeChange: (mode: SearchMode) => void;
|
||||
metadataProviders?: MetadataProviderSummary[];
|
||||
activeMetadataProvider?: string | null;
|
||||
onMetadataProviderChange?: (provider: string) => void;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export const SearchSection = ({
|
||||
@@ -38,39 +45,28 @@ export const SearchSection = ({
|
||||
isInitialState,
|
||||
bookLanguages,
|
||||
defaultLanguage,
|
||||
supportedFormats,
|
||||
logoUrl,
|
||||
searchInput,
|
||||
onSearchInputChange,
|
||||
queryValue,
|
||||
queryValueLabel,
|
||||
onQueryValueChange,
|
||||
queryTargets,
|
||||
activeQueryTarget,
|
||||
onQueryTargetChange,
|
||||
showAdvanced,
|
||||
onAdvancedToggle,
|
||||
advancedFilters,
|
||||
onAdvancedFiltersChange,
|
||||
metadataSearchFields,
|
||||
searchFieldValues,
|
||||
onSearchFieldChange,
|
||||
contentType = 'ebook',
|
||||
onContentTypeChange,
|
||||
allowedContentTypes,
|
||||
isManualSearch = false,
|
||||
onManualSearchToggle,
|
||||
searchDisabled = false,
|
||||
activeListLabel,
|
||||
activeQueryField,
|
||||
searchMode,
|
||||
onSearchModeChange,
|
||||
metadataProviders,
|
||||
activeMetadataProvider,
|
||||
onMetadataProviderChange,
|
||||
isAdmin = false,
|
||||
}: SearchSectionProps) => {
|
||||
const { searchMode } = useSearchMode();
|
||||
|
||||
const handleSearch = () => {
|
||||
const query = buildSearchQuery({
|
||||
searchInput,
|
||||
showAdvanced,
|
||||
advancedFilters,
|
||||
bookLanguages,
|
||||
defaultLanguage,
|
||||
searchMode,
|
||||
});
|
||||
onSearch(query);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id="search-section"
|
||||
@@ -90,33 +86,35 @@ export const SearchSection = ({
|
||||
isInitialState ? '' : 'hidden'
|
||||
}`}>
|
||||
<SearchBar
|
||||
value={searchInput}
|
||||
onChange={onSearchInputChange}
|
||||
onSubmit={handleSearch}
|
||||
value={queryValue}
|
||||
valueLabel={queryValueLabel}
|
||||
onChange={onQueryValueChange}
|
||||
onSubmit={onSearch}
|
||||
isLoading={isLoading}
|
||||
onAdvancedToggle={onAdvancedToggle}
|
||||
contentType={contentType}
|
||||
onContentTypeChange={onContentTypeChange}
|
||||
allowedContentTypes={allowedContentTypes}
|
||||
isManualSearch={isManualSearch}
|
||||
disabled={searchDisabled}
|
||||
activeListLabel={activeListLabel}
|
||||
queryTargets={queryTargets}
|
||||
activeQueryTarget={activeQueryTarget}
|
||||
onQueryTargetChange={onQueryTargetChange}
|
||||
activeQueryField={activeQueryField}
|
||||
/>
|
||||
<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"
|
||||
renderWrapper={form => form}
|
||||
metadataSearchFields={metadataSearchFields}
|
||||
searchFieldValues={searchFieldValues}
|
||||
onSearchFieldChange={onSearchFieldChange}
|
||||
onSubmit={handleSearch}
|
||||
isManualSearch={isManualSearch}
|
||||
onManualSearchToggle={onManualSearchToggle}
|
||||
searchMode={searchMode}
|
||||
onSearchModeChange={onSearchModeChange}
|
||||
metadataProviders={metadataProviders}
|
||||
activeMetadataProvider={activeMetadataProvider}
|
||||
onMetadataProviderChange={onMetadataProviderChange}
|
||||
contentType={contentType}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -518,12 +518,14 @@ export const ActivityCard = ({
|
||||
|
||||
const titleNode =
|
||||
canShowDownloadLink && item.downloadBookId ? (
|
||||
<a
|
||||
href={withBasePath(`/api/localdownload?id=${encodeURIComponent(item.downloadBookId)}`)}
|
||||
className="text-sky-600 hover:underline"
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
<Tooltip content="Download file" position="top" delay={0}>
|
||||
<a
|
||||
href={withBasePath(`/api/localdownload?id=${encodeURIComponent(item.downloadBookId)}`)}
|
||||
className="text-sky-600 hover:underline"
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
</Tooltip>
|
||||
) : (
|
||||
item.title
|
||||
);
|
||||
@@ -652,6 +654,8 @@ export const ActivityCard = ({
|
||||
? getProgressConfig(badge.visualStatus, badge.progress)
|
||||
: null;
|
||||
|
||||
const isError = badge.visualStatus === 'error';
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={badgeId}
|
||||
@@ -659,6 +663,7 @@ export const ActivityCard = ({
|
||||
delay={0}
|
||||
position="bottom"
|
||||
unstyled
|
||||
interactive={isError}
|
||||
className={STATUS_TOOLTIP_CLASSES[badge.visualStatus]}
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -3,7 +3,6 @@ export { SearchSection } from './SearchSection';
|
||||
export { SearchBar } from './SearchBar';
|
||||
export { ResultsSection } from './ResultsSection';
|
||||
export { DetailsModal } from './DetailsModal';
|
||||
export { DownloadsSidebar } from './DownloadsSidebar';
|
||||
export { ActivitySidebar } from './activity';
|
||||
export { ToastContainer } from './ToastContainer';
|
||||
export { Footer } from './Footer';
|
||||
|
||||
@@ -2,11 +2,17 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { DropdownList, DropdownListOption } from '../DropdownList';
|
||||
import { DynamicFieldOption, fetchFieldOptions } from '../../services/api';
|
||||
|
||||
const optionsCache = new Map<string, DynamicFieldOption[]>();
|
||||
const OPTIONS_CACHE_MAX = 50;
|
||||
|
||||
interface DynamicDropdownProps {
|
||||
endpoint: string;
|
||||
value: string;
|
||||
onChange: (value: string, label?: string) => void;
|
||||
placeholder?: string;
|
||||
widthClassName?: string;
|
||||
buttonClassName?: string;
|
||||
triggerChrome?: 'default' | 'minimal';
|
||||
}
|
||||
|
||||
const buildOptions = (
|
||||
@@ -24,15 +30,25 @@ export const DynamicDropdown = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Select...',
|
||||
widthClassName,
|
||||
buttonClassName,
|
||||
triggerChrome = 'default',
|
||||
}: DynamicDropdownProps) => {
|
||||
const [options, setOptions] = useState<DynamicFieldOption[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const cachedOptions = optionsCache.get(endpoint) ?? [];
|
||||
const [options, setOptions] = useState<DynamicFieldOption[]>(cachedOptions);
|
||||
const [isLoading, setIsLoading] = useState(cachedOptions.length === 0);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const load = async () => {
|
||||
if (optionsCache.has(endpoint)) {
|
||||
setOptions(optionsCache.get(endpoint) ?? []);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setLoadError(null);
|
||||
|
||||
@@ -41,6 +57,11 @@ export const DynamicDropdown = ({
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
if (optionsCache.size >= OPTIONS_CACHE_MAX) {
|
||||
const oldest = optionsCache.keys().next().value;
|
||||
if (oldest !== undefined) optionsCache.delete(oldest);
|
||||
}
|
||||
optionsCache.set(endpoint, loaded);
|
||||
setOptions(loaded);
|
||||
} catch (error) {
|
||||
if (!isMounted) {
|
||||
@@ -86,6 +107,9 @@ export const DynamicDropdown = ({
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
widthClassName={widthClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
triggerChrome={triggerChrome}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ interface TooltipProps {
|
||||
unstyled?: boolean;
|
||||
triggerClassName?: string;
|
||||
alwaysWrap?: boolean;
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export function Tooltip({
|
||||
@@ -21,6 +22,7 @@ export function Tooltip({
|
||||
unstyled = false,
|
||||
triggerClassName = 'inline-flex max-w-full',
|
||||
alwaysWrap = false,
|
||||
interactive = false,
|
||||
}: TooltipProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
|
||||
@@ -67,11 +69,37 @@ export function Tooltip({
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const isOverTooltipRef = useRef(false);
|
||||
|
||||
const hideTooltip = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
if (interactive) {
|
||||
// Small delay so the user can move the mouse from trigger to tooltip
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (!isOverTooltipRef.current) {
|
||||
setIsVisible(false);
|
||||
setCoords(null);
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
setIsVisible(false);
|
||||
setCoords(null);
|
||||
};
|
||||
|
||||
const handleTooltipMouseEnter = () => {
|
||||
isOverTooltipRef.current = true;
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTooltipMouseLeave = () => {
|
||||
isOverTooltipRef.current = false;
|
||||
setIsVisible(false);
|
||||
setCoords(null);
|
||||
};
|
||||
@@ -164,7 +192,9 @@ export function Tooltip({
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
role="tooltip"
|
||||
className={`fixed z-[9999] pointer-events-none ${tooltipSizeClass} ${transformClass} ${className}`}
|
||||
onMouseEnter={interactive ? handleTooltipMouseEnter : undefined}
|
||||
onMouseLeave={interactive ? handleTooltipMouseLeave : undefined}
|
||||
className={`fixed z-[9999] ${interactive ? 'select-text cursor-auto' : 'pointer-events-none'} ${tooltipSizeClass} ${transformClass} ${className}`}
|
||||
style={{
|
||||
top: coords.top,
|
||||
left: coords.left,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Book, AppConfig, AdvancedFilterState, ContentType } from '../types';
|
||||
import { Book, AppConfig, AdvancedFilterState, ContentType, SearchMode } from '../types';
|
||||
import { searchBooks, searchMetadata, AuthenticationError } from '../services/api';
|
||||
import { LANGUAGE_OPTION_DEFAULT } from '../utils/languageFilters';
|
||||
import { DEFAULT_SUPPORTED_FORMATS } from '../data/languages';
|
||||
|
||||
const DEFAULT_FORMAT_SELECTION = DEFAULT_SUPPORTED_FORMATS.filter(format => format !== 'pdf');
|
||||
const DEFAULT_FORMAT_SELECTION = DEFAULT_SUPPORTED_FORMATS;
|
||||
|
||||
interface UseSearchOptions {
|
||||
showToast: (message: string, type: 'info' | 'success' | 'error') => void;
|
||||
@@ -30,14 +30,15 @@ interface UseSearchReturn {
|
||||
advancedFilters: AdvancedFilterState;
|
||||
setAdvancedFilters: React.Dispatch<React.SetStateAction<AdvancedFilterState>>;
|
||||
updateAdvancedFilters: (updates: Partial<AdvancedFilterState>) => void;
|
||||
handleSearch: (
|
||||
query: string,
|
||||
config: AppConfig | null,
|
||||
fieldValues?: Record<string, string | number | boolean>,
|
||||
contentTypeOverride?: ContentType
|
||||
) => Promise<void>;
|
||||
handleSearch: (params: {
|
||||
query: string;
|
||||
config: AppConfig | null;
|
||||
fieldValues?: Record<string, string | number | boolean>;
|
||||
contentTypeOverride?: ContentType;
|
||||
searchMode?: SearchMode;
|
||||
providerOverride?: string;
|
||||
}) => Promise<void>;
|
||||
handleResetSearch: (config: AppConfig | null) => void;
|
||||
handleSortChange: (value: string, config: AppConfig | null) => void;
|
||||
resetSortFilter: () => void;
|
||||
// Universal mode search field values
|
||||
searchFieldValues: SearchFieldValues;
|
||||
@@ -46,7 +47,7 @@ interface UseSearchReturn {
|
||||
// Pagination (universal mode only)
|
||||
hasMore: boolean;
|
||||
isLoadingMore: boolean;
|
||||
loadMore: (config: AppConfig | null) => Promise<void>;
|
||||
loadMore: (config: AppConfig | null, searchMode?: SearchMode) => Promise<void>;
|
||||
totalFound: number;
|
||||
}
|
||||
|
||||
@@ -84,6 +85,8 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
query: string;
|
||||
sort: string;
|
||||
fieldValues: SearchFieldValues;
|
||||
providerOverride?: string;
|
||||
contentType: ContentType;
|
||||
} | null>(null);
|
||||
|
||||
const updateAdvancedFilters = useCallback((updates: Partial<AdvancedFilterState>) => {
|
||||
@@ -92,11 +95,20 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
|
||||
const updateSearchFieldValue = useCallback((key: string, value: string | number | boolean, label?: string) => {
|
||||
setSearchFieldValues(prev => ({ ...prev, [key]: value }));
|
||||
if (label !== undefined) {
|
||||
setSearchFieldLabels(prev => ({ ...prev, [key]: label }));
|
||||
} else if (!value) {
|
||||
setSearchFieldLabels(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||
}
|
||||
setSearchFieldLabels(prev => {
|
||||
const next = { ...prev };
|
||||
if (label !== undefined) {
|
||||
if (label) {
|
||||
next[key] = label;
|
||||
} else {
|
||||
delete next[key];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resetSortFilter = useCallback(() => {
|
||||
@@ -118,14 +130,23 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
showToast(message, 'error');
|
||||
}, [setIsAuthenticated, authRequired, navigate, showToast]);
|
||||
|
||||
const handleSearch = useCallback(async (
|
||||
query: string,
|
||||
config: AppConfig | null,
|
||||
fieldValues?: Record<string, string | number | boolean>,
|
||||
contentTypeOverride?: ContentType
|
||||
) => {
|
||||
const handleSearch = useCallback(async ({
|
||||
query,
|
||||
config,
|
||||
fieldValues,
|
||||
contentTypeOverride,
|
||||
searchMode: searchModeOverride,
|
||||
providerOverride,
|
||||
}: {
|
||||
query: string;
|
||||
config: AppConfig | null;
|
||||
fieldValues?: Record<string, string | number | boolean>;
|
||||
contentTypeOverride?: ContentType;
|
||||
searchMode?: SearchMode;
|
||||
providerOverride?: string;
|
||||
}) => {
|
||||
const effectiveContentType = contentTypeOverride ?? contentType;
|
||||
const searchMode = config?.search_mode || 'direct';
|
||||
const searchMode = (searchModeOverride ?? config?.search_mode) || 'direct';
|
||||
|
||||
// In universal mode, check if we have either a query or field values
|
||||
if (searchMode === 'universal') {
|
||||
@@ -134,11 +155,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
// Use explicitly passed fieldValues if provided, otherwise fall back to state
|
||||
const effectiveFieldValues = fieldValues ?? searchFieldValues;
|
||||
const hasFieldValues = Object.values(effectiveFieldValues).some(v => v !== '' && v !== false);
|
||||
|
||||
// Auto-set sort to series_order when searching by series field
|
||||
const seriesValue = effectiveFieldValues.series;
|
||||
const hasSeriesSearch = typeof seriesValue === 'string' && seriesValue.trim() !== '';
|
||||
const sort = hasSeriesSearch ? 'series_order' : (params.get('sort') || 'relevance');
|
||||
const sort = params.get('sort') || 'relevance';
|
||||
|
||||
if (!searchQuery && !hasFieldValues) {
|
||||
setBooks([]);
|
||||
@@ -150,11 +167,6 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update UI sort dropdown to reflect series_order when searching by series
|
||||
if (hasSeriesSearch) {
|
||||
setAdvancedFilters(prev => ({ ...prev, sort: 'series_order' }));
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
setLastSearchQuery(query);
|
||||
// Reset pagination for new search
|
||||
@@ -163,13 +175,27 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
setTotalFound(0);
|
||||
|
||||
try {
|
||||
const result = await searchMetadata(searchQuery, 40, sort, effectiveFieldValues, 1, effectiveContentType);
|
||||
const result = await searchMetadata(
|
||||
searchQuery,
|
||||
40,
|
||||
sort,
|
||||
effectiveFieldValues,
|
||||
1,
|
||||
effectiveContentType,
|
||||
providerOverride,
|
||||
);
|
||||
if (result.books.length > 0) {
|
||||
setBooks(result.books);
|
||||
setHasMore(result.hasMore);
|
||||
setTotalFound(result.totalFound);
|
||||
// Store params for loadMore
|
||||
lastSearchParamsRef.current = { query: searchQuery, sort, fieldValues: effectiveFieldValues };
|
||||
lastSearchParamsRef.current = {
|
||||
query: searchQuery,
|
||||
sort,
|
||||
fieldValues: effectiveFieldValues,
|
||||
providerOverride,
|
||||
contentType: effectiveContentType,
|
||||
};
|
||||
} else {
|
||||
setBooks([]);
|
||||
setHasMore(false);
|
||||
@@ -237,6 +263,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
|
||||
// Reset universal mode search field values
|
||||
setSearchFieldValues({});
|
||||
setSearchFieldLabels({});
|
||||
|
||||
// Reset pagination
|
||||
setCurrentPage(1);
|
||||
@@ -246,19 +273,27 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
}, [onSearchReset]);
|
||||
|
||||
// Load more results (universal mode pagination)
|
||||
const loadMore = useCallback(async (config: AppConfig | null) => {
|
||||
const searchMode = config?.search_mode || 'direct';
|
||||
const loadMore = useCallback(async (config: AppConfig | null, searchModeOverride?: SearchMode) => {
|
||||
const searchMode = (searchModeOverride ?? config?.search_mode) || 'direct';
|
||||
if (searchMode !== 'universal') return;
|
||||
if (!lastSearchParamsRef.current) return;
|
||||
if (isLoadingMore || !hasMore) return;
|
||||
|
||||
const { query, sort, fieldValues } = lastSearchParamsRef.current;
|
||||
const { query, sort, fieldValues, providerOverride, contentType: searchContentType } = lastSearchParamsRef.current;
|
||||
const nextPage = currentPage + 1;
|
||||
|
||||
setIsLoadingMore(true);
|
||||
|
||||
try {
|
||||
const result = await searchMetadata(query, 40, sort, fieldValues, nextPage, contentType);
|
||||
const result = await searchMetadata(
|
||||
query,
|
||||
40,
|
||||
sort,
|
||||
fieldValues,
|
||||
nextPage,
|
||||
searchContentType,
|
||||
providerOverride,
|
||||
);
|
||||
if (result.books.length > 0) {
|
||||
setBooks(prev => [...prev, ...result.books]);
|
||||
setHasMore(result.hasMore);
|
||||
@@ -271,23 +306,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [currentPage, hasMore, isLoadingMore, handleSearchError, contentType]);
|
||||
|
||||
const handleSortChange = useCallback((value: string, config: AppConfig | null) => {
|
||||
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, config);
|
||||
}, [lastSearchQuery, updateAdvancedFilters, handleSearch]);
|
||||
}, [currentPage, hasMore, isLoadingMore, handleSearchError]);
|
||||
|
||||
return {
|
||||
books,
|
||||
@@ -303,7 +322,6 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
updateAdvancedFilters,
|
||||
handleSearch,
|
||||
handleResetSearch,
|
||||
handleSortChange,
|
||||
resetSortFilter,
|
||||
// Universal mode search field values
|
||||
searchFieldValues,
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
RequestPolicyResponse,
|
||||
CreateRequestPayload,
|
||||
RequestRecord,
|
||||
MetadataProvidersResponse,
|
||||
MetadataSearchConfig,
|
||||
} from '../types';
|
||||
import { SettingsResponse, ActionResult, UpdateResult, SettingsTab } from '../types/settings';
|
||||
import {
|
||||
@@ -34,6 +36,8 @@ const API_BASE = getApiBase();
|
||||
// API endpoints
|
||||
const API = {
|
||||
metadataSearch: `${API_BASE}/metadata/search`,
|
||||
metadataConfig: `${API_BASE}/metadata/config`,
|
||||
metadataProviders: `${API_BASE}/metadata/providers`,
|
||||
status: `${API_BASE}/status`,
|
||||
cancelDownload: `${API_BASE}/download`,
|
||||
retryDownload: `${API_BASE}/download`,
|
||||
@@ -244,7 +248,8 @@ export const searchMetadata = async (
|
||||
sort: string = 'relevance',
|
||||
fields: Record<string, string | number | boolean> = {},
|
||||
page: number = 1,
|
||||
contentType: string = 'ebook'
|
||||
contentType: string = 'ebook',
|
||||
provider?: string
|
||||
): Promise<MetadataSearchResult> => {
|
||||
const hasFields = Object.values(fields).some(v => v !== '' && v !== false);
|
||||
|
||||
@@ -260,6 +265,9 @@ export const searchMetadata = async (
|
||||
params.set('sort', sort);
|
||||
params.set('page', String(page));
|
||||
params.set('content_type', contentType);
|
||||
if (provider) {
|
||||
params.set('provider', provider);
|
||||
}
|
||||
|
||||
// Add custom search field values
|
||||
Object.entries(fields).forEach(([key, value]) => {
|
||||
@@ -278,13 +286,44 @@ export const searchMetadata = async (
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchFieldOptions = async (endpoint: string): Promise<DynamicFieldOption[]> => {
|
||||
export const getMetadataProviders = async (): Promise<MetadataProvidersResponse> => {
|
||||
return fetchJSON<MetadataProvidersResponse>(API.metadataProviders);
|
||||
};
|
||||
|
||||
export const getMetadataSearchConfig = async (
|
||||
contentType: string = 'ebook',
|
||||
provider?: string,
|
||||
): Promise<MetadataSearchConfig> => {
|
||||
const params = new URLSearchParams({
|
||||
content_type: contentType,
|
||||
});
|
||||
|
||||
if (provider) {
|
||||
params.set('provider', provider);
|
||||
}
|
||||
|
||||
return fetchJSON<MetadataSearchConfig>(`${API.metadataConfig}?${params.toString()}`);
|
||||
};
|
||||
|
||||
export const fetchFieldOptions = async (
|
||||
endpoint: string,
|
||||
query?: string,
|
||||
): Promise<DynamicFieldOption[]> => {
|
||||
const normalizedEndpoint =
|
||||
endpoint.startsWith('http://') || endpoint.startsWith('https://')
|
||||
? endpoint
|
||||
: withBasePath(endpoint);
|
||||
|
||||
const response = await fetchJSON<{ options?: unknown }>(normalizedEndpoint);
|
||||
const url = new URL(normalizedEndpoint, window.location.origin);
|
||||
if (query && query.trim().length > 0) {
|
||||
url.searchParams.set('query', query.trim());
|
||||
}
|
||||
|
||||
const requestUrl = url.origin === window.location.origin
|
||||
? `${url.pathname}${url.search}`
|
||||
: url.toString();
|
||||
|
||||
const response = await fetchJSON<{ options?: unknown }>(requestUrl);
|
||||
if (!Array.isArray(response.options)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -433,6 +433,11 @@ button:disabled {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--text);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.search-input::-webkit-search-cancel-button,
|
||||
.search-input::-webkit-search-decoration,
|
||||
.search-input::-webkit-search-results-button,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { buildQueryTargets, getDefaultQueryTargetKey } from '../utils/queryTargets.js';
|
||||
|
||||
describe('queryTargets', () => {
|
||||
it('builds direct-mode query targets', () => {
|
||||
const targets = buildQueryTargets({ searchMode: 'direct' });
|
||||
|
||||
assert.deepEqual(
|
||||
targets.map((target) => target.key),
|
||||
['general', 'isbn', 'author', 'title'],
|
||||
);
|
||||
});
|
||||
|
||||
it('builds universal query targets from provider fields', () => {
|
||||
const targets = buildQueryTargets({
|
||||
searchMode: 'universal',
|
||||
metadataSearchFields: [
|
||||
{
|
||||
key: 'author',
|
||||
label: 'Author',
|
||||
type: 'TextSearchField',
|
||||
description: 'Search by author name',
|
||||
},
|
||||
{
|
||||
key: 'hardcover_list',
|
||||
label: 'List',
|
||||
type: 'DynamicSelectSearchField',
|
||||
options_endpoint: '/api/metadata/field-options?provider=hardcover&field=hardcover_list',
|
||||
description: 'Browse books from a list',
|
||||
},
|
||||
],
|
||||
manualSearchAllowed: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
targets.map((target) => target.key),
|
||||
['general', 'author', 'hardcover_list', 'manual'],
|
||||
);
|
||||
assert.equal(targets[1]?.source, 'provider-field');
|
||||
assert.equal(targets[3]?.source, 'manual');
|
||||
});
|
||||
|
||||
it('falls back to general when choosing a default target', () => {
|
||||
assert.equal(getDefaultQueryTargetKey([]), 'general');
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,7 @@ export interface Book {
|
||||
source_url?: string; // Link to book on provider's site
|
||||
display_fields?: DisplayField[]; // Provider-specific display data
|
||||
// Series info (if book is part of a series)
|
||||
series_id?: string; // Provider-specific series ID
|
||||
series_name?: string; // Name of the series
|
||||
series_position?: number; // This book's position (e.g., 3, 1.5 for novellas)
|
||||
series_count?: number; // Total books in the series
|
||||
@@ -133,6 +134,8 @@ interface SearchFieldBase {
|
||||
|
||||
export interface TextSearchField extends SearchFieldBase {
|
||||
type: 'TextSearchField';
|
||||
suggestions_endpoint?: string;
|
||||
suggestions_min_query_length?: number;
|
||||
}
|
||||
|
||||
export interface NumberSearchField extends SearchFieldBase {
|
||||
@@ -164,6 +167,20 @@ export type MetadataSearchField =
|
||||
| CheckboxSearchField
|
||||
| DynamicSelectSearchField;
|
||||
|
||||
export type QueryTargetSource =
|
||||
| 'general'
|
||||
| 'manual'
|
||||
| 'direct-field'
|
||||
| 'provider-field';
|
||||
|
||||
export interface QueryTargetOption {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
source: QueryTargetSource;
|
||||
field?: MetadataSearchField;
|
||||
}
|
||||
|
||||
// App configuration
|
||||
// Content type for search (ebook vs audiobook)
|
||||
export type ContentType = 'ebook' | 'audiobook';
|
||||
@@ -251,6 +268,37 @@ export interface AppConfig {
|
||||
metadata_default_sort: string; // Default sort for universal mode (from metadata provider)
|
||||
}
|
||||
|
||||
export interface MetadataProviderSummary {
|
||||
name: string;
|
||||
display_name: string;
|
||||
requires_auth: boolean;
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface MetadataProvidersResponse {
|
||||
providers: MetadataProviderSummary[];
|
||||
configured_provider: string | null;
|
||||
configured_provider_audiobook: string | null;
|
||||
}
|
||||
|
||||
export interface MetadataCapability {
|
||||
key: string;
|
||||
field_key?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface MetadataSearchConfig {
|
||||
provider: string | null;
|
||||
display_name: string | null;
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
search_fields: MetadataSearchField[];
|
||||
capabilities: MetadataCapability[];
|
||||
sort_options: SortOption[];
|
||||
default_sort: string;
|
||||
}
|
||||
|
||||
// Authentication types
|
||||
export interface LoginCredentials {
|
||||
username: string;
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface MetadataBookData {
|
||||
icon?: string;
|
||||
}>;
|
||||
// Series info
|
||||
series_id?: string;
|
||||
series_name?: string;
|
||||
series_position?: number;
|
||||
series_count?: number;
|
||||
@@ -88,6 +89,7 @@ export function transformMetadataToBook(data: MetadataBookData): Book {
|
||||
genres: data.genres,
|
||||
source_url: data.source_url,
|
||||
display_fields: data.display_fields,
|
||||
series_id: data.series_id,
|
||||
series_name: data.series_name,
|
||||
series_position: data.series_position,
|
||||
series_count: data.series_count,
|
||||
|
||||
@@ -12,7 +12,7 @@ interface BuildSearchQueryOptions {
|
||||
|
||||
export const buildSearchQuery = ({
|
||||
searchInput,
|
||||
showAdvanced,
|
||||
showAdvanced: _showAdvanced,
|
||||
advancedFilters,
|
||||
bookLanguages,
|
||||
defaultLanguage,
|
||||
@@ -34,20 +34,18 @@ export const buildSearchQuery = ({
|
||||
return queryParts.join('&');
|
||||
}
|
||||
|
||||
// Direct mode: include all filters
|
||||
if (showAdvanced) {
|
||||
const { isbn, author, title, content, formats, lang } = advancedFilters;
|
||||
// Direct mode: include the active direct search settings regardless of panel visibility.
|
||||
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)}`);
|
||||
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)}`));
|
||||
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 (content) queryParts.push(`content=${encodeURIComponent(content)}`);
|
||||
formats.forEach(format => queryParts.push(`format=${encodeURIComponent(format)}`));
|
||||
|
||||
if (advancedFilters.sort) {
|
||||
queryParts.push(`sort=${encodeURIComponent(advancedFilters.sort)}`);
|
||||
@@ -55,4 +53,3 @@ export const buildSearchQuery = ({
|
||||
|
||||
return queryParts.join('&');
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Color styles with transparent backgrounds and contrasting text (matches DownloadsSidebar style)
|
||||
// Color styles with transparent backgrounds and contrasting text
|
||||
interface ColorStyle {
|
||||
bg: string;
|
||||
text: string;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { MetadataSearchField, QueryTargetOption, SearchMode, TextSearchField } from '../types';
|
||||
|
||||
const makeDirectField = (
|
||||
key: 'isbn' | 'author' | 'title',
|
||||
label: string,
|
||||
description: string,
|
||||
): TextSearchField => ({
|
||||
key,
|
||||
label,
|
||||
type: 'TextSearchField',
|
||||
placeholder: `${label}…`,
|
||||
description,
|
||||
});
|
||||
|
||||
const GENERAL_QUERY_TARGET: QueryTargetOption = {
|
||||
key: 'general',
|
||||
label: 'General',
|
||||
description: 'Search across all supported fields.',
|
||||
source: 'general',
|
||||
};
|
||||
|
||||
const DIRECT_QUERY_TARGETS: QueryTargetOption[] = [
|
||||
GENERAL_QUERY_TARGET,
|
||||
{
|
||||
key: 'isbn',
|
||||
label: 'ISBN',
|
||||
description: 'Search for an exact ISBN.',
|
||||
source: 'direct-field',
|
||||
field: makeDirectField('isbn', 'ISBN', 'Search by ISBN'),
|
||||
},
|
||||
{
|
||||
key: 'author',
|
||||
label: 'Author',
|
||||
description: 'Search by author name.',
|
||||
source: 'direct-field',
|
||||
field: makeDirectField('author', 'Author', 'Search by author name'),
|
||||
},
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
description: 'Search by title.',
|
||||
source: 'direct-field',
|
||||
field: makeDirectField('title', 'Title', 'Search by title'),
|
||||
},
|
||||
];
|
||||
|
||||
const mapMetadataFieldToTarget = (field: MetadataSearchField): QueryTargetOption => ({
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
description: field.description,
|
||||
source: 'provider-field',
|
||||
field,
|
||||
});
|
||||
|
||||
export const buildQueryTargets = ({
|
||||
searchMode,
|
||||
metadataSearchFields = [],
|
||||
manualSearchAllowed = false,
|
||||
}: {
|
||||
searchMode: SearchMode;
|
||||
metadataSearchFields?: MetadataSearchField[];
|
||||
manualSearchAllowed?: boolean;
|
||||
}): QueryTargetOption[] => {
|
||||
if (searchMode === 'direct') {
|
||||
return DIRECT_QUERY_TARGETS;
|
||||
}
|
||||
|
||||
const targets: QueryTargetOption[] = [
|
||||
GENERAL_QUERY_TARGET,
|
||||
...metadataSearchFields.map(mapMetadataFieldToTarget),
|
||||
];
|
||||
|
||||
if (manualSearchAllowed) {
|
||||
targets.push({
|
||||
key: 'manual',
|
||||
label: 'Manual',
|
||||
description: 'Search release sources directly.',
|
||||
source: 'manual',
|
||||
});
|
||||
}
|
||||
|
||||
return targets;
|
||||
};
|
||||
|
||||
export const getDefaultQueryTargetKey = (targets: QueryTargetOption[]): string => {
|
||||
return targets[0]?.key || 'general';
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
from shelfmark.metadata_providers.hardcover import HardcoverProvider
|
||||
|
||||
|
||||
class TestHardcoverFieldOptions:
|
||||
def test_search_fields_enable_typeahead_for_author_title_and_series(self):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
fields_by_key = {field.key: field for field in provider.search_fields}
|
||||
|
||||
assert fields_by_key["author"].suggestions_endpoint == (
|
||||
"/api/metadata/field-options?provider=hardcover&field=author"
|
||||
)
|
||||
assert fields_by_key["title"].suggestions_endpoint == (
|
||||
"/api/metadata/field-options?provider=hardcover&field=title"
|
||||
)
|
||||
assert fields_by_key["series"].suggestions_endpoint == (
|
||||
"/api/metadata/field-options?provider=hardcover&field=series"
|
||||
)
|
||||
|
||||
def test_get_search_field_options_returns_author_suggestions(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_execute_query",
|
||||
lambda query, variables: captured.update({"query": query, "variables": variables}) or {
|
||||
"search": {
|
||||
"results": {
|
||||
"hits": [
|
||||
{"document": {"name": "Brandon Sanderson"}},
|
||||
{"document": {"name": "Brandon Sanderson"}},
|
||||
{"document": {"name": "Brian Sanderson"}},
|
||||
],
|
||||
"found": 3,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
options = provider.get_search_field_options("author", query="sand")
|
||||
|
||||
assert options == [
|
||||
{"value": "Brandon Sanderson", "label": "Brandon Sanderson"},
|
||||
{"value": "Brian Sanderson", "label": "Brian Sanderson"},
|
||||
]
|
||||
assert captured["variables"] == {
|
||||
"query": "sand",
|
||||
"queryType": "Author",
|
||||
"limit": 7,
|
||||
"page": 1,
|
||||
"sort": "_text_match:desc,books_count:desc",
|
||||
"fields": "name,name_personal,alternate_names",
|
||||
"weights": "4,3,2",
|
||||
}
|
||||
|
||||
def test_get_search_field_options_returns_filtered_title_suggestions(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.metadata_providers.hardcover.app_config.get",
|
||||
lambda key, default=None: {
|
||||
"HARDCOVER_EXCLUDE_COMPILATIONS": True,
|
||||
"HARDCOVER_EXCLUDE_UNRELEASED": True,
|
||||
}.get(key, default),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_execute_query",
|
||||
lambda query, variables: captured.update({"query": query, "variables": variables}) or {
|
||||
"search": {
|
||||
"results": {
|
||||
"hits": [
|
||||
{
|
||||
"document": {
|
||||
"title": "Mistborn: The Final Empire",
|
||||
"compilation": False,
|
||||
"release_year": 2006,
|
||||
}
|
||||
},
|
||||
{
|
||||
"document": {
|
||||
"title": "Mistborn Trilogy",
|
||||
"compilation": True,
|
||||
"release_year": 2001,
|
||||
}
|
||||
},
|
||||
{
|
||||
"document": {
|
||||
"title": "Ghostbloods 1",
|
||||
"compilation": False,
|
||||
"release_year": 2028,
|
||||
}
|
||||
},
|
||||
{
|
||||
"document": {
|
||||
"title": "Mistborn: The Final Empire",
|
||||
"compilation": False,
|
||||
"release_year": 2006,
|
||||
}
|
||||
},
|
||||
{
|
||||
"document": {
|
||||
"title": "Mistborn: Secret History",
|
||||
"compilation": False,
|
||||
"release_year": 2016,
|
||||
}
|
||||
},
|
||||
],
|
||||
"found": 5,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
options = provider.get_search_field_options("title", query="mistborn")
|
||||
|
||||
assert options == [
|
||||
{"value": "Mistborn: The Final Empire", "label": "Mistborn: The Final Empire"},
|
||||
{"value": "Mistborn: Secret History", "label": "Mistborn: Secret History"},
|
||||
]
|
||||
assert captured["variables"] == {
|
||||
"query": "mistborn",
|
||||
"queryType": "Book",
|
||||
"limit": 7,
|
||||
"page": 1,
|
||||
"sort": "_text_match:desc,users_count:desc",
|
||||
"fields": "title,alternative_titles",
|
||||
"weights": "5,2",
|
||||
}
|
||||
|
||||
def test_get_search_field_options_skips_short_text_queries(self):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
|
||||
assert provider.get_search_field_options("author", query="a") == []
|
||||
assert provider.get_search_field_options("title", query="i") == []
|
||||
@@ -0,0 +1,756 @@
|
||||
from shelfmark.metadata_providers import MetadataSearchOptions, SearchResult
|
||||
from shelfmark.metadata_providers.hardcover import HardcoverProvider
|
||||
|
||||
|
||||
class TestHardcoverSeriesSearch:
|
||||
def test_get_search_field_options_returns_series_suggestions(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_execute_query",
|
||||
lambda query, variables: {
|
||||
"search": {
|
||||
"results": {
|
||||
"hits": [
|
||||
{
|
||||
"document": {
|
||||
"id": 7,
|
||||
"name": "Mistborn",
|
||||
"author_name": "Brandon Sanderson",
|
||||
"primary_books_count": 7,
|
||||
}
|
||||
}
|
||||
],
|
||||
"found": 1,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
options = provider.get_search_field_options("series", query="mist")
|
||||
|
||||
assert options == [
|
||||
{
|
||||
"value": "id:7",
|
||||
"label": "Mistborn",
|
||||
"description": "by Brandon Sanderson • 7 books",
|
||||
}
|
||||
]
|
||||
|
||||
def test_series_suggestions_prefer_direct_author_series_for_author_queries(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
|
||||
def fake_execute(query: str, variables):
|
||||
if variables.get("queryType") == "Author":
|
||||
return {
|
||||
"search": {
|
||||
"results": {
|
||||
"hits": [
|
||||
{
|
||||
"document": {
|
||||
"id": 204214,
|
||||
"name": "Brandon Sanderson",
|
||||
}
|
||||
}
|
||||
],
|
||||
"found": 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
if variables.get("queryType") == "Series":
|
||||
return {
|
||||
"search": {
|
||||
"results": {
|
||||
"hits": [
|
||||
{
|
||||
"document": {
|
||||
"id": 193594,
|
||||
"name": " Brandon Sanderson",
|
||||
"author_name": "Brandon Sanderson",
|
||||
}
|
||||
},
|
||||
{
|
||||
"document": {
|
||||
"id": 1052,
|
||||
"name": "White Sand",
|
||||
"author_name": "Brandon Sanderson",
|
||||
"primary_books_count": 3,
|
||||
}
|
||||
},
|
||||
],
|
||||
"found": 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
if variables.get("authorIds") == [204214]:
|
||||
return {
|
||||
"series": [
|
||||
{
|
||||
"id": 997,
|
||||
"name": "The Stormlight Archive",
|
||||
"primary_books_count": 10,
|
||||
"books_count": 25,
|
||||
"author": {"name": "Brandon Sanderson"},
|
||||
},
|
||||
{
|
||||
"id": 5452,
|
||||
"name": "The Mistborn Saga",
|
||||
"primary_books_count": 10,
|
||||
"books_count": 16,
|
||||
"author": {"name": "Brandon Sanderson"},
|
||||
},
|
||||
]
|
||||
}
|
||||
raise AssertionError(f"Unexpected query variables: {variables}")
|
||||
|
||||
monkeypatch.setattr(provider, "_execute_query", fake_execute)
|
||||
|
||||
options = provider.get_search_field_options("series", query="Brandon Sanderson")
|
||||
|
||||
assert options[:3] == [
|
||||
{
|
||||
"value": "id:997",
|
||||
"label": "The Stormlight Archive",
|
||||
"description": "by Brandon Sanderson • 10 books",
|
||||
},
|
||||
{
|
||||
"value": "id:5452",
|
||||
"label": "The Mistborn Saga",
|
||||
"description": "by Brandon Sanderson • 10 books",
|
||||
},
|
||||
{
|
||||
"value": "id:193594",
|
||||
"label": "Brandon Sanderson",
|
||||
"description": "by Brandon Sanderson",
|
||||
},
|
||||
]
|
||||
|
||||
def test_search_paginated_uses_selected_series_id(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
expected = SearchResult(books=[], page=2, total_found=14, has_more=True)
|
||||
captured: dict[str, int] = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.metadata_providers.hardcover.app_config.get",
|
||||
lambda key, default=None: {
|
||||
"HARDCOVER_EXCLUDE_COMPILATIONS": True,
|
||||
"HARDCOVER_EXCLUDE_UNRELEASED": True,
|
||||
}.get(key, default),
|
||||
)
|
||||
|
||||
def fake_fetch(
|
||||
series_id: int,
|
||||
page: int,
|
||||
limit: int,
|
||||
exclude_compilations: bool,
|
||||
exclude_unreleased: bool,
|
||||
) -> SearchResult:
|
||||
captured["series_id"] = series_id
|
||||
captured["page"] = page
|
||||
captured["limit"] = limit
|
||||
captured["exclude_compilations"] = int(exclude_compilations)
|
||||
captured["exclude_unreleased"] = int(exclude_unreleased)
|
||||
return expected
|
||||
|
||||
monkeypatch.setattr(provider, "_fetch_series_books_by_id", fake_fetch)
|
||||
|
||||
result = provider.search_paginated(
|
||||
MetadataSearchOptions(
|
||||
query="",
|
||||
page=2,
|
||||
limit=20,
|
||||
fields={"series": "id:42"},
|
||||
)
|
||||
)
|
||||
|
||||
assert result == expected
|
||||
assert captured == {
|
||||
"series_id": 42,
|
||||
"page": 2,
|
||||
"limit": 20,
|
||||
"exclude_compilations": 1,
|
||||
"exclude_unreleased": 1,
|
||||
}
|
||||
|
||||
def test_search_paginated_resolves_typed_series_name_before_fetch(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
expected = SearchResult(books=[], page=1, total_found=3, has_more=False)
|
||||
captured: dict[str, int] = {}
|
||||
|
||||
def fake_execute(query: str, variables):
|
||||
return {
|
||||
"search": {
|
||||
"results": {
|
||||
"hits": [
|
||||
{"document": {"id": 3, "name": "Mistborn Trilogy"}},
|
||||
{"document": {"id": 9, "name": "Mistborn"}},
|
||||
],
|
||||
"found": 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.metadata_providers.hardcover.app_config.get",
|
||||
lambda key, default=None: {
|
||||
"HARDCOVER_EXCLUDE_COMPILATIONS": False,
|
||||
"HARDCOVER_EXCLUDE_UNRELEASED": True,
|
||||
}.get(key, default),
|
||||
)
|
||||
|
||||
def fake_fetch(
|
||||
series_id: int,
|
||||
page: int,
|
||||
limit: int,
|
||||
exclude_compilations: bool,
|
||||
exclude_unreleased: bool,
|
||||
) -> SearchResult:
|
||||
captured["series_id"] = series_id
|
||||
captured["exclude_compilations"] = int(exclude_compilations)
|
||||
captured["exclude_unreleased"] = int(exclude_unreleased)
|
||||
return expected
|
||||
|
||||
monkeypatch.setattr(provider, "_execute_query", fake_execute)
|
||||
monkeypatch.setattr(provider, "_fetch_series_books_by_id", fake_fetch)
|
||||
|
||||
result = provider.search_paginated(
|
||||
MetadataSearchOptions(
|
||||
query="",
|
||||
page=1,
|
||||
limit=40,
|
||||
fields={"series": "Mistborn"},
|
||||
)
|
||||
)
|
||||
|
||||
assert result == expected
|
||||
assert captured["series_id"] == 9
|
||||
assert captured["exclude_compilations"] == 0
|
||||
assert captured["exclude_unreleased"] == 1
|
||||
|
||||
def test_fetch_series_books_by_id_preserves_series_metadata(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.metadata_providers.hardcover.app_config.get",
|
||||
lambda key, default=None: {
|
||||
"HARDCOVER_EXCLUDE_COMPILATIONS": True,
|
||||
"HARDCOVER_EXCLUDE_UNRELEASED": False,
|
||||
}.get(key, default),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_execute_query",
|
||||
lambda query, variables: captured.update({"query": query, "variables": variables}) or {
|
||||
"series": [
|
||||
{
|
||||
"id": 42,
|
||||
"name": "Mistborn",
|
||||
"primary_books_count": 3,
|
||||
"book_series": [
|
||||
{
|
||||
"position": 1,
|
||||
"book": {
|
||||
"id": 100,
|
||||
"title": "The Final Empire",
|
||||
"subtitle": None,
|
||||
"slug": "the-final-empire",
|
||||
"release_date": "2006-07-17",
|
||||
"headline": None,
|
||||
"description": "A heist.",
|
||||
"rating": 4.5,
|
||||
"ratings_count": 120,
|
||||
"users_count": 250,
|
||||
"cached_image": {"url": "https://example.com/final-empire.jpg"},
|
||||
"cached_contributors": [{"name": "Brandon Sanderson"}],
|
||||
"contributions": [],
|
||||
"featured_book_series": {
|
||||
"position": 1,
|
||||
"series": {
|
||||
"id": 42,
|
||||
"name": "Mistborn",
|
||||
"primary_books_count": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"book": {
|
||||
"id": 101,
|
||||
"title": "The Well of Ascension",
|
||||
"subtitle": None,
|
||||
"slug": "the-well-of-ascension",
|
||||
"release_date": "2007-08-21",
|
||||
"headline": None,
|
||||
"description": "The sequel.",
|
||||
"rating": 4.4,
|
||||
"ratings_count": 110,
|
||||
"users_count": 220,
|
||||
"cached_image": {"url": "https://example.com/well-of-ascension.jpg"},
|
||||
"cached_contributors": [{"name": "Brandon Sanderson"}],
|
||||
"contributions": [],
|
||||
"featured_book_series": {
|
||||
"position": 2,
|
||||
"series": {
|
||||
"id": 42,
|
||||
"name": "Mistborn",
|
||||
"primary_books_count": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
result = provider._fetch_series_books_by_id(
|
||||
42,
|
||||
page=1,
|
||||
limit=2,
|
||||
exclude_compilations=True,
|
||||
exclude_unreleased=False,
|
||||
)
|
||||
|
||||
assert "canonical_id: {_is_null: true}" in str(captured["query"])
|
||||
assert 'state: {_in: ["normalized", "normalizing"]}' in str(captured["query"])
|
||||
assert result.total_found == 2
|
||||
assert result.has_more is False
|
||||
assert captured["variables"] == {"seriesId": 42}
|
||||
assert [book.title for book in result.books] == [
|
||||
"The Final Empire",
|
||||
"The Well of Ascension",
|
||||
]
|
||||
assert result.books[0].series_id == "42"
|
||||
assert result.books[0].series_name == "Mistborn"
|
||||
assert result.books[0].series_position == 1
|
||||
assert result.books[0].series_count == 2
|
||||
|
||||
def test_fetch_series_books_by_id_skips_split_part_entries_for_standard_series(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.metadata_providers.hardcover.app_config.get",
|
||||
lambda key, default=None: {
|
||||
"HARDCOVER_EXCLUDE_COMPILATIONS": False,
|
||||
"HARDCOVER_EXCLUDE_UNRELEASED": False,
|
||||
}.get(key, default),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_execute_query",
|
||||
lambda query, variables: {
|
||||
"series": [
|
||||
{
|
||||
"id": 10001,
|
||||
"name": "The Mistborn Saga: The Original Trilogy",
|
||||
"primary_books_count": 6,
|
||||
"book_series": [
|
||||
{
|
||||
"position": 0.5,
|
||||
"book": {
|
||||
"id": 427844,
|
||||
"title": "The Eleventh Metal",
|
||||
"subtitle": None,
|
||||
"slug": "the-eleventh-metal",
|
||||
"release_date": "2012-04-11",
|
||||
"headline": None,
|
||||
"description": None,
|
||||
"rating": 4.1,
|
||||
"ratings_count": 404,
|
||||
"users_count": 991,
|
||||
"editions_count": 5,
|
||||
"compilation": False,
|
||||
"cached_image": {},
|
||||
"cached_contributors": [{"name": "Brandon Sanderson"}],
|
||||
"contributions": [],
|
||||
"featured_book_series": {
|
||||
"position": 0.5,
|
||||
"series": {
|
||||
"id": 10001,
|
||||
"name": "The Mistborn Saga: The Original Trilogy",
|
||||
"primary_books_count": 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"book": {
|
||||
"id": 369692,
|
||||
"title": "Mistborn: The Final Empire",
|
||||
"subtitle": None,
|
||||
"slug": "mistborn-the-final-empire",
|
||||
"release_date": "2006-01-01",
|
||||
"headline": None,
|
||||
"description": None,
|
||||
"rating": 4.5,
|
||||
"ratings_count": 3942,
|
||||
"users_count": 8192,
|
||||
"editions_count": 92,
|
||||
"compilation": False,
|
||||
"cached_image": {},
|
||||
"cached_contributors": [{"name": "Brandon Sanderson"}],
|
||||
"contributions": [],
|
||||
"featured_book_series": {
|
||||
"position": 1,
|
||||
"series": {
|
||||
"id": 10001,
|
||||
"name": "The Mistborn Saga: The Original Trilogy",
|
||||
"primary_books_count": 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"book": {
|
||||
"id": 427383,
|
||||
"title": "The Well of Ascension",
|
||||
"subtitle": None,
|
||||
"slug": "the-well-of-ascension",
|
||||
"release_date": "2007-08-21",
|
||||
"headline": None,
|
||||
"description": None,
|
||||
"rating": 4.4,
|
||||
"ratings_count": 3007,
|
||||
"users_count": 5136,
|
||||
"editions_count": 85,
|
||||
"compilation": False,
|
||||
"cached_image": {},
|
||||
"cached_contributors": [{"name": "Brandon Sanderson"}],
|
||||
"contributions": [],
|
||||
"featured_book_series": {
|
||||
"position": 2,
|
||||
"series": {
|
||||
"id": 10001,
|
||||
"name": "The Mistborn Saga: The Original Trilogy",
|
||||
"primary_books_count": 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"book": {
|
||||
"id": 103241,
|
||||
"title": "The Hero of Ages",
|
||||
"subtitle": None,
|
||||
"slug": "the-hero-of-ages",
|
||||
"release_date": "2007-12-30",
|
||||
"headline": None,
|
||||
"description": None,
|
||||
"rating": 4.4,
|
||||
"ratings_count": 2786,
|
||||
"users_count": 4752,
|
||||
"editions_count": 78,
|
||||
"compilation": False,
|
||||
"cached_image": {},
|
||||
"cached_contributors": [{"name": "Brandon Sanderson"}],
|
||||
"contributions": [],
|
||||
"featured_book_series": {
|
||||
"position": 3,
|
||||
"series": {
|
||||
"id": 10001,
|
||||
"name": "The Mistborn Saga: The Original Trilogy",
|
||||
"primary_books_count": 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"position": 3.5,
|
||||
"book": {
|
||||
"id": 427881,
|
||||
"title": "Mistborn: Secret History",
|
||||
"subtitle": None,
|
||||
"slug": "mistborn-secret-history",
|
||||
"release_date": "2016-01-01",
|
||||
"headline": None,
|
||||
"description": None,
|
||||
"rating": 4.3,
|
||||
"ratings_count": 717,
|
||||
"users_count": 1546,
|
||||
"editions_count": 13,
|
||||
"compilation": False,
|
||||
"cached_image": {},
|
||||
"cached_contributors": [{"name": "Brandon Sanderson"}],
|
||||
"contributions": [],
|
||||
"featured_book_series": {
|
||||
"position": 3.5,
|
||||
"series": {
|
||||
"id": 10001,
|
||||
"name": "The Mistborn Saga: The Original Trilogy",
|
||||
"primary_books_count": 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"position": 6,
|
||||
"book": {
|
||||
"id": 763900,
|
||||
"title": "The Bands of Mourning, Part 2",
|
||||
"subtitle": None,
|
||||
"slug": "the-bands-of-mourning-part-2",
|
||||
"release_date": "2011-11-08",
|
||||
"headline": None,
|
||||
"description": None,
|
||||
"rating": 2.0,
|
||||
"ratings_count": 1,
|
||||
"users_count": 13,
|
||||
"editions_count": 1,
|
||||
"compilation": False,
|
||||
"cached_image": {},
|
||||
"cached_contributors": [{"name": "Brandon Sanderson"}],
|
||||
"contributions": [],
|
||||
"featured_book_series": {
|
||||
"position": 6,
|
||||
"series": {
|
||||
"id": 10001,
|
||||
"name": "The Mistborn Saga: The Original Trilogy",
|
||||
"primary_books_count": 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
result = provider._fetch_series_books_by_id(
|
||||
10001,
|
||||
page=1,
|
||||
limit=20,
|
||||
exclude_compilations=False,
|
||||
exclude_unreleased=False,
|
||||
)
|
||||
|
||||
assert [book.title for book in result.books] == [
|
||||
"The Eleventh Metal",
|
||||
"Mistborn: The Final Empire",
|
||||
"The Well of Ascension",
|
||||
"The Hero of Ages",
|
||||
"Mistborn: Secret History",
|
||||
]
|
||||
assert result.total_found == 5
|
||||
assert result.has_more is False
|
||||
|
||||
def test_fetch_series_books_by_id_prefers_best_book_per_position(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.metadata_providers.hardcover.app_config.get",
|
||||
lambda key, default=None: {
|
||||
"HARDCOVER_EXCLUDE_COMPILATIONS": False,
|
||||
"HARDCOVER_EXCLUDE_UNRELEASED": True,
|
||||
}.get(key, default),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_execute_query",
|
||||
lambda query, variables: {
|
||||
"series": [
|
||||
{
|
||||
"id": 5452,
|
||||
"name": "The Mistborn Saga",
|
||||
"primary_books_count": 10,
|
||||
"book_series": [
|
||||
{
|
||||
"position": 0.5,
|
||||
"book": {
|
||||
"id": 1389888,
|
||||
"title": "Sunrise on the Reaping",
|
||||
"subtitle": None,
|
||||
"slug": "sunrise-on-the-reaping",
|
||||
"release_date": "2025-01-01",
|
||||
"headline": None,
|
||||
"description": "The next Panem story.",
|
||||
"rating": 4.5,
|
||||
"ratings_count": 900,
|
||||
"users_count": 2314,
|
||||
"editions_count": 32,
|
||||
"compilation": False,
|
||||
"cached_image": {"url": "https://example.com/sunrise.jpg"},
|
||||
"cached_contributors": [{"name": "Suzanne Collins"}],
|
||||
"contributions": [],
|
||||
"featured_book_series": {
|
||||
"position": 0.5,
|
||||
"series": {
|
||||
"id": 5452,
|
||||
"name": "The Mistborn Saga",
|
||||
"primary_books_count": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"book": {
|
||||
"id": 330249,
|
||||
"title": "The Alloy of Law",
|
||||
"subtitle": None,
|
||||
"slug": "the-alloy-of-law",
|
||||
"release_date": "2011-01-01",
|
||||
"headline": None,
|
||||
"description": "Wax and Wayne arrive.",
|
||||
"rating": 4.2,
|
||||
"ratings_count": 1630,
|
||||
"users_count": 3142,
|
||||
"editions_count": 53,
|
||||
"compilation": False,
|
||||
"cached_image": {"url": "https://example.com/alloy.jpg"},
|
||||
"cached_contributors": [{"name": "Brandon Sanderson"}],
|
||||
"contributions": [],
|
||||
"featured_book_series": {
|
||||
"position": 1,
|
||||
"series": {
|
||||
"id": 10825,
|
||||
"name": "Wax & Wayne",
|
||||
"primary_books_count": 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"book": {
|
||||
"id": 2190514,
|
||||
"title": "Aleación de ley (Wax & Wayne 1): Una novela de Mistborn",
|
||||
"subtitle": None,
|
||||
"slug": "aleacion-de-ley",
|
||||
"release_date": "2011-11-08",
|
||||
"headline": None,
|
||||
"description": "Localized duplicate.",
|
||||
"rating": 4.0,
|
||||
"ratings_count": 1,
|
||||
"users_count": 2,
|
||||
"editions_count": 1,
|
||||
"compilation": False,
|
||||
"cached_image": {"url": "https://example.com/aleacion.jpg"},
|
||||
"cached_contributors": [{"name": "Brandon Sanderson"}],
|
||||
"contributions": [],
|
||||
"featured_book_series": {
|
||||
"position": 4,
|
||||
"series": {
|
||||
"id": 5452,
|
||||
"name": "The Mistborn Saga",
|
||||
"primary_books_count": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"position": 4.5,
|
||||
"book": {
|
||||
"id": 427892,
|
||||
"title": "Allomancer Jak and the Pits of Eltania",
|
||||
"subtitle": None,
|
||||
"slug": "allomancer-jak",
|
||||
"release_date": "2014-08-03",
|
||||
"headline": None,
|
||||
"description": "A novella.",
|
||||
"rating": 4.1,
|
||||
"ratings_count": 182,
|
||||
"users_count": 572,
|
||||
"editions_count": 3,
|
||||
"compilation": False,
|
||||
"cached_image": {"url": "https://example.com/jak.jpg"},
|
||||
"cached_contributors": [{"name": "Brandon Sanderson"}],
|
||||
"contributions": [],
|
||||
"featured_book_series": {
|
||||
"position": 4.5,
|
||||
"series": {
|
||||
"id": 5452,
|
||||
"name": "The Mistborn Saga",
|
||||
"primary_books_count": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"position": 8,
|
||||
"book": {
|
||||
"id": 1878844,
|
||||
"title": "Ghostbloods 1",
|
||||
"subtitle": None,
|
||||
"slug": "ghostbloods-1",
|
||||
"release_date": "2028-12-01",
|
||||
"headline": None,
|
||||
"description": "Future title.",
|
||||
"rating": None,
|
||||
"ratings_count": 0,
|
||||
"users_count": 68,
|
||||
"editions_count": 1,
|
||||
"compilation": False,
|
||||
"cached_image": {"url": "https://example.com/ghostbloods.jpg"},
|
||||
"cached_contributors": [{"name": "Brandon Sanderson"}],
|
||||
"contributions": [],
|
||||
"featured_book_series": {
|
||||
"position": 8,
|
||||
"series": {
|
||||
"id": 5452,
|
||||
"name": "The Mistborn Saga",
|
||||
"primary_books_count": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"position": 9,
|
||||
"book": {
|
||||
"id": 1898126,
|
||||
"title": "Ghostbloods 2",
|
||||
"subtitle": None,
|
||||
"slug": "ghostbloods-2",
|
||||
"release_date": None,
|
||||
"headline": None,
|
||||
"description": "No published date yet.",
|
||||
"rating": None,
|
||||
"ratings_count": 0,
|
||||
"users_count": 40,
|
||||
"editions_count": 1,
|
||||
"compilation": False,
|
||||
"cached_image": {"url": "https://example.com/ghostbloods-2.jpg"},
|
||||
"cached_contributors": [{"name": "Brandon Sanderson"}],
|
||||
"contributions": [],
|
||||
"featured_book_series": {
|
||||
"position": 9,
|
||||
"series": {
|
||||
"id": 5452,
|
||||
"name": "The Mistborn Saga",
|
||||
"primary_books_count": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
result = provider._fetch_series_books_by_id(
|
||||
5452,
|
||||
page=1,
|
||||
limit=10,
|
||||
exclude_compilations=False,
|
||||
exclude_unreleased=True,
|
||||
)
|
||||
|
||||
assert result.total_found == 3
|
||||
assert result.has_more is False
|
||||
assert [book.title for book in result.books] == [
|
||||
"Sunrise on the Reaping",
|
||||
"The Alloy of Law",
|
||||
"Allomancer Jak and the Pits of Eltania",
|
||||
]
|
||||
assert [book.series_position for book in result.books] == [0.5, 4, 4.5]
|
||||
@@ -0,0 +1,15 @@
|
||||
from shelfmark.metadata_providers import get_provider_capabilities
|
||||
|
||||
|
||||
class TestMetadataProviderCapabilities:
|
||||
def test_hardcover_exposes_view_series_capability(self):
|
||||
assert get_provider_capabilities("hardcover") == [
|
||||
{
|
||||
"key": "view_series",
|
||||
"field_key": "series",
|
||||
"sort": "series_order",
|
||||
}
|
||||
]
|
||||
|
||||
def test_providers_without_capabilities_return_empty_list(self):
|
||||
assert get_provider_capabilities("openlibrary") == []
|
||||
Reference in New Issue
Block a user