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:
Alex
2026-03-06 14:44:55 +00:00
committed by GitHub
parent 8bb188c903
commit 60893b19c6
36 changed files with 3517 additions and 1111 deletions
+7 -1
View File
@@ -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..."
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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
+13 -1
View File
@@ -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."""
+9
View File
@@ -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:
+1 -1
View File
@@ -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
View File
@@ -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}")
+75 -7
View File
@@ -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
+665 -52
View File
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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'}
/>
+100 -207
View File
@@ -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>
);
};
+2 -2
View File
@@ -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>
</>
);
};
+16 -10
View File
@@ -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>
);
};
+3 -1
View File
@@ -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 [];
};
+21 -16
View File
@@ -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>
+3 -3
View File
@@ -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"
+641 -159
View File
@@ -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>
);
});
+52 -54
View File
@@ -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
-1
View File
@@ -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}
/>
);
};
+31 -1
View File
@@ -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,
+74 -56
View File
@@ -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,
+42 -3
View File
@@ -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 [];
}
+5
View File
@@ -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');
});
});
+48
View File
@@ -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,
+10 -13
View File
@@ -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 -1
View File
@@ -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;
+87
View File
@@ -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") == []