mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 08:51:11 +01:00
Expanded Hardcover list features (#719)
- Adds full interaction with Hardcover lists, including adding and removing from lists + want to read status - List selection exposed in search results, details modal and release modal - Added automatic list dropdown when selecting "list" search - Added auto-removal of books from a list when downloading from that specific list page - Changed search selector to hover-activated
This commit is contained in:
@@ -62,6 +62,14 @@ class CacheService:
|
||||
return True
|
||||
return False
|
||||
|
||||
def invalidate_prefix(self, prefix: str) -> int:
|
||||
"""Remove all cache entries whose keys start with prefix."""
|
||||
with self._lock:
|
||||
matching_keys = [key for key in self._cache if key.startswith(prefix)]
|
||||
for key in matching_keys:
|
||||
del self._cache[key]
|
||||
return len(matching_keys)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all cache entries."""
|
||||
with self._lock:
|
||||
|
||||
+99
-17
@@ -2178,14 +2178,17 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]:
|
||||
cache_id = f"{book_dict['provider']}_{book_dict['provider_id']}"
|
||||
book_dict['cover_url'] = transform_cover_url(book_dict['cover_url'], cache_id)
|
||||
|
||||
return jsonify({
|
||||
response_data = {
|
||||
"books": books_data,
|
||||
"provider": provider.name,
|
||||
"query": query,
|
||||
"page": search_result.page,
|
||||
"total_found": search_result.total_found,
|
||||
"has_more": search_result.has_more
|
||||
})
|
||||
"has_more": search_result.has_more,
|
||||
}
|
||||
if search_result.source_url:
|
||||
response_data["source_url"] = search_result.source_url
|
||||
return jsonify(response_data)
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Metadata search error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
@@ -2232,6 +2235,29 @@ def api_metadata_field_options() -> Response:
|
||||
return jsonify({"options": []})
|
||||
|
||||
|
||||
def _resolve_metadata_provider(provider_name: str):
|
||||
"""Validate, instantiate and return a ready metadata provider.
|
||||
|
||||
Raises appropriate HTTP-friendly exceptions on failure.
|
||||
"""
|
||||
from shelfmark.metadata_providers import (
|
||||
get_provider,
|
||||
get_provider_kwargs,
|
||||
is_provider_registered,
|
||||
)
|
||||
|
||||
if not is_provider_registered(provider_name):
|
||||
raise ValueError(f"Unknown metadata provider: {provider_name}")
|
||||
|
||||
kwargs = get_provider_kwargs(provider_name)
|
||||
prov = get_provider(provider_name, **kwargs)
|
||||
|
||||
if not prov.is_available():
|
||||
raise RuntimeError(f"Provider '{provider_name}' is not available")
|
||||
|
||||
return prov
|
||||
|
||||
|
||||
@app.route('/api/metadata/book/<provider>/<book_id>', methods=['GET'])
|
||||
@login_required
|
||||
def api_metadata_book(provider: str, book_id: str) -> Union[Response, Tuple[Response, int]]:
|
||||
@@ -2246,22 +2272,9 @@ def api_metadata_book(provider: str, book_id: str) -> Union[Response, Tuple[Resp
|
||||
flask.Response: JSON with book details.
|
||||
"""
|
||||
try:
|
||||
from shelfmark.metadata_providers import (
|
||||
get_provider,
|
||||
is_provider_registered,
|
||||
get_provider_kwargs,
|
||||
)
|
||||
from dataclasses import asdict
|
||||
|
||||
if not is_provider_registered(provider):
|
||||
return jsonify({"error": f"Unknown metadata provider: {provider}"}), 400
|
||||
|
||||
# Get provider instance with appropriate configuration
|
||||
kwargs = get_provider_kwargs(provider)
|
||||
prov = get_provider(provider, **kwargs)
|
||||
|
||||
if not prov.is_available():
|
||||
return jsonify({"error": f"Provider '{provider}' is not available"}), 503
|
||||
prov = _resolve_metadata_provider(provider)
|
||||
|
||||
book = prov.get_book(book_id)
|
||||
if not book:
|
||||
@@ -2278,11 +2291,80 @@ def api_metadata_book(provider: str, book_id: str) -> Union[Response, Tuple[Resp
|
||||
return jsonify(book_dict)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except RuntimeError as e:
|
||||
return jsonify({"error": str(e)}), 503
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Metadata book error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
def _handle_target_errors(fallback_message: str):
|
||||
"""Decorator that wraps a metadata-target route with standard error handling."""
|
||||
def decorator(fn):
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except (NotImplementedError, ValueError) as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except RuntimeError as e:
|
||||
return jsonify({"error": str(e)}), 502
|
||||
except Exception as e:
|
||||
logger.error_trace(f"{fallback_message}: {e}")
|
||||
return jsonify({"error": fallback_message}), 500
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
@app.route('/api/metadata/book/<provider>/<book_id>/targets', methods=['GET'])
|
||||
@login_required
|
||||
@_handle_target_errors("Failed to load book targets")
|
||||
def api_metadata_book_targets(provider: str, book_id: str) -> Union[Response, Tuple[Response, int]]:
|
||||
"""Get provider-managed list/status targets for a specific book."""
|
||||
prov = _resolve_metadata_provider(provider)
|
||||
return jsonify({"options": prov.get_book_targets(book_id)})
|
||||
|
||||
|
||||
@app.route('/api/metadata/book/<provider>/targets/batch', methods=['POST'])
|
||||
@login_required
|
||||
@_handle_target_errors("Failed to load book targets")
|
||||
def api_metadata_book_targets_batch(provider: str) -> Union[Response, Tuple[Response, int]]:
|
||||
"""Get provider-managed list/status targets for multiple books."""
|
||||
prov = _resolve_metadata_provider(provider)
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
raw_ids = payload.get("book_ids", []) if isinstance(payload, dict) else []
|
||||
if not isinstance(raw_ids, list) or not raw_ids:
|
||||
return jsonify({"error": "book_ids must be a non-empty array"}), 400
|
||||
|
||||
book_ids = [str(bid) for bid in raw_ids[:50]]
|
||||
|
||||
return jsonify({"results": prov.get_book_targets_batch(book_ids)})
|
||||
|
||||
|
||||
@app.route('/api/metadata/book/<provider>/<book_id>/targets', methods=['PUT'])
|
||||
@login_required
|
||||
@_handle_target_errors("Failed to update book targets")
|
||||
def api_metadata_book_targets_update(provider: str, book_id: str) -> Union[Response, Tuple[Response, int]]:
|
||||
"""Set whether a book belongs to a provider-managed list or shelf."""
|
||||
prov = _resolve_metadata_provider(provider)
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
target = str(payload.get("target", "")).strip() if isinstance(payload, dict) else ""
|
||||
selected = payload.get("selected") if isinstance(payload, dict) else None
|
||||
if not target:
|
||||
return jsonify({"error": "target is required"}), 400
|
||||
if not isinstance(selected, bool):
|
||||
return jsonify({"error": "selected must be a boolean"}), 400
|
||||
|
||||
result = prov.set_book_target_state(book_id, target, selected)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"changed": bool(result.get("changed", True)),
|
||||
"selected": selected,
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/releases', methods=['GET'])
|
||||
@login_required
|
||||
def api_releases() -> Union[Response, Tuple[Response, int]]:
|
||||
|
||||
@@ -310,6 +310,7 @@ class SearchResult:
|
||||
page: int = 1
|
||||
total_found: int = 0 # Total matching results (if known)
|
||||
has_more: bool = False # True if more results available
|
||||
source_url: Optional[str] = None # External URL for the result set (e.g. Hardcover list page)
|
||||
|
||||
|
||||
class MetadataProvider(ABC):
|
||||
@@ -373,6 +374,36 @@ class MetadataProvider(ABC):
|
||||
"""Get dynamic options for a provider-specific search field."""
|
||||
return []
|
||||
|
||||
def get_book_targets(self, book_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get provider-managed list or status targets for a specific book."""
|
||||
raise NotImplementedError(f"{self.display_name} does not support book targets")
|
||||
|
||||
def get_book_targets_batch(self, book_ids: List[str]) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Get provider-managed targets for multiple books.
|
||||
|
||||
Returns a dict mapping each book_id to its list of target options.
|
||||
Default implementation calls get_book_targets per book.
|
||||
"""
|
||||
results: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for book_id in book_ids:
|
||||
try:
|
||||
results[book_id] = self.get_book_targets(book_id)
|
||||
except (NotImplementedError, ValueError):
|
||||
results[book_id] = []
|
||||
return results
|
||||
|
||||
def set_book_target_state(
|
||||
self,
|
||||
book_id: str,
|
||||
target: str,
|
||||
selected: bool,
|
||||
) -> Dict[str, Any]:
|
||||
"""Set whether a book belongs to a provider-managed list or shelf.
|
||||
|
||||
Returns a dict with at least ``{"changed": bool}``.
|
||||
"""
|
||||
raise NotImplementedError(f"{self.display_name} does not support book targets")
|
||||
|
||||
|
||||
# Provider registry
|
||||
_PROVIDERS: Dict[str, Type[MetadataProvider]] = {}
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import re
|
||||
import requests
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from shelfmark.core.cache import cacheable
|
||||
from shelfmark.core.cache import cache_key, cacheable, get_metadata_cache
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.settings_registry import (
|
||||
register_settings,
|
||||
@@ -58,6 +59,10 @@ query LookupListsBySlug($slug: String!) {
|
||||
LIST_BOOKS_BY_ID_QUERY = """
|
||||
query GetListBooksById($id: Int!, $limit: Int!, $offset: Int!) {
|
||||
lists(where: {id: {_eq: $id}}, limit: 1) {
|
||||
slug
|
||||
user {
|
||||
username
|
||||
}
|
||||
books_count
|
||||
list_books(order_by: {position: asc}, limit: $limit, offset: $offset) {
|
||||
book {
|
||||
@@ -172,6 +177,102 @@ query GetCurrentUserBooksByStatus($statusId: Int!, $limit: Int!, $offset: Int!)
|
||||
}
|
||||
"""
|
||||
|
||||
BOOK_TARGET_MEMBERSHIP_QUERY = """
|
||||
query GetBookTargetMembership($bookId: Int!) {
|
||||
me {
|
||||
user_books(where: {book_id: {_eq: $bookId}}, limit: 1, order_by: [{created_at: desc}]) {
|
||||
id
|
||||
status_id
|
||||
}
|
||||
lists {
|
||||
id
|
||||
list_books(where: {book_id: {_eq: $bookId}}, limit: 1) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
BOOK_TARGET_MEMBERSHIP_BATCH_QUERY = """
|
||||
query GetBookTargetMembershipBatch($bookIds: [Int!]!) {
|
||||
me {
|
||||
user_books(where: {book_id: {_in: $bookIds}}, order_by: [{created_at: desc}]) {
|
||||
id
|
||||
book_id
|
||||
status_id
|
||||
}
|
||||
lists {
|
||||
id
|
||||
list_books(where: {book_id: {_in: $bookIds}}) {
|
||||
id
|
||||
book_id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
INSERT_USER_BOOK_MUTATION = """
|
||||
mutation AddBookToStatus($bookId: Int!, $statusId: Int!) {
|
||||
insert_user_book(object: {book_id: $bookId, status_id: $statusId}) {
|
||||
id
|
||||
error
|
||||
user_book {
|
||||
id
|
||||
book_id
|
||||
status_id
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
UPDATE_USER_BOOK_MUTATION = """
|
||||
mutation UpdateBookStatus($userBookId: Int!, $statusId: Int!) {
|
||||
update_user_book(id: $userBookId, object: {status_id: $statusId}) {
|
||||
id
|
||||
error
|
||||
user_book {
|
||||
id
|
||||
book_id
|
||||
status_id
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
DELETE_USER_BOOK_MUTATION = """
|
||||
mutation RemoveBookStatus($userBookId: Int!) {
|
||||
delete_user_book(id: $userBookId) {
|
||||
id
|
||||
book_id
|
||||
user_id
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
INSERT_LIST_BOOK_MUTATION = """
|
||||
mutation AddBookToList($bookId: Int!, $listId: Int!) {
|
||||
insert_list_book(object: {book_id: $bookId, list_id: $listId}) {
|
||||
id
|
||||
list_book {
|
||||
id
|
||||
book_id
|
||||
list_id
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
DELETE_LIST_BOOK_MUTATION = """
|
||||
mutation RemoveBookFromList($listBookId: Int!) {
|
||||
delete_list_book(id: $listBookId) {
|
||||
id
|
||||
list_id
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
SEARCH_FIELD_OPTIONS_QUERY = """
|
||||
query SearchFieldOptions(
|
||||
$query: String!,
|
||||
@@ -271,6 +372,46 @@ query GetSeriesBooks($seriesId: Int!) {
|
||||
|
||||
HARDCOVER_WANT_TO_READ_STATUS_ID = 1
|
||||
HARDCOVER_STATUS_PREFIX = "status:"
|
||||
HARDCOVER_STATUS_URL_SLUGS: dict[int, str] = {
|
||||
1: "want-to-read",
|
||||
2: "currently-reading",
|
||||
3: "read",
|
||||
5: "did-not-finish",
|
||||
}
|
||||
HARDCOVER_LIST_ID_PREFIX = "id:"
|
||||
HARDCOVER_WRITABLE_TARGET_GROUPS = {"My Books", "My Lists"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HardcoverBookTargetState:
|
||||
"""Current Hardcover target state for a specific book."""
|
||||
user_book_id: Optional[int]
|
||||
status_id: Optional[int]
|
||||
list_book_ids: Dict[int, int]
|
||||
|
||||
|
||||
class HardcoverGraphQLError(ValueError):
|
||||
"""GraphQL request was rejected by Hardcover."""
|
||||
|
||||
|
||||
def _extract_graphql_error_message(payload: Any) -> str:
|
||||
"""Extract a readable message from a GraphQL error payload."""
|
||||
if not isinstance(payload, dict):
|
||||
return ""
|
||||
|
||||
errors = payload.get("errors", [])
|
||||
if not isinstance(errors, list):
|
||||
return ""
|
||||
|
||||
messages: List[str] = []
|
||||
for error in errors:
|
||||
if not isinstance(error, dict):
|
||||
continue
|
||||
message = str(error.get("message") or "").strip()
|
||||
if message:
|
||||
messages.append(message)
|
||||
|
||||
return "; ".join(messages)
|
||||
|
||||
|
||||
# Mapping from abstract sort order to Hardcover sort parameter
|
||||
@@ -662,14 +803,12 @@ class HardcoverProvider(MetadataProvider):
|
||||
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",
|
||||
@@ -770,6 +909,14 @@ class HardcoverProvider(MetadataProvider):
|
||||
list_books = list_data.get("list_books", []) if isinstance(list_data, dict) else []
|
||||
books_count_raw = list_data.get("books_count", 0) if isinstance(list_data, dict) else 0
|
||||
|
||||
# Build source URL from slug and owner username
|
||||
source_url = None
|
||||
list_slug = str(list_data.get("slug") or "").strip()
|
||||
user_data = list_data.get("user", {})
|
||||
owner_username = str(user_data.get("username") or "").strip() if isinstance(user_data, dict) else ""
|
||||
if list_slug and owner_username:
|
||||
source_url = f"https://hardcover.app/@{owner_username}/lists/{list_slug}"
|
||||
|
||||
try:
|
||||
books_count = int(books_count_raw)
|
||||
except (TypeError, ValueError):
|
||||
@@ -790,7 +937,7 @@ class HardcoverProvider(MetadataProvider):
|
||||
logger.debug(f"Failed to parse Hardcover list book for list_id={list_id}: {exc}")
|
||||
|
||||
has_more = offset + len(list_books) < books_count
|
||||
return SearchResult(books=books, page=page, total_found=books_count, has_more=has_more)
|
||||
return SearchResult(books=books, page=page, total_found=books_count, has_more=has_more, source_url=source_url)
|
||||
|
||||
@cacheable(ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="hardcover:list:slug")
|
||||
def _fetch_list_books(self, slug: str, owner_username: Optional[str], page: int, limit: int) -> SearchResult:
|
||||
@@ -1125,10 +1272,10 @@ class HardcoverProvider(MetadataProvider):
|
||||
if not normalized_value:
|
||||
return None
|
||||
|
||||
if normalized_value.startswith("id:"):
|
||||
if normalized_value.startswith(HARDCOVER_LIST_ID_PREFIX):
|
||||
try:
|
||||
return {"id": int(normalized_value.split(":", 1)[1])}
|
||||
except (IndexError, ValueError):
|
||||
return {"id": self._parse_prefixed_int(normalized_value, "series id")}
|
||||
except ValueError:
|
||||
logger.debug(f"Invalid Hardcover series id field value: {normalized_value}")
|
||||
return None
|
||||
|
||||
@@ -1358,7 +1505,15 @@ class HardcoverProvider(MetadataProvider):
|
||||
logger.debug(f"Failed to parse Hardcover status book for status_id={status_id}: {exc}")
|
||||
|
||||
has_more = offset + len(status_books) < total_found
|
||||
return SearchResult(books=books, page=page, total_found=total_found, has_more=has_more)
|
||||
|
||||
# Build source URL for the status shelf
|
||||
source_url = None
|
||||
url_slug = HARDCOVER_STATUS_URL_SLUGS.get(status_id)
|
||||
username = _get_connected_username()
|
||||
if url_slug and username:
|
||||
source_url = f"https://hardcover.app/@{username}/books/{url_slug}"
|
||||
|
||||
return SearchResult(books=books, page=page, total_found=total_found, has_more=has_more, source_url=source_url)
|
||||
|
||||
def _fetch_user_lists(self) -> List[Dict[str, str]]:
|
||||
"""Fetch raw list options from Hardcover me query."""
|
||||
@@ -1453,6 +1608,359 @@ class HardcoverProvider(MetadataProvider):
|
||||
|
||||
return options
|
||||
|
||||
def get_book_targets(self, book_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get writable Hardcover list/status targets for a specific book."""
|
||||
if not self.api_key:
|
||||
return []
|
||||
|
||||
book_id_int = coerce_int(book_id, 0)
|
||||
if book_id_int < 1:
|
||||
raise ValueError("book_id must be a valid Hardcover book id")
|
||||
|
||||
state = self._fetch_book_target_state(book_id_int)
|
||||
options = [
|
||||
dict(option)
|
||||
for option in self.get_user_lists()
|
||||
if option.get("group") in HARDCOVER_WRITABLE_TARGET_GROUPS
|
||||
]
|
||||
|
||||
for option in options:
|
||||
value = str(option.get("value") or "").strip()
|
||||
option["checked"] = self._is_target_checked(value, state)
|
||||
option["writable"] = True
|
||||
|
||||
return options
|
||||
|
||||
def set_book_target_state(self, book_id: str, target: str, selected: bool) -> Dict[str, Any]:
|
||||
"""Set whether a Hardcover book belongs to a status shelf or user list."""
|
||||
if not self.api_key:
|
||||
raise ValueError("Hardcover is not configured")
|
||||
|
||||
book_id_int = coerce_int(book_id, 0)
|
||||
if book_id_int < 1:
|
||||
raise ValueError("book_id must be a valid Hardcover book id")
|
||||
|
||||
selected_target = str(target or "").strip()
|
||||
if not selected_target:
|
||||
raise ValueError("target is required")
|
||||
|
||||
if selected_target not in self._get_writable_targets():
|
||||
raise ValueError("Unsupported Hardcover target")
|
||||
|
||||
state = self._fetch_book_target_state(book_id_int)
|
||||
status_ids_to_invalidate: set[int] = set()
|
||||
list_ids_to_invalidate: set[int] = set()
|
||||
|
||||
if selected_target.startswith(HARDCOVER_STATUS_PREFIX):
|
||||
status_id = self._parse_prefixed_int(selected_target, "status target")
|
||||
previous_status_id = state.status_id
|
||||
changed = self._set_status_target_state(book_id_int, status_id, selected, state)
|
||||
if changed:
|
||||
if previous_status_id is not None:
|
||||
status_ids_to_invalidate.add(previous_status_id)
|
||||
status_ids_to_invalidate.add(status_id)
|
||||
elif selected_target.startswith(HARDCOVER_LIST_ID_PREFIX):
|
||||
list_id = self._parse_prefixed_int(selected_target, "list target")
|
||||
changed = self._set_list_target_state(book_id_int, list_id, selected, state)
|
||||
if changed:
|
||||
list_ids_to_invalidate.add(list_id)
|
||||
else:
|
||||
raise ValueError("Unsupported Hardcover target")
|
||||
|
||||
if changed:
|
||||
self._invalidate_book_target_caches(
|
||||
connected_user_id=self._resolve_current_user_id(),
|
||||
status_ids=status_ids_to_invalidate,
|
||||
list_ids=list_ids_to_invalidate,
|
||||
)
|
||||
|
||||
return {"changed": changed}
|
||||
|
||||
@staticmethod
|
||||
def _unwrap_me_data(result: Optional[Dict]) -> Dict:
|
||||
"""Extract and validate the ``me`` payload from a GraphQL result."""
|
||||
if not isinstance(result, dict):
|
||||
raise RuntimeError("Hardcover could not load book targets")
|
||||
|
||||
me_data = result.get("me", {})
|
||||
if isinstance(me_data, list) and me_data:
|
||||
me_data = me_data[0]
|
||||
if not isinstance(me_data, dict):
|
||||
raise RuntimeError("Hardcover returned an invalid target payload")
|
||||
return me_data
|
||||
|
||||
def _fetch_book_target_state(self, book_id: int) -> HardcoverBookTargetState:
|
||||
"""Load current Hardcover membership state for a specific book."""
|
||||
result = self._execute_query(
|
||||
BOOK_TARGET_MEMBERSHIP_QUERY,
|
||||
{"bookId": book_id},
|
||||
raise_on_error=True,
|
||||
)
|
||||
me_data = self._unwrap_me_data(result)
|
||||
|
||||
user_book_id: Optional[int] = None
|
||||
status_id: Optional[int] = None
|
||||
user_books = me_data.get("user_books", [])
|
||||
if isinstance(user_books, list) and user_books:
|
||||
latest_user_book = user_books[0] if isinstance(user_books[0], dict) else {}
|
||||
user_book_id = coerce_int(latest_user_book.get("id"), 0) or None
|
||||
status_id = coerce_int(latest_user_book.get("status_id"), 0) or None
|
||||
|
||||
list_book_ids: Dict[int, int] = {}
|
||||
for user_list in me_data.get("lists", []):
|
||||
if not isinstance(user_list, dict):
|
||||
continue
|
||||
list_id = coerce_int(user_list.get("id"), 0)
|
||||
if list_id < 1:
|
||||
continue
|
||||
|
||||
list_books = user_list.get("list_books", [])
|
||||
if not isinstance(list_books, list) or not list_books:
|
||||
continue
|
||||
|
||||
list_book = list_books[0] if isinstance(list_books[0], dict) else {}
|
||||
list_book_id = coerce_int(list_book.get("id"), 0)
|
||||
if list_book_id > 0:
|
||||
list_book_ids[list_id] = list_book_id
|
||||
|
||||
return HardcoverBookTargetState(
|
||||
user_book_id=user_book_id,
|
||||
status_id=status_id,
|
||||
list_book_ids=list_book_ids,
|
||||
)
|
||||
|
||||
def _fetch_book_target_states_batch(
|
||||
self, book_ids: List[int],
|
||||
) -> Dict[int, HardcoverBookTargetState]:
|
||||
"""Load Hardcover membership state for multiple books in one query."""
|
||||
result = self._execute_query(
|
||||
BOOK_TARGET_MEMBERSHIP_BATCH_QUERY,
|
||||
{"bookIds": book_ids},
|
||||
raise_on_error=True,
|
||||
)
|
||||
me_data = self._unwrap_me_data(result)
|
||||
|
||||
# Group user_books by book_id (keep only the latest per book)
|
||||
user_book_by_book: Dict[int, Dict] = {}
|
||||
for ub in me_data.get("user_books", []):
|
||||
if not isinstance(ub, dict):
|
||||
continue
|
||||
bid = coerce_int(ub.get("book_id"), 0)
|
||||
if bid > 0 and bid not in user_book_by_book:
|
||||
user_book_by_book[bid] = ub
|
||||
|
||||
# Group list_book memberships by book_id
|
||||
list_book_ids_by_book: Dict[int, Dict[int, int]] = {}
|
||||
for user_list in me_data.get("lists", []):
|
||||
if not isinstance(user_list, dict):
|
||||
continue
|
||||
list_id = coerce_int(user_list.get("id"), 0)
|
||||
if list_id < 1:
|
||||
continue
|
||||
for lb in user_list.get("list_books", []):
|
||||
if not isinstance(lb, dict):
|
||||
continue
|
||||
bid = coerce_int(lb.get("book_id"), 0)
|
||||
lb_id = coerce_int(lb.get("id"), 0)
|
||||
if bid > 0 and lb_id > 0:
|
||||
list_book_ids_by_book.setdefault(bid, {})[list_id] = lb_id
|
||||
|
||||
states: Dict[int, HardcoverBookTargetState] = {}
|
||||
for bid in book_ids:
|
||||
ub = user_book_by_book.get(bid)
|
||||
states[bid] = HardcoverBookTargetState(
|
||||
user_book_id=coerce_int(ub.get("id"), 0) or None if ub else None,
|
||||
status_id=coerce_int(ub.get("status_id"), 0) or None if ub else None,
|
||||
list_book_ids=list_book_ids_by_book.get(bid, {}),
|
||||
)
|
||||
return states
|
||||
|
||||
def get_book_targets_batch(self, book_ids: List[str]) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Get writable Hardcover list/status targets for multiple books."""
|
||||
if not self.api_key or not book_ids:
|
||||
return {bid: [] for bid in book_ids}
|
||||
|
||||
int_ids = []
|
||||
id_map: Dict[int, str] = {}
|
||||
for bid in book_ids:
|
||||
int_id = coerce_int(bid, 0)
|
||||
if int_id > 0:
|
||||
int_ids.append(int_id)
|
||||
id_map[int_id] = bid
|
||||
|
||||
if not int_ids:
|
||||
return {bid: [] for bid in book_ids}
|
||||
|
||||
states = self._fetch_book_target_states_batch(int_ids)
|
||||
writable_options = [
|
||||
dict(option)
|
||||
for option in self.get_user_lists()
|
||||
if option.get("group") in HARDCOVER_WRITABLE_TARGET_GROUPS
|
||||
]
|
||||
|
||||
results: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for int_id, str_id in id_map.items():
|
||||
state = states.get(int_id, HardcoverBookTargetState(
|
||||
user_book_id=None, status_id=None, list_book_ids={},
|
||||
))
|
||||
options = [dict(opt) for opt in writable_options]
|
||||
for option in options:
|
||||
value = str(option.get("value") or "").strip()
|
||||
option["checked"] = self._is_target_checked(value, state)
|
||||
option["writable"] = True
|
||||
results[str_id] = options
|
||||
|
||||
# Fill in any book_ids that didn't parse as valid ints
|
||||
for bid in book_ids:
|
||||
if bid not in results:
|
||||
results[bid] = []
|
||||
|
||||
return results
|
||||
|
||||
def _get_writable_targets(self) -> set[str]:
|
||||
"""Return the set of writable Hardcover targets for the current user."""
|
||||
writable_targets: set[str] = set()
|
||||
for option in self.get_user_lists():
|
||||
value = str(option.get("value") or "").strip()
|
||||
if (
|
||||
option.get("group") in HARDCOVER_WRITABLE_TARGET_GROUPS
|
||||
and value
|
||||
and (
|
||||
value.startswith(HARDCOVER_STATUS_PREFIX)
|
||||
or value.startswith(HARDCOVER_LIST_ID_PREFIX)
|
||||
)
|
||||
):
|
||||
writable_targets.add(value)
|
||||
return writable_targets
|
||||
|
||||
def _is_target_checked(self, target: str, state: HardcoverBookTargetState) -> bool:
|
||||
"""Return whether a target is currently selected for the book."""
|
||||
if target.startswith(HARDCOVER_STATUS_PREFIX):
|
||||
return state.status_id == self._parse_prefixed_int(target)
|
||||
if target.startswith(HARDCOVER_LIST_ID_PREFIX):
|
||||
return self._parse_prefixed_int(target) in state.list_book_ids
|
||||
return False
|
||||
|
||||
def _set_status_target_state(
|
||||
self,
|
||||
book_id: int,
|
||||
status_id: int,
|
||||
selected: bool,
|
||||
state: HardcoverBookTargetState,
|
||||
) -> bool:
|
||||
"""Set whether the book belongs to a Hardcover status shelf."""
|
||||
if selected:
|
||||
if state.user_book_id is None:
|
||||
result = self._execute_query(
|
||||
INSERT_USER_BOOK_MUTATION,
|
||||
{"bookId": book_id, "statusId": status_id},
|
||||
raise_on_error=True,
|
||||
)
|
||||
self._check_mutation_result(result, "insert_user_book")
|
||||
return True
|
||||
|
||||
if state.status_id == status_id:
|
||||
return False
|
||||
|
||||
result = self._execute_query(
|
||||
UPDATE_USER_BOOK_MUTATION,
|
||||
{"userBookId": state.user_book_id, "statusId": status_id},
|
||||
raise_on_error=True,
|
||||
)
|
||||
self._check_mutation_result(result, "update_user_book")
|
||||
return True
|
||||
|
||||
if state.user_book_id is None or state.status_id != status_id:
|
||||
return False
|
||||
|
||||
result = self._execute_query(
|
||||
DELETE_USER_BOOK_MUTATION,
|
||||
{"userBookId": state.user_book_id},
|
||||
raise_on_error=True,
|
||||
)
|
||||
self._check_mutation_result(result, "delete_user_book", check_error=False)
|
||||
return True
|
||||
|
||||
def _set_list_target_state(
|
||||
self,
|
||||
book_id: int,
|
||||
list_id: int,
|
||||
selected: bool,
|
||||
state: HardcoverBookTargetState,
|
||||
) -> bool:
|
||||
"""Set whether the book belongs to a Hardcover list."""
|
||||
list_book_id = state.list_book_ids.get(list_id)
|
||||
|
||||
if selected:
|
||||
if list_book_id is not None:
|
||||
return False
|
||||
|
||||
result = self._execute_query(
|
||||
INSERT_LIST_BOOK_MUTATION,
|
||||
{"bookId": book_id, "listId": list_id},
|
||||
raise_on_error=True,
|
||||
)
|
||||
self._check_mutation_result(result, "insert_list_book")
|
||||
return True
|
||||
|
||||
if list_book_id is None:
|
||||
return False
|
||||
|
||||
result = self._execute_query(
|
||||
DELETE_LIST_BOOK_MUTATION,
|
||||
{"listBookId": list_book_id},
|
||||
raise_on_error=True,
|
||||
)
|
||||
self._check_mutation_result(result, "delete_list_book", check_error=False)
|
||||
return True
|
||||
|
||||
def _invalidate_book_target_caches(
|
||||
self,
|
||||
*,
|
||||
connected_user_id: Optional[str],
|
||||
status_ids: set[int],
|
||||
list_ids: set[int],
|
||||
) -> None:
|
||||
"""Invalidate caches affected by a target membership change."""
|
||||
metadata_cache = get_metadata_cache()
|
||||
|
||||
if connected_user_id:
|
||||
metadata_cache.invalidate(cache_key("hardcover:user_lists", connected_user_id))
|
||||
for status_id in status_ids:
|
||||
metadata_cache.invalidate_prefix(
|
||||
cache_key("hardcover:user_books:status", connected_user_id, status_id)
|
||||
)
|
||||
|
||||
for list_id in list_ids:
|
||||
metadata_cache.invalidate_prefix(cache_key("hardcover:list:id", list_id))
|
||||
|
||||
@staticmethod
|
||||
def _parse_prefixed_int(value: str, label: str = "target") -> int:
|
||||
"""Parse an integer from a colon-prefixed value like 'status:1' or 'id:42'."""
|
||||
try:
|
||||
return int(value.split(":", 1)[1])
|
||||
except (IndexError, ValueError) as exc:
|
||||
raise ValueError(f"Invalid Hardcover {label}") from exc
|
||||
|
||||
@staticmethod
|
||||
def _check_mutation_result(result: Any, key: str, *, check_error: bool = True) -> None:
|
||||
"""Raise if a Hardcover mutation failed.
|
||||
|
||||
When *check_error* is True (the default) the ``error`` field inside
|
||||
the payload is inspected and surfaced as a ``ValueError``. Pass
|
||||
``check_error=False`` for delete mutations that don't return an
|
||||
error field.
|
||||
"""
|
||||
payload = result.get(key, {}) if isinstance(result, dict) else {}
|
||||
if isinstance(payload, dict):
|
||||
if check_error:
|
||||
error_text = str(payload.get("error") or "").strip()
|
||||
if error_text:
|
||||
raise ValueError(error_text)
|
||||
if payload.get("id") is not None:
|
||||
return
|
||||
raise RuntimeError("Hardcover could not complete this action")
|
||||
|
||||
def search(self, options: MetadataSearchOptions) -> List[BookMetadata]:
|
||||
"""Search for books using Hardcover's search API."""
|
||||
return self.search_paginated(options).books
|
||||
@@ -1474,16 +1982,16 @@ class HardcoverProvider(MetadataProvider):
|
||||
if list_value_from_field:
|
||||
if list_value_from_field.startswith(HARDCOVER_STATUS_PREFIX):
|
||||
try:
|
||||
status_id = int(list_value_from_field.split(":", 1)[1])
|
||||
status_id = self._parse_prefixed_int(list_value_from_field, "status")
|
||||
return self._fetch_current_user_books_by_status(status_id, options.page, options.limit)
|
||||
except (IndexError, ValueError):
|
||||
except ValueError:
|
||||
logger.debug(f"Invalid Hardcover status field value: {list_value_from_field}")
|
||||
return SearchResult(books=[], page=options.page, total_found=0, has_more=False)
|
||||
if list_value_from_field.startswith("id:"):
|
||||
if list_value_from_field.startswith(HARDCOVER_LIST_ID_PREFIX):
|
||||
try:
|
||||
list_id = int(list_value_from_field.split(":", 1)[1])
|
||||
list_id = self._parse_prefixed_int(list_value_from_field, "list")
|
||||
return self._fetch_list_books_by_id(list_id, options.page, options.limit)
|
||||
except (IndexError, ValueError):
|
||||
except ValueError:
|
||||
logger.debug(f"Invalid hardcover_list field value: {list_value_from_field}")
|
||||
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)
|
||||
@@ -1755,7 +2263,13 @@ class HardcoverProvider(MetadataProvider):
|
||||
logger.error(f"Hardcover ISBN search error: {e}")
|
||||
return None
|
||||
|
||||
def _execute_query(self, query: str, variables: Dict[str, Any]) -> Optional[Dict]:
|
||||
def _execute_query(
|
||||
self,
|
||||
query: str,
|
||||
variables: Dict[str, Any],
|
||||
*,
|
||||
raise_on_error: bool = False,
|
||||
) -> Optional[Dict]:
|
||||
"""Execute a GraphQL query and return data or None on error."""
|
||||
try:
|
||||
response = self.session.post(
|
||||
@@ -1770,21 +2284,39 @@ class HardcoverProvider(MetadataProvider):
|
||||
|
||||
if "errors" in data:
|
||||
logger.error(f"GraphQL errors: {data['errors']}")
|
||||
if raise_on_error:
|
||||
message = _extract_graphql_error_message(data) or "Hardcover rejected this request"
|
||||
raise HardcoverGraphQLError(message)
|
||||
return None
|
||||
|
||||
return data.get("data")
|
||||
|
||||
except requests.Timeout:
|
||||
logger.warning("Hardcover API request timed out")
|
||||
if raise_on_error:
|
||||
raise RuntimeError("Hardcover API request timed out")
|
||||
return None
|
||||
except requests.HTTPError as e:
|
||||
if e.response.status_code == 401:
|
||||
logger.error("Hardcover API key is invalid")
|
||||
if raise_on_error:
|
||||
raise RuntimeError("Hardcover API key is invalid")
|
||||
else:
|
||||
logger.error(f"Hardcover API HTTP error: {e}")
|
||||
if raise_on_error:
|
||||
raise RuntimeError(f"Hardcover API HTTP error: {e}")
|
||||
return None
|
||||
except HardcoverGraphQLError:
|
||||
raise
|
||||
except ValueError as e:
|
||||
logger.error(f"Hardcover API returned invalid JSON: {e}")
|
||||
if raise_on_error:
|
||||
raise RuntimeError("Hardcover API returned an invalid response") from e
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Hardcover API request failed: {e}")
|
||||
if raise_on_error:
|
||||
raise RuntimeError("Hardcover API request failed") from e
|
||||
return None
|
||||
|
||||
def _parse_search_result(self, item: Dict) -> Optional[BookMetadata]:
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
createRequest,
|
||||
isApiResponseError,
|
||||
updateSelfUser,
|
||||
setBookTargetState,
|
||||
type DownloadReleasePayload,
|
||||
} from './services/api';
|
||||
import { useToast } from './hooks/useToast';
|
||||
@@ -76,6 +77,8 @@ import {
|
||||
toContentType,
|
||||
} from './utils/requestPayload';
|
||||
import { bookFromRequestData } from './utils/requestFulfil';
|
||||
import { emitBookTargetChange, onBookTargetChange } from './utils/bookTargetEvents';
|
||||
import { bookSupportsTargets } from './utils/bookTargetLoader';
|
||||
import { policyTrace } from './utils/policyTrace';
|
||||
import { SearchModeProvider } from './contexts/SearchModeContext';
|
||||
import { useSocket } from './contexts/SocketContext';
|
||||
@@ -384,6 +387,7 @@ function App() {
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
totalFound,
|
||||
resultsSourceUrl,
|
||||
} = useSearch({
|
||||
showToast,
|
||||
setIsAuthenticated,
|
||||
@@ -392,6 +396,19 @@ function App() {
|
||||
contentType,
|
||||
});
|
||||
|
||||
// When a book is removed from the Hardcover list currently being browsed, remove it from results
|
||||
const searchFieldValuesRef = useRef(searchFieldValues);
|
||||
searchFieldValuesRef.current = searchFieldValues;
|
||||
|
||||
useEffect(() => {
|
||||
return onBookTargetChange((event) => {
|
||||
if (event.selected) return;
|
||||
const activeListValue = searchFieldValuesRef.current.hardcover_list;
|
||||
if (!activeListValue || String(activeListValue) !== event.target) return;
|
||||
setBooks((prev) => prev.filter((book) => book.provider_id !== event.bookId));
|
||||
});
|
||||
}, [setBooks]);
|
||||
|
||||
const [pendingRequestPayload, setPendingRequestPayload] = useState<CreateRequestPayload | null>(null);
|
||||
const [actingAsUser, setActingAsUser] = useState<ActingAsUserSelection | null>(null);
|
||||
const [pendingOnBehalfDownload, setPendingOnBehalfDownload] = useState<PendingOnBehalfDownload | null>(null);
|
||||
@@ -1072,6 +1089,30 @@ function App() {
|
||||
[]
|
||||
);
|
||||
|
||||
// When downloading a book while browsing a Hardcover list, automatically
|
||||
// remove it from that list (fire-and-forget).
|
||||
const searchFieldLabelsRef = useRef(searchFieldLabels);
|
||||
searchFieldLabelsRef.current = searchFieldLabels;
|
||||
|
||||
const removeBookFromActiveList = useCallback((book: Book) => {
|
||||
if (!bookSupportsTargets(book)) return;
|
||||
const activeList = searchFieldValuesRef.current.hardcover_list;
|
||||
if (!activeList) return;
|
||||
const target = String(activeList);
|
||||
void setBookTargetState(book.provider!, book.provider_id!, target, false).then((result) => {
|
||||
if (result.changed) {
|
||||
emitBookTargetChange({
|
||||
provider: book.provider!,
|
||||
bookId: book.provider_id!,
|
||||
target,
|
||||
selected: false,
|
||||
});
|
||||
const listName = searchFieldLabelsRef.current['hardcover_list'];
|
||||
showToast(`Removed from ${listName || 'list'}`, 'info');
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [showToast]);
|
||||
|
||||
const executeBookDownload = useCallback(
|
||||
async (book: Book, onBehalfOfUserId?: number): Promise<void> => {
|
||||
const source = getBrowseSource(book);
|
||||
@@ -1079,6 +1120,7 @@ function App() {
|
||||
try {
|
||||
await downloadRelease(buildReleaseDataFromDirectBook(book), onBehalfOfUserId);
|
||||
await fetchStatus();
|
||||
removeBookFromActiveList(book);
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
if (isPolicyGuardError(error)) {
|
||||
@@ -1103,7 +1145,7 @@ function App() {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[fetchStatus, openRequestConfirmation, refreshRequestPolicy, showToast]
|
||||
[fetchStatus, openRequestConfirmation, refreshRequestPolicy, removeBookFromActiveList, showToast]
|
||||
);
|
||||
|
||||
const executeReleaseDownload = useCallback(
|
||||
@@ -1120,6 +1162,7 @@ function App() {
|
||||
onBehalfOfUserId
|
||||
);
|
||||
await fetchStatus();
|
||||
removeBookFromActiveList(book);
|
||||
} catch (error) {
|
||||
console.error('Release download failed:', error);
|
||||
if (isPolicyGuardError(error)) {
|
||||
@@ -1168,7 +1211,7 @@ function App() {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[buildReleaseDownloadPayload, fetchStatus, openRequestConfirmation, refreshRequestPolicy, showToast, trackRelease]
|
||||
[buildReleaseDownloadPayload, fetchStatus, openRequestConfirmation, refreshRequestPolicy, removeBookFromActiveList, showToast, trackRelease]
|
||||
);
|
||||
|
||||
const handleConfirmOnBehalfDownload = useCallback(async (): Promise<boolean> => {
|
||||
@@ -1657,6 +1700,12 @@ function App() {
|
||||
&& activeQueryValue !== ''
|
||||
&& activeQueryValue !== false,
|
||||
);
|
||||
const activeQueryUsesListBrowse = Boolean(
|
||||
activeQueryOption?.source === 'provider-field'
|
||||
&& activeQueryOption.field?.type === 'DynamicSelectSearchField'
|
||||
&& activeQueryValue !== ''
|
||||
&& activeQueryValue !== false,
|
||||
);
|
||||
const effectiveMetadataSort = getEffectiveMetadataSort({
|
||||
currentSort: advancedFilters.sort,
|
||||
defaultSort: resolvedMetadataDefaultSort,
|
||||
@@ -2086,6 +2135,7 @@ function App() {
|
||||
getButtonState={getDirectActionButtonState}
|
||||
getUniversalButtonState={getUniversalActionButtonState}
|
||||
sortValue={visibleResultsSort}
|
||||
showSortControl={!activeQueryUsesSeriesBrowse && !activeQueryUsesListBrowse}
|
||||
onSortChange={(value) => {
|
||||
const request = buildCurrentSearchRequest(value);
|
||||
const shouldPersistAppliedSort = !(
|
||||
@@ -2109,6 +2159,8 @@ function App() {
|
||||
isLoadingMore={isLoadingMore}
|
||||
onLoadMore={() => loadMore(config, effectiveSearchMode)}
|
||||
totalFound={totalFound}
|
||||
onShowToast={showToast}
|
||||
resultsSourceUrl={resultsSourceUrl}
|
||||
/>
|
||||
|
||||
{selectedBook && (
|
||||
@@ -2116,6 +2168,7 @@ function App() {
|
||||
book={selectedBook}
|
||||
onClose={() => setSelectedBook(null)}
|
||||
onDownload={handleDownload}
|
||||
onShowToast={showToast}
|
||||
onFindDownloads={(book) => {
|
||||
setSelectedBook(null);
|
||||
void handleGetReleases(book);
|
||||
@@ -2152,6 +2205,7 @@ function App() {
|
||||
onSearchSeries={isBrowseFulfilMode || !canSearchSeriesForBook(activeReleaseBook) ? undefined : handleSearchSeries}
|
||||
defaultShowManualQuery={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'}
|
||||
isRequestMode={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'}
|
||||
onShowToast={showToast}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { DropdownList, type DropdownListOption } from './DropdownList';
|
||||
import {
|
||||
setBookTargetState,
|
||||
type BookTargetOption,
|
||||
} from '../services/api';
|
||||
import { loadBookTargets } from '../utils/bookTargetLoader';
|
||||
import { emitBookTargetChange, onBookTargetChange } from '../utils/bookTargetEvents';
|
||||
|
||||
interface BookTargetDropdownProps {
|
||||
provider: string;
|
||||
bookId: string;
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
widthClassName?: string;
|
||||
variant?: 'default' | 'pill' | 'icon';
|
||||
align?: 'left' | 'right' | 'auto';
|
||||
className?: string;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
const stripCountSuffix = (label: string): string => {
|
||||
return label.replace(/\s+\(\d+\)\s*$/, '');
|
||||
};
|
||||
|
||||
const BookmarkIcon = ({ className = 'h-4 w-4' }: { className?: string }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
className={`${className} flex-shrink-0`}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const renderSummary = (selectedOptions: DropdownListOption[]) => {
|
||||
const count = selectedOptions.length;
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 whitespace-nowrap">
|
||||
<BookmarkIcon />
|
||||
<span>Hardcover Lists{count > 0 ? ` (${count})` : ''}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const updateOptionChecked = (
|
||||
prev: BookTargetOption[],
|
||||
target: string,
|
||||
checked: boolean,
|
||||
): BookTargetOption[] =>
|
||||
prev.map((option) =>
|
||||
option.value === target ? { ...option, checked } : option,
|
||||
);
|
||||
|
||||
export const BookTargetDropdown = ({
|
||||
provider,
|
||||
bookId,
|
||||
onShowToast,
|
||||
widthClassName = 'w-full sm:w-56',
|
||||
variant = 'default',
|
||||
align = 'auto',
|
||||
className,
|
||||
onOpenChange,
|
||||
}: BookTargetDropdownProps) => {
|
||||
const [options, setOptions] = useState<BookTargetOption[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [pendingTargets, setPendingTargets] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const loaded = await loadBookTargets(provider, bookId);
|
||||
if (!isMounted) return;
|
||||
setOptions(loaded);
|
||||
setLoadError(null);
|
||||
} catch (error) {
|
||||
if (!isMounted) return;
|
||||
const message = error instanceof Error ? error.message : 'Failed to load Hardcover lists';
|
||||
setOptions([]);
|
||||
setLoadError(message);
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setLoadError(null);
|
||||
setPendingTargets(new Set());
|
||||
setIsLoading(true);
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [provider, bookId]);
|
||||
|
||||
// Sync from changes made by other BookTargetDropdown instances for the same book
|
||||
useEffect(() => {
|
||||
return onBookTargetChange((event) => {
|
||||
if (event.provider !== provider || event.bookId !== bookId) return;
|
||||
setOptions((prev) => updateOptionChecked(prev, event.target, event.selected));
|
||||
});
|
||||
}, [provider, bookId]);
|
||||
|
||||
const selectedValues = useMemo(
|
||||
() => options.filter((option) => option.checked).map((option) => option.value),
|
||||
[options],
|
||||
);
|
||||
|
||||
const dropdownOptions = useMemo<DropdownListOption[]>(() => {
|
||||
if (isLoading) {
|
||||
return [{ value: '__loading', label: 'Loading…', disabled: true }];
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return [{ value: '__error', label: loadError, disabled: true }];
|
||||
}
|
||||
|
||||
if (options.length === 0) {
|
||||
return [{ value: '__empty', label: 'No writable Hardcover targets', disabled: true }];
|
||||
}
|
||||
|
||||
return options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
disabled: !option.writable || pendingTargets.has(option.value),
|
||||
}));
|
||||
}, [isLoading, loadError, options, pendingTargets]);
|
||||
|
||||
const handleChange = useCallback((nextValue: string[] | string) => {
|
||||
if (!Array.isArray(nextValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSelected = new Set(nextValue);
|
||||
const currentSelected = new Set(selectedValues);
|
||||
const toggledTarget =
|
||||
nextValue.find((value) => !currentSelected.has(value))
|
||||
?? selectedValues.find((value) => !nextSelected.has(value));
|
||||
|
||||
if (!toggledTarget || pendingTargets.has(toggledTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = nextSelected.has(toggledTarget);
|
||||
const toggledOption = options.find((option) => option.value === toggledTarget);
|
||||
if (!toggledOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingTargets((prev) => new Set(prev).add(toggledTarget));
|
||||
setOptions((prev) => updateOptionChecked(prev, toggledTarget, selected));
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await setBookTargetState(provider, bookId, toggledTarget, selected);
|
||||
setOptions((prev) => updateOptionChecked(prev, toggledTarget, result.selected));
|
||||
|
||||
if (result.changed) {
|
||||
emitBookTargetChange({
|
||||
provider,
|
||||
bookId,
|
||||
target: toggledTarget,
|
||||
selected: result.selected,
|
||||
});
|
||||
const label = stripCountSuffix(toggledOption.label);
|
||||
onShowToast?.(
|
||||
`${result.selected ? 'Added to' : 'Removed from'} ${label}`,
|
||||
'success',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
setOptions((prev) => updateOptionChecked(prev, toggledTarget, !selected));
|
||||
const message = error instanceof Error ? error.message : 'Failed to update Hardcover list';
|
||||
onShowToast?.(message, 'error');
|
||||
} finally {
|
||||
setPendingTargets((prev) => {
|
||||
const nextPending = new Set(prev);
|
||||
nextPending.delete(toggledTarget);
|
||||
return nextPending;
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, [bookId, onShowToast, options, pendingTargets, provider, selectedValues]);
|
||||
|
||||
const customTrigger = variant === 'pill'
|
||||
? ({ toggle }: { isOpen: boolean; toggle: () => void }) => {
|
||||
const count = selectedValues.length;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full transition-colors text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 hover:bg-emerald-100 dark:hover:bg-emerald-900/40 focus:outline-none`}
|
||||
>
|
||||
<BookmarkIcon className="w-3 h-3" />
|
||||
Hardcover Lists{count > 0 ? ` (${count})` : ''}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
: variant === 'icon'
|
||||
? ({ toggle }: { isOpen: boolean; toggle: () => void }) => {
|
||||
const count = selectedValues.length;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); toggle(); }}
|
||||
className={`flex items-center justify-center rounded-full transition-all duration-200 focus:outline-none ${className ?? 'p-1.5 sm:p-2 text-gray-600 dark:text-gray-200 hover-action'}`}
|
||||
aria-label="Hardcover Lists"
|
||||
title={count > 0 ? `On ${count} Hardcover list${count > 1 ? 's' : ''}` : 'Hardcover Lists'}
|
||||
>
|
||||
<BookmarkIcon className={`w-4 h-4 sm:w-5 sm:h-5 ${count > 0 ? 'fill-current' : ''}`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<DropdownList
|
||||
options={dropdownOptions}
|
||||
value={selectedValues}
|
||||
onChange={handleChange}
|
||||
placeholder={isLoading ? 'Loading…' : 'Lists & Want to Read'}
|
||||
widthClassName={variant !== 'default' ? 'w-auto' : widthClassName}
|
||||
buttonClassName={variant !== 'default' ? '' : 'py-1.5 leading-none'}
|
||||
panelClassName={variant !== 'default' ? 'w-56' : undefined}
|
||||
align={align}
|
||||
multiple
|
||||
showCheckboxes
|
||||
keepOpenOnSelect
|
||||
summaryFormatter={(selectedOptions) => renderSummary(selectedOptions)}
|
||||
renderTrigger={customTrigger}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,8 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Book, ButtonStateInfo, isMetadataBook } from '../types';
|
||||
import { isUserCancelledError } from '../utils/errors';
|
||||
import { BookTargetDropdown } from './BookTargetDropdown';
|
||||
import { bookSupportsTargets } from '../utils/bookTargetLoader';
|
||||
|
||||
interface DetailsModalProps {
|
||||
book: Book | null;
|
||||
@@ -10,9 +12,18 @@ interface DetailsModalProps {
|
||||
onFindDownloads?: (book: Book) => void; // For Universal mode
|
||||
onSearchSeries?: (seriesName: string, seriesId?: string) => void; // Callback to search for series
|
||||
buttonState: ButtonStateInfo;
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
}
|
||||
|
||||
export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSearchSeries, buttonState }: DetailsModalProps) => {
|
||||
export const DetailsModal = ({
|
||||
book,
|
||||
onClose,
|
||||
onDownload,
|
||||
onFindDownloads,
|
||||
onSearchSeries,
|
||||
buttonState,
|
||||
onShowToast,
|
||||
}: DetailsModalProps) => {
|
||||
const [isQueuing, setIsQueuing] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
@@ -56,6 +67,8 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
|
||||
}
|
||||
}, [book]);
|
||||
|
||||
const hasBookTargets = Boolean(book && isMetadataBook(book) && bookSupportsTargets(book));
|
||||
|
||||
if (!book && !isClosing) return null;
|
||||
if (!book) return null;
|
||||
|
||||
@@ -319,14 +332,14 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
|
||||
className="border-t border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)] px-5 py-4"
|
||||
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
{/* Source link - shown for both Universal and Direct Download modes */}
|
||||
{book.source_url && (
|
||||
<a
|
||||
href={book.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--border-muted)] bg-[var(--bg)] px-3 py-2 text-xs font-medium text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-900 dark:text-gray-400 dark:hover:border-gray-500 dark:hover:text-gray-200"
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
View on {isMetadata ? providerDisplay : "Source"}
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -339,22 +352,32 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{/* Action button - mirrors search result action state/flow */}
|
||||
<button
|
||||
onClick={isMetadata ? () => onFindDownloads?.(book) : handleDownload}
|
||||
disabled={isMetadata ? buttonState.state === 'blocked' : buttonState.state !== 'download'}
|
||||
className={`ml-auto rounded-full px-6 py-2.5 text-sm font-medium text-white transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isMetadata
|
||||
? buttonState.state === 'blocked'
|
||||
? 'bg-gray-500 focus:ring-gray-400'
|
||||
: 'bg-emerald-600 hover:bg-emerald-700 focus:ring-emerald-500'
|
||||
: buttonState.state === 'blocked'
|
||||
? 'bg-gray-500 focus:ring-gray-400'
|
||||
: 'bg-sky-700 hover:bg-sky-800 focus:ring-sky-500'
|
||||
}`}
|
||||
>
|
||||
{isMetadata ? metadataActionText : buttonState.text}
|
||||
</button>
|
||||
<div className="flex w-full flex-col gap-2 sm:ml-auto sm:w-auto sm:flex-row sm:items-center">
|
||||
{hasBookTargets && book.provider_id && (
|
||||
<BookTargetDropdown
|
||||
provider={book.provider!}
|
||||
bookId={book.provider_id}
|
||||
onShowToast={onShowToast}
|
||||
widthClassName="w-full sm:w-56"
|
||||
/>
|
||||
)}
|
||||
{/* Action button - mirrors search result action state/flow */}
|
||||
<button
|
||||
onClick={isMetadata ? () => onFindDownloads?.(book) : handleDownload}
|
||||
disabled={isMetadata ? buttonState.state === 'blocked' : buttonState.state !== 'download'}
|
||||
className={`rounded-lg px-5 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isMetadata
|
||||
? buttonState.state === 'blocked'
|
||||
? 'bg-gray-500'
|
||||
: 'bg-emerald-600 hover:bg-emerald-700'
|
||||
: buttonState.state === 'blocked'
|
||||
? 'bg-gray-500'
|
||||
: 'bg-sky-700 hover:bg-sky-800'
|
||||
}`}
|
||||
>
|
||||
{isMetadata ? metadataActionText : buttonState.text}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ function getScrollableAncestor(element: HTMLElement | null): HTMLElement | null
|
||||
while (current) {
|
||||
const style = getComputedStyle(current);
|
||||
const overflowY = style.overflowY;
|
||||
if (overflowY === 'auto' || overflowY === 'scroll') {
|
||||
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'hidden') {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
@@ -41,7 +41,7 @@ interface DropdownProps {
|
||||
label?: string;
|
||||
summary?: ReactNode;
|
||||
children: (helpers: { close: () => void }) => ReactNode;
|
||||
align?: 'left' | 'right';
|
||||
align?: 'left' | 'right' | 'auto';
|
||||
widthClassName?: string;
|
||||
buttonClassName?: string;
|
||||
panelClassName?: string;
|
||||
@@ -50,6 +50,7 @@ interface DropdownProps {
|
||||
/** Disable max-height and overflow scrolling (for panels with nested dropdowns) */
|
||||
noScrollLimit?: boolean;
|
||||
triggerChrome?: 'default' | 'minimal';
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const Dropdown = ({
|
||||
@@ -64,18 +65,27 @@ export const Dropdown = ({
|
||||
renderTrigger,
|
||||
noScrollLimit = false,
|
||||
triggerChrome = 'default',
|
||||
onOpenChange,
|
||||
}: DropdownProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [panelDirection, setPanelDirection] = useState<'down' | 'up'>('down');
|
||||
const [resolvedAlign, setResolvedAlign] = useState<'left' | 'right'>(align === 'right' ? 'right' : 'left');
|
||||
|
||||
const toggleOpen = () => {
|
||||
if (disabled) return;
|
||||
setIsOpen(prev => !prev);
|
||||
setIsOpen(prev => {
|
||||
const next = !prev;
|
||||
onOpenChange?.(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const close = () => setIsOpen(false);
|
||||
const close = () => {
|
||||
setIsOpen(false);
|
||||
onOpenChange?.(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -124,7 +134,24 @@ export const Dropdown = ({
|
||||
const shouldOpenUp = spaceBelow < panelHeight && spaceAbove >= panelHeight;
|
||||
|
||||
setPanelDirection(shouldOpenUp ? 'up' : 'down');
|
||||
}, []);
|
||||
|
||||
// Auto horizontal alignment: check if panel overflows viewport right/left
|
||||
if (align === 'auto') {
|
||||
const panelWidth = panelRef.current.offsetWidth || panelRef.current.scrollWidth;
|
||||
const overflowsRight = rect.left + panelWidth > window.innerWidth - 8;
|
||||
const overflowsLeft = rect.right - panelWidth < 8;
|
||||
|
||||
if (overflowsRight && !overflowsLeft) {
|
||||
setResolvedAlign('right');
|
||||
} else if (overflowsLeft && !overflowsRight) {
|
||||
setResolvedAlign('left');
|
||||
} else {
|
||||
setResolvedAlign('left');
|
||||
}
|
||||
} else {
|
||||
setResolvedAlign(align === 'right' ? 'right' : 'left');
|
||||
}
|
||||
}, [align]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -192,7 +219,7 @@ export const Dropdown = ({
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`absolute ${align === 'right' ? 'right-0' : 'left-0'} ${
|
||||
className={`absolute ${resolvedAlign === 'right' ? 'right-0' : 'left-0'} ${
|
||||
panelDirection === 'down'
|
||||
? renderTrigger ? 'mt-2' : ''
|
||||
: renderTrigger ? 'bottom-full mb-2' : 'bottom-full'
|
||||
|
||||
@@ -17,12 +17,15 @@ interface DropdownListProps {
|
||||
showCheckboxes?: boolean;
|
||||
value: string[] | string | null | undefined;
|
||||
onChange: (value: string[] | string) => void;
|
||||
align?: 'left' | 'right';
|
||||
align?: 'left' | 'right' | 'auto';
|
||||
widthClassName?: string;
|
||||
buttonClassName?: string;
|
||||
panelClassName?: string;
|
||||
summaryFormatter?: (selected: DropdownListOption[], placeholder: string) => ReactNode;
|
||||
keepOpenOnSelect?: boolean;
|
||||
triggerChrome?: 'default' | 'minimal';
|
||||
renderTrigger?: (props: { isOpen: boolean; toggle: () => void }) => ReactNode;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const DropdownList = ({
|
||||
@@ -36,9 +39,12 @@ export const DropdownList = ({
|
||||
align,
|
||||
widthClassName,
|
||||
buttonClassName,
|
||||
panelClassName,
|
||||
summaryFormatter,
|
||||
keepOpenOnSelect,
|
||||
triggerChrome = 'default',
|
||||
renderTrigger,
|
||||
onOpenChange,
|
||||
}: DropdownListProps) => {
|
||||
const selectedValues = normalizeValue(value, multiple);
|
||||
const selectedOptions = options.filter(opt => selectedValues.includes(opt.value));
|
||||
@@ -104,7 +110,10 @@ export const DropdownList = ({
|
||||
align={align}
|
||||
widthClassName={widthClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
panelClassName={panelClassName}
|
||||
triggerChrome={triggerChrome}
|
||||
renderTrigger={renderTrigger}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div role="listbox" aria-multiselectable={multiple}>
|
||||
|
||||
@@ -20,6 +20,8 @@ import { useSocket } from '../contexts/SocketContext';
|
||||
import { Dropdown } from './Dropdown';
|
||||
import { DropdownList } from './DropdownList';
|
||||
import { BookDownloadButton } from './BookDownloadButton';
|
||||
import { BookTargetDropdown } from './BookTargetDropdown';
|
||||
import { bookSupportsTargets } from '../utils/bookTargetLoader';
|
||||
import { ReleaseCell } from './ReleaseCell';
|
||||
import { getColorStyleFromHint } from '../utils/colorMaps';
|
||||
import { getNestedValue } from '../utils/objectHelpers';
|
||||
@@ -95,6 +97,7 @@ interface ReleaseModalProps {
|
||||
onSearchSeries?: (seriesName: string, seriesId?: string) => void; // Callback to search for series
|
||||
defaultShowManualQuery?: boolean;
|
||||
isRequestMode?: boolean;
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -529,6 +532,7 @@ export const ReleaseModal = ({
|
||||
onSearchSeries,
|
||||
defaultShowManualQuery = false,
|
||||
isRequestMode = false,
|
||||
onShowToast,
|
||||
}: ReleaseModalProps) => {
|
||||
// Use audiobook formats when in audiobook mode
|
||||
const effectiveFormats = contentType === 'audiobook' && supportedAudiobookFormats.length > 0
|
||||
@@ -1491,6 +1495,14 @@ export const ReleaseModal = ({
|
||||
{isRequestingBook ? 'Adding...' : 'Add to requests'}
|
||||
</button>
|
||||
)}
|
||||
{bookSupportsTargets(book) && (
|
||||
<BookTargetDropdown
|
||||
provider={book.provider!}
|
||||
bookId={book.provider_id!}
|
||||
onShowToast={onShowToast}
|
||||
variant="pill"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,11 +25,14 @@ interface ResultsSectionProps {
|
||||
sortValue: string;
|
||||
onSortChange: (value: string) => void;
|
||||
metadataSortOptions?: SortOption[];
|
||||
showSortControl?: boolean;
|
||||
// Pagination (universal mode)
|
||||
hasMore?: boolean;
|
||||
isLoadingMore?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
totalFound?: number;
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
resultsSourceUrl?: string;
|
||||
}
|
||||
|
||||
export const ResultsSection = ({
|
||||
@@ -43,10 +46,13 @@ export const ResultsSection = ({
|
||||
sortValue,
|
||||
onSortChange,
|
||||
metadataSortOptions,
|
||||
showSortControl = true,
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
onLoadMore,
|
||||
totalFound,
|
||||
onShowToast,
|
||||
resultsSourceUrl,
|
||||
}: ResultsSectionProps) => {
|
||||
const { searchMode } = useSearchMode();
|
||||
const [viewMode, setViewMode] = useState<'card' | 'compact' | 'list'>(() => {
|
||||
@@ -86,10 +92,29 @@ export const ResultsSection = ({
|
||||
return (
|
||||
<section id="results-section" className="mb-4 sm:mb-8 w-full">
|
||||
<div className="flex items-center justify-between mb-2 sm:mb-3 relative z-10">
|
||||
<SortControl value={sortValue} onChange={onSortChange} metadataSortOptions={metadataSortOptions} />
|
||||
|
||||
{showSortControl ? (
|
||||
<SortControl value={sortValue} onChange={onSortChange} metadataSortOptions={metadataSortOptions} />
|
||||
) : resultsSourceUrl ? (
|
||||
<a
|
||||
href={resultsSourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200 animate-pop-up"
|
||||
>
|
||||
View list on Hardcover
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{/* View toggle buttons - Desktop: show all 3, Mobile: show Compact and List only */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{isDesktop && (
|
||||
<button
|
||||
onClick={() => setViewMode('card')}
|
||||
@@ -175,7 +200,7 @@ export const ResultsSection = ({
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === 'list' ? (
|
||||
<ListView books={books} onDetails={onDetails} onDownload={onDownload} onGetReleases={onGetReleases} getButtonState={getButtonState} getUniversalButtonState={getUniversalButtonState} showSeriesPosition={sortValue === 'series_order'} />
|
||||
<ListView books={books} onDetails={onDetails} onDownload={onDownload} onGetReleases={onGetReleases} getButtonState={getButtonState} getUniversalButtonState={getUniversalButtonState} showSeriesPosition={sortValue === 'series_order'} onShowToast={onShowToast} />
|
||||
) : (
|
||||
<div
|
||||
id="results-grid"
|
||||
@@ -199,6 +224,7 @@ export const ResultsSection = ({
|
||||
buttonState={buttonState}
|
||||
animationDelay={animationDelay}
|
||||
showSeriesPosition={sortValue === 'series_order'}
|
||||
onShowToast={onShowToast}
|
||||
/>
|
||||
) : (
|
||||
<CompactView
|
||||
@@ -211,6 +237,7 @@ export const ResultsSection = ({
|
||||
showDetailsButton={!isDesktop}
|
||||
animationDelay={animationDelay}
|
||||
showSeriesPosition={sortValue === 'series_order'}
|
||||
onShowToast={onShowToast}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -187,6 +187,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
const selectTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const selectPanelRef = useRef<HTMLDivElement>(null);
|
||||
const autocompletePanelRef = useRef<HTMLDivElement>(null);
|
||||
const selectorHoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const deferredTextInputValue = useDeferredValue(textInputValue);
|
||||
|
||||
const hasMultipleContentTypes = !allowedContentTypes || allowedContentTypes.length !== 1;
|
||||
@@ -210,9 +211,22 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
useDismiss(isSelectOpen, [selectPanelRef, selectTriggerRef], () => setIsSelectOpen(false));
|
||||
useDismiss(isAutocompleteOpen, [autocompletePanelRef, inputRef], () => setIsAutocompleteOpen(false));
|
||||
|
||||
// Close select dropdown when active field changes
|
||||
// Clean up hover timeout on unmount
|
||||
useEffect(() => {
|
||||
setIsSelectOpen(false);
|
||||
return () => {
|
||||
if (selectorHoverTimeout.current) clearTimeout(selectorHoverTimeout.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-open select dropdown only when transitioning into a select field
|
||||
const prevFieldKeyRef = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
const fieldKey = activeQueryField?.key;
|
||||
const isSelect = activeQueryField?.type === 'SelectSearchField' || activeQueryField?.type === 'DynamicSelectSearchField';
|
||||
const fieldChanged = fieldKey !== prevFieldKeyRef.current;
|
||||
prevFieldKeyRef.current = fieldKey;
|
||||
|
||||
setIsSelectOpen(fieldChanged && isSelect);
|
||||
setIsAutocompleteOpen(false);
|
||||
setAutocompleteOptions([]);
|
||||
}, [activeQueryField?.key, activeQueryField?.type]);
|
||||
@@ -434,6 +448,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
onFocus={() => {
|
||||
if (autocompleteEndpoint && textInputValue.trim().length >= autocompleteMinQueryLength) {
|
||||
setIsAutocompleteOpen(true);
|
||||
setIsSelectorOpen(false);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -496,6 +511,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
if (!disabled && !isDynamicLoading) {
|
||||
setIsSelectOpen((prev) => !prev);
|
||||
setIsSelectorOpen(false);
|
||||
setIsAutocompleteOpen(false);
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
@@ -559,10 +575,28 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
}}
|
||||
>
|
||||
{showQueryTargetSelector && (
|
||||
<div className="relative flex-shrink-0 flex self-stretch" ref={selectorRef}>
|
||||
<div
|
||||
className="relative flex-shrink-0 flex self-stretch"
|
||||
ref={selectorRef}
|
||||
onMouseEnter={() => {
|
||||
if (selectorHoverTimeout.current) {
|
||||
clearTimeout(selectorHoverTimeout.current);
|
||||
selectorHoverTimeout.current = null;
|
||||
}
|
||||
setIsSelectorOpen(true);
|
||||
setIsSelectOpen(false);
|
||||
setIsAutocompleteOpen(false);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
selectorHoverTimeout.current = setTimeout(() => {
|
||||
setIsSelectorOpen(false);
|
||||
selectorHoverTimeout.current = null;
|
||||
}, 150);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setIsSelectorOpen((prev) => !prev); setIsSelectOpen(false); }}
|
||||
onClick={() => { setIsSelectorOpen((prev) => !prev); setIsSelectOpen(false); setIsAutocompleteOpen(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'} by ${activeTarget?.label ?? 'general'}. Click to change.`}
|
||||
|
||||
@@ -25,7 +25,7 @@ export const ToastContainer = ({ toasts }: ToastContainerProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="toast-container" className="fixed bottom-4 right-4 z-[100] space-y-2">
|
||||
<div id="toast-container" className="fixed bottom-4 right-4 z-[1100] space-y-2">
|
||||
{toasts.map(toast => (
|
||||
<div
|
||||
key={toast.id}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState } from 'react';
|
||||
import { Book, ButtonStateInfo } from '../../types';
|
||||
import { useSearchMode } from '../../contexts/SearchModeContext';
|
||||
import { BookActionButton } from '../BookActionButton';
|
||||
import { BookTargetDropdown } from '../BookTargetDropdown';
|
||||
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
|
||||
import { DisplayFieldBadges } from '../shared';
|
||||
|
||||
const SkeletonLoader = () => (
|
||||
@@ -16,15 +18,17 @@ interface CardViewProps {
|
||||
buttonState: ButtonStateInfo;
|
||||
animationDelay?: number;
|
||||
showSeriesPosition?: boolean;
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
}
|
||||
|
||||
export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonState, animationDelay = 0, showSeriesPosition = false }: CardViewProps) => {
|
||||
export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonState, animationDelay = 0, showSeriesPosition = false, onShowToast }: CardViewProps) => {
|
||||
const { searchMode } = useSearchMode();
|
||||
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
|
||||
const [isLoadingReleases, setIsLoadingReleases] = useState(false);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
const handleDetails = async (id: string) => {
|
||||
setIsLoadingDetails(true);
|
||||
@@ -46,83 +50,100 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
|
||||
|
||||
return (
|
||||
<article
|
||||
className="book-card overflow-hidden flex flex-col sm:flex-col max-sm:flex-row space-between w-full sm:max-w-[292px] max-sm:h-[180px] h-full transition-shadow duration-300 animate-pop-up will-change-transform"
|
||||
className="book-card flex flex-col sm:flex-col max-sm:flex-row space-between w-full sm:max-w-[292px] max-sm:h-[180px] h-full transition-shadow duration-300 animate-pop-up will-change-transform relative"
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
borderRadius: '.75rem',
|
||||
boxShadow: isHovered ? '0 10px 30px rgba(0, 0, 0, 0.15)' : 'none',
|
||||
boxShadow: isHovered || dropdownOpen ? '0 10px 30px rgba(0, 0, 0, 0.15)' : 'none',
|
||||
zIndex: dropdownOpen ? 20 : isHovered ? 10 : undefined,
|
||||
animationDelay: `${animationDelay}ms`,
|
||||
animationFillMode: 'both',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="relative w-full sm:w-full max-sm:w-[120px] max-sm:h-full max-sm:flex-shrink-0 group" style={{ aspectRatio: '2/3' }}>
|
||||
{/* Series position badge */}
|
||||
{showSeriesPosition && book.series_position != null && (
|
||||
<div
|
||||
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border border-emerald-700"
|
||||
style={{
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3)',
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
#{book.series_position}
|
||||
</div>
|
||||
)}
|
||||
{book.preview && !imageError ? (
|
||||
<>
|
||||
{!imageLoaded && (
|
||||
<div className="absolute inset-0">
|
||||
<SkeletonLoader />
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={book.preview}
|
||||
alt={book.title || 'Book cover'}
|
||||
className="w-full h-full"
|
||||
<div className="relative w-full sm:w-full max-sm:w-[120px] max-sm:h-full max-sm:flex-shrink-0" style={{ aspectRatio: '2/3' }}>
|
||||
<div className="absolute inset-0 overflow-hidden sm:rounded-t-[.75rem] max-sm:rounded-l-[.75rem]">
|
||||
{/* Series position badge */}
|
||||
{showSeriesPosition && book.series_position != null && (
|
||||
<div
|
||||
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border border-emerald-700"
|
||||
style={{
|
||||
opacity: imageLoaded ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'top',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3)',
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-sm opacity-50" style={{ background: 'var(--border-muted)' }}>
|
||||
No Cover
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
#{book.series_position}
|
||||
</div>
|
||||
)}
|
||||
{book.preview && !imageError ? (
|
||||
<>
|
||||
{!imageLoaded && (
|
||||
<div className="absolute inset-0">
|
||||
<SkeletonLoader />
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={book.preview}
|
||||
alt={book.title || 'Book cover'}
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
opacity: imageLoaded ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'top',
|
||||
}}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-sm opacity-50" style={{ background: 'var(--border-muted)' }}>
|
||||
No Cover
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="absolute inset-0 bg-white transition-opacity duration-300 pointer-events-none"
|
||||
style={{ opacity: isHovered || dropdownOpen ? 0.02 : 0 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute inset-0 bg-white transition-opacity duration-300 pointer-events-none"
|
||||
style={{ opacity: isHovered ? 0.02 : 0 }}
|
||||
/>
|
||||
|
||||
<button
|
||||
className="absolute bottom-2 right-2 w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110 max-sm:hidden"
|
||||
className="absolute bottom-2 right-2 z-10 flex flex-col gap-1.5 max-sm:hidden transition-all duration-300"
|
||||
style={{
|
||||
opacity: isHovered || isLoadingDetails ? 1 : 0,
|
||||
pointerEvents: isHovered || isLoadingDetails ? 'auto' : 'none',
|
||||
opacity: isHovered || dropdownOpen || isLoadingDetails ? 1 : 0,
|
||||
pointerEvents: isHovered || dropdownOpen || isLoadingDetails ? 'auto' : 'none',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDetails(book.id);
|
||||
}}
|
||||
disabled={isLoadingDetails}
|
||||
aria-label="Book details"
|
||||
>
|
||||
{isLoadingDetails ? (
|
||||
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{bookSupportsTargets(book) && (
|
||||
<BookTargetDropdown
|
||||
provider={book.provider!}
|
||||
bookId={book.provider_id!}
|
||||
onShowToast={onShowToast}
|
||||
variant="icon"
|
||||
className="w-8 h-8 bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm shadow-lg hover:scale-110"
|
||||
onOpenChange={setDropdownOpen}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDetails(book.id);
|
||||
}}
|
||||
disabled={isLoadingDetails}
|
||||
aria-label="Book details"
|
||||
>
|
||||
{isLoadingDetails ? (
|
||||
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 max-sm:p-3 max-sm:py-2 flex flex-col gap-3 max-sm:gap-2 max-sm:flex-1 max-sm:justify-between max-sm:min-w-0 sm:flex-1 sm:flex sm:flex-col sm:justify-end">
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState } from 'react';
|
||||
import { Book, ButtonStateInfo } from '../../types';
|
||||
import { useSearchMode } from '../../contexts/SearchModeContext';
|
||||
import { BookActionButton } from '../BookActionButton';
|
||||
import { BookTargetDropdown } from '../BookTargetDropdown';
|
||||
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
|
||||
import { DisplayFieldBadges } from '../shared';
|
||||
|
||||
const SkeletonLoader = () => (
|
||||
@@ -17,15 +19,17 @@ interface CompactViewProps {
|
||||
showDetailsButton?: boolean;
|
||||
animationDelay?: number;
|
||||
showSeriesPosition?: boolean;
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
}
|
||||
|
||||
export const CompactView = ({ book, onDetails, onDownload, onGetReleases, buttonState, showDetailsButton = false, animationDelay = 0, showSeriesPosition = false }: CompactViewProps) => {
|
||||
export const CompactView = ({ book, onDetails, onDownload, onGetReleases, buttonState, showDetailsButton = false, animationDelay = 0, showSeriesPosition = false, onShowToast }: CompactViewProps) => {
|
||||
const { searchMode } = useSearchMode();
|
||||
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
|
||||
const [isLoadingReleases, setIsLoadingReleases] = useState(false);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
const handleDetails = async (id: string) => {
|
||||
setIsLoadingDetails(true);
|
||||
@@ -47,11 +51,12 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
|
||||
|
||||
return (
|
||||
<article
|
||||
className="book-card overflow-hidden !flex !flex-row w-full !h-[180px] transition-shadow duration-300 animate-pop-up will-change-transform"
|
||||
className="book-card !flex !flex-row w-full !h-[180px] transition-shadow duration-300 animate-pop-up will-change-transform relative"
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
borderRadius: '.75rem',
|
||||
boxShadow: isHovered ? '0 10px 30px rgba(0, 0, 0, 0.15)' : 'none',
|
||||
boxShadow: isHovered || dropdownOpen ? '0 10px 30px rgba(0, 0, 0, 0.15)' : 'none',
|
||||
zIndex: dropdownOpen ? 20 : isHovered ? 10 : undefined,
|
||||
animationDelay: `${animationDelay}ms`,
|
||||
animationFillMode: 'both',
|
||||
}}
|
||||
@@ -59,69 +64,85 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="relative w-[120px] h-full flex-shrink-0">
|
||||
{/* Series position badge */}
|
||||
{showSeriesPosition && book.series_position != null && (
|
||||
<div
|
||||
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border border-emerald-700"
|
||||
style={{
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3)',
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
#{book.series_position}
|
||||
</div>
|
||||
)}
|
||||
{book.preview && !imageError ? (
|
||||
<>
|
||||
{!imageLoaded && (
|
||||
<div className="absolute inset-0">
|
||||
<SkeletonLoader />
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={book.preview}
|
||||
alt={book.title || 'Book cover'}
|
||||
className="w-full h-full"
|
||||
<div className="absolute inset-0 overflow-hidden rounded-l-[.75rem]">
|
||||
{/* Series position badge */}
|
||||
{showSeriesPosition && book.series_position != null && (
|
||||
<div
|
||||
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border border-emerald-700"
|
||||
style={{
|
||||
opacity: imageLoaded ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'top',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3)',
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-sm opacity-50" style={{ background: 'var(--border-muted)' }}>
|
||||
No Cover
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
#{book.series_position}
|
||||
</div>
|
||||
)}
|
||||
{book.preview && !imageError ? (
|
||||
<>
|
||||
{!imageLoaded && (
|
||||
<div className="absolute inset-0">
|
||||
<SkeletonLoader />
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={book.preview}
|
||||
alt={book.title || 'Book cover'}
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
opacity: imageLoaded ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'top',
|
||||
}}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-sm opacity-50" style={{ background: 'var(--border-muted)' }}>
|
||||
No Cover
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 bg-white transition-opacity duration-300 pointer-events-none" style={{ opacity: isHovered ? 0.02 : 0 }} />
|
||||
<div className="absolute inset-0 bg-white transition-opacity duration-300 pointer-events-none" style={{ opacity: isHovered || dropdownOpen ? 0.02 : 0 }} />
|
||||
</div>
|
||||
|
||||
{!showDetailsButton && (
|
||||
<button
|
||||
className="absolute bottom-2 right-2 w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
|
||||
<div
|
||||
className="absolute bottom-2 right-2 z-10 flex flex-col gap-1.5 transition-all duration-300"
|
||||
style={{
|
||||
opacity: isHovered || isLoadingDetails ? 1 : 0,
|
||||
pointerEvents: isHovered || isLoadingDetails ? 'auto' : 'none',
|
||||
opacity: isHovered || dropdownOpen || isLoadingDetails ? 1 : 0,
|
||||
pointerEvents: isHovered || dropdownOpen || isLoadingDetails ? 'auto' : 'none',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDetails(book.id);
|
||||
}}
|
||||
disabled={isLoadingDetails}
|
||||
aria-label="Book details"
|
||||
>
|
||||
{isLoadingDetails ? (
|
||||
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{bookSupportsTargets(book) && (
|
||||
<BookTargetDropdown
|
||||
provider={book.provider!}
|
||||
bookId={book.provider_id!}
|
||||
onShowToast={onShowToast}
|
||||
variant="icon"
|
||||
className="w-8 h-8 bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm shadow-lg hover:scale-110"
|
||||
onOpenChange={setDropdownOpen}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDetails(book.id);
|
||||
}}
|
||||
disabled={isLoadingDetails}
|
||||
aria-label="Book details"
|
||||
>
|
||||
{isLoadingDetails ? (
|
||||
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState } from 'react';
|
||||
import { Book, ButtonStateInfo } from '../../types';
|
||||
import { useSearchMode } from '../../contexts/SearchModeContext';
|
||||
import { BookActionButton } from '../BookActionButton';
|
||||
import { BookTargetDropdown } from '../BookTargetDropdown';
|
||||
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
|
||||
import { DisplayFieldIcon, DisplayFieldBadge } from '../shared';
|
||||
import { getFormatColor, getLanguageColor } from '../../utils/colorMaps';
|
||||
|
||||
@@ -13,6 +15,7 @@ interface ListViewProps {
|
||||
getButtonState: (bookId: string) => ButtonStateInfo;
|
||||
getUniversalButtonState: (bookId: string) => ButtonStateInfo;
|
||||
showSeriesPosition?: boolean;
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
}
|
||||
|
||||
const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: string }) => {
|
||||
@@ -48,10 +51,11 @@ const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: strin
|
||||
);
|
||||
};
|
||||
|
||||
export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButtonState, getUniversalButtonState, showSeriesPosition = false }: ListViewProps) => {
|
||||
export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButtonState, getUniversalButtonState, showSeriesPosition = false, onShowToast }: ListViewProps) => {
|
||||
const { searchMode } = useSearchMode();
|
||||
const [detailsLoadingId, setDetailsLoadingId] = useState<string | null>(null);
|
||||
const [releasesLoadingId, setReleasesLoadingId] = useState<string | null>(null);
|
||||
const [openDropdownBookId, setOpenDropdownBookId] = useState<string | null>(null);
|
||||
|
||||
if (books.length === 0) {
|
||||
return null;
|
||||
@@ -77,7 +81,7 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
|
||||
|
||||
return (
|
||||
<article
|
||||
className="w-full overflow-hidden rounded-lg sm:rounded-2xl"
|
||||
className="w-full rounded-lg sm:rounded-2xl"
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
boxShadow: '0 10px 30px rgba(15, 23, 42, 0.08)',
|
||||
@@ -100,8 +104,9 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
|
||||
return (
|
||||
<div
|
||||
key={book.id}
|
||||
className="px-1.5 sm:px-2 py-1.5 sm:py-2 transition-colors duration-200 hover-row w-full animate-pop-up will-change-transform"
|
||||
className="px-1.5 sm:px-2 py-1.5 sm:py-2 transition-colors duration-200 hover-row w-full animate-pop-up will-change-transform relative"
|
||||
style={{
|
||||
zIndex: openDropdownBookId === book.id ? 30 : undefined,
|
||||
animationDelay: `${index * 50}ms`,
|
||||
animationFillMode: 'both',
|
||||
}}
|
||||
@@ -218,6 +223,15 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-row justify-end gap-0.5 sm:gap-1 sm:pr-3">
|
||||
{bookSupportsTargets(book) && (
|
||||
<BookTargetDropdown
|
||||
provider={book.provider!}
|
||||
bookId={book.provider_id!}
|
||||
onShowToast={onShowToast}
|
||||
variant="icon"
|
||||
onOpenChange={(isOpen) => setOpenDropdownBookId(isOpen ? book.id : null)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center justify-center p-1.5 sm:p-2 rounded-full text-gray-600 dark:text-gray-200 hover-action transition-all duration-200"
|
||||
onClick={() => handleDetails(book.id)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { DropdownList, DropdownListOption } from '../DropdownList';
|
||||
import { DropdownList, type DropdownListOption } from '../DropdownList';
|
||||
import { DynamicFieldOption, fetchFieldOptions } from '../../services/api';
|
||||
|
||||
const optionsCache = new Map<string, DynamicFieldOption[]>();
|
||||
@@ -92,20 +92,22 @@ export const DynamicDropdown = ({
|
||||
return [{ value: '__error', label: loadError, disabled: true }];
|
||||
}
|
||||
|
||||
if (options.length === 0) {
|
||||
return [{ value: '__empty', label: 'No options available', disabled: true }];
|
||||
}
|
||||
|
||||
return buildOptions(options);
|
||||
}, [isLoading, loadError, options]);
|
||||
|
||||
const handleChange = (nextValue: string[] | string) => {
|
||||
const normalized = Array.isArray(nextValue) ? nextValue[0] ?? '' : nextValue;
|
||||
const match = options.find((opt) => opt.value === normalized);
|
||||
onChange(normalized, match?.label);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownList
|
||||
options={dropdownOptions}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onChange={(nextValue) => {
|
||||
const normalized = Array.isArray(nextValue) ? nextValue[0] ?? '' : nextValue;
|
||||
const match = options.find((option) => option.value === normalized);
|
||||
onChange(normalized, match?.label);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
widthClassName={widthClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
|
||||
@@ -20,7 +20,7 @@ type SearchFieldValues = Record<string, string | number | boolean>;
|
||||
|
||||
interface UseSearchReturn {
|
||||
books: Book[];
|
||||
setBooks: (books: Book[]) => void;
|
||||
setBooks: React.Dispatch<React.SetStateAction<Book[]>>;
|
||||
isSearching: boolean;
|
||||
lastSearchQuery: string;
|
||||
searchInput: string;
|
||||
@@ -49,6 +49,8 @@ interface UseSearchReturn {
|
||||
isLoadingMore: boolean;
|
||||
loadMore: (config: AppConfig | null, searchMode?: SearchMode) => Promise<void>;
|
||||
totalFound: number;
|
||||
// Source URL for the current result set (e.g. Hardcover list page)
|
||||
resultsSourceUrl: string | undefined;
|
||||
}
|
||||
|
||||
export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
@@ -79,6 +81,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [totalFound, setTotalFound] = useState(0);
|
||||
const [resultsSourceUrl, setResultsSourceUrl] = useState<string | undefined>();
|
||||
|
||||
// Store last search params for loadMore
|
||||
const lastSearchParamsRef = useRef<{
|
||||
@@ -163,6 +166,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
setHasMore(false);
|
||||
setTotalFound(0);
|
||||
setCurrentPage(1);
|
||||
setResultsSourceUrl(undefined);
|
||||
lastSearchParamsRef.current = null;
|
||||
return;
|
||||
}
|
||||
@@ -188,6 +192,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
setBooks(result.books);
|
||||
setHasMore(result.hasMore);
|
||||
setTotalFound(result.totalFound);
|
||||
setResultsSourceUrl(result.sourceUrl);
|
||||
// Store params for loadMore
|
||||
lastSearchParamsRef.current = {
|
||||
query: searchQuery,
|
||||
@@ -200,6 +205,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
setBooks([]);
|
||||
setHasMore(false);
|
||||
setTotalFound(0);
|
||||
setResultsSourceUrl(undefined);
|
||||
showToast('No results found', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -269,6 +275,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
setCurrentPage(1);
|
||||
setHasMore(false);
|
||||
setTotalFound(0);
|
||||
setResultsSourceUrl(undefined);
|
||||
lastSearchParamsRef.current = null;
|
||||
}, [onSearchReset]);
|
||||
|
||||
@@ -332,5 +339,6 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
totalFound,
|
||||
resultsSourceUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -224,6 +224,7 @@ interface MetadataSearchResponse {
|
||||
page?: number;
|
||||
total_found?: number;
|
||||
has_more?: boolean;
|
||||
source_url?: string;
|
||||
}
|
||||
|
||||
// Metadata search result with pagination info
|
||||
@@ -232,6 +233,7 @@ export interface MetadataSearchResult {
|
||||
page: number;
|
||||
totalFound: number;
|
||||
hasMore: boolean;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
export interface DynamicFieldOption {
|
||||
@@ -241,6 +243,20 @@ export interface DynamicFieldOption {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface BookTargetOption {
|
||||
value: string;
|
||||
label: string;
|
||||
group?: string;
|
||||
description?: string;
|
||||
checked: boolean;
|
||||
writable: boolean;
|
||||
}
|
||||
|
||||
export interface BookTargetStateResult {
|
||||
changed: boolean;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
// Search metadata providers and normalize to Book format
|
||||
export const searchMetadata = async (
|
||||
query: string,
|
||||
@@ -283,6 +299,7 @@ export const searchMetadata = async (
|
||||
page: response.page || page,
|
||||
totalFound: response.total_found || 0,
|
||||
hasMore: response.has_more || false,
|
||||
sourceUrl: response.source_url,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -328,18 +345,95 @@ export const fetchFieldOptions = async (
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.options
|
||||
.filter((option): option is Record<string, unknown> => typeof option === 'object' && option !== null)
|
||||
.map((option) => {
|
||||
const value = typeof option.value === 'string' ? option.value : String(option.value ?? '');
|
||||
const label = typeof option.label === 'string' ? option.label : value;
|
||||
const group = typeof option.group === 'string' ? option.group : undefined;
|
||||
const description = typeof option.description === 'string' ? option.description : undefined;
|
||||
return { value, label, group, description };
|
||||
})
|
||||
return parseOptionList(response.options).map(({ value, label, group, description }) => ({
|
||||
value,
|
||||
label,
|
||||
group,
|
||||
description,
|
||||
}));
|
||||
};
|
||||
|
||||
const parseBaseOption = (
|
||||
option: Record<string, unknown>,
|
||||
): { value: string; label: string; group?: string; description?: string } => {
|
||||
const value = typeof option.value === 'string' ? option.value : String(option.value ?? '');
|
||||
const label = typeof option.label === 'string' ? option.label : value;
|
||||
const group = typeof option.group === 'string' ? option.group : undefined;
|
||||
const description = typeof option.description === 'string' ? option.description : undefined;
|
||||
return { value, label, group, description };
|
||||
};
|
||||
|
||||
const parseOptionList = (raw: unknown): ReturnType<typeof parseBaseOption>[] => {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
|
||||
.map(parseBaseOption)
|
||||
.filter((option) => option.value !== '');
|
||||
};
|
||||
|
||||
const parseBookTargetOptions = (raw: unknown): BookTargetOption[] => {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
|
||||
.map((item) => ({
|
||||
...parseBaseOption(item),
|
||||
checked: item.checked === true,
|
||||
writable: item.writable !== false,
|
||||
}))
|
||||
.filter((option) => option.value !== '');
|
||||
};
|
||||
|
||||
export const fetchBookTargetOptions = async (
|
||||
provider: string,
|
||||
bookId: string,
|
||||
): Promise<BookTargetOption[]> => {
|
||||
const response = await fetchJSON<{ options?: unknown }>(
|
||||
`${API_BASE}/metadata/book/${encodeURIComponent(provider)}/${encodeURIComponent(bookId)}/targets`
|
||||
);
|
||||
return parseBookTargetOptions(response.options);
|
||||
};
|
||||
|
||||
export const fetchBookTargetOptionsBatch = async (
|
||||
provider: string,
|
||||
bookIds: string[],
|
||||
): Promise<Map<string, BookTargetOption[]>> => {
|
||||
const response = await fetchJSON<{ results?: unknown }>(
|
||||
`${API_BASE}/metadata/book/${encodeURIComponent(provider)}/targets/batch`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ book_ids: bookIds }),
|
||||
}
|
||||
);
|
||||
|
||||
const results = new Map<string, BookTargetOption[]>();
|
||||
if (typeof response.results === 'object' && response.results !== null) {
|
||||
for (const [bookId, options] of Object.entries(response.results as Record<string, unknown>)) {
|
||||
results.set(bookId, parseBookTargetOptions(options));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
export const setBookTargetState = async (
|
||||
provider: string,
|
||||
bookId: string,
|
||||
target: string,
|
||||
selected: boolean,
|
||||
): Promise<BookTargetStateResult> => {
|
||||
const response = await fetchJSON<{ changed?: unknown; selected?: unknown }>(
|
||||
`${API_BASE}/metadata/book/${encodeURIComponent(provider)}/${encodeURIComponent(bookId)}/targets`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ target, selected }),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
changed: response.changed === true,
|
||||
selected: response.selected === true,
|
||||
};
|
||||
};
|
||||
|
||||
export const getSourceRecordInfo = async (source: string, id: string): Promise<Book> => {
|
||||
const response = await fetchJSON<SourceRecordData>(
|
||||
`${API_BASE}/release-sources/${encodeURIComponent(source)}/records/${encodeURIComponent(id)}`
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
type BookTargetChangeEvent = {
|
||||
provider: string;
|
||||
bookId: string;
|
||||
target: string;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
type Listener = (event: BookTargetChangeEvent) => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
export const onBookTargetChange = (listener: Listener): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
};
|
||||
|
||||
export const emitBookTargetChange = (event: BookTargetChangeEvent): void => {
|
||||
listeners.forEach((listener) => listener(event));
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import { fetchBookTargetOptionsBatch, type BookTargetOption } from '../services/api';
|
||||
import { onBookTargetChange } from './bookTargetEvents';
|
||||
import type { Book } from '../types';
|
||||
|
||||
// Providers that support the book targets feature.
|
||||
// Centralised here so the check isn't hardcoded across every view component.
|
||||
const PROVIDERS_WITH_TARGETS = new Set(['hardcover']);
|
||||
|
||||
/** Returns true if the book's provider supports the book-target dropdown. */
|
||||
export const bookSupportsTargets = (book: Book): boolean =>
|
||||
Boolean(book.provider && book.provider_id && PROVIDERS_WITH_TARGETS.has(book.provider));
|
||||
|
||||
type PendingRequest = {
|
||||
bookId: string;
|
||||
resolve: (options: BookTargetOption[]) => void;
|
||||
reject: (error: unknown) => void;
|
||||
};
|
||||
|
||||
const BATCH_SIZE = 50;
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
const pendingByProvider = new Map<string, PendingRequest[]>();
|
||||
let flushScheduled = false;
|
||||
|
||||
// Client-side cache to avoid re-fetching on view mode switches and re-renders
|
||||
const cache = new Map<string, { options: BookTargetOption[]; expiresAt: number }>();
|
||||
|
||||
const cacheKey = (provider: string, bookId: string) => `${provider}:${bookId}`;
|
||||
|
||||
const getCached = (provider: string, bookId: string): BookTargetOption[] | undefined => {
|
||||
const entry = cache.get(cacheKey(provider, bookId));
|
||||
if (!entry) return undefined;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
cache.delete(cacheKey(provider, bookId));
|
||||
return undefined;
|
||||
}
|
||||
return entry.options;
|
||||
};
|
||||
|
||||
const setCache = (provider: string, bookId: string, options: BookTargetOption[]) => {
|
||||
cache.set(cacheKey(provider, bookId), { options, expiresAt: Date.now() + CACHE_TTL_MS });
|
||||
};
|
||||
|
||||
export const invalidateCache = (provider: string, bookId: string) => {
|
||||
cache.delete(cacheKey(provider, bookId));
|
||||
};
|
||||
|
||||
// Invalidate cache when a target changes
|
||||
onBookTargetChange((event) => {
|
||||
invalidateCache(event.provider, event.bookId);
|
||||
});
|
||||
|
||||
const fetchChunked = async (
|
||||
provider: string,
|
||||
bookIds: string[],
|
||||
): Promise<Map<string, BookTargetOption[]>> => {
|
||||
if (bookIds.length <= BATCH_SIZE) {
|
||||
return fetchBookTargetOptionsBatch(provider, bookIds);
|
||||
}
|
||||
|
||||
const merged = new Map<string, BookTargetOption[]>();
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < bookIds.length; i += BATCH_SIZE) {
|
||||
chunks.push(bookIds.slice(i, i + BATCH_SIZE));
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
chunks.map((chunk) => fetchBookTargetOptionsBatch(provider, chunk)),
|
||||
);
|
||||
for (const chunkResult of results) {
|
||||
for (const [bookId, options] of chunkResult) {
|
||||
merged.set(bookId, options);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
|
||||
const flush = async () => {
|
||||
flushScheduled = false;
|
||||
const snapshot = new Map(pendingByProvider);
|
||||
pendingByProvider.clear();
|
||||
|
||||
await Promise.all(
|
||||
[...snapshot].map(async ([provider, requests]) => {
|
||||
// Deduplicate and separate cached vs uncached
|
||||
const uncachedIds = new Set<string>();
|
||||
for (const req of requests) {
|
||||
if (!getCached(provider, req.bookId)) {
|
||||
uncachedIds.add(req.bookId);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let fetched = new Map<string, BookTargetOption[]>();
|
||||
if (uncachedIds.size > 0) {
|
||||
fetched = await fetchChunked(provider, [...uncachedIds]);
|
||||
for (const [bookId, options] of fetched) {
|
||||
setCache(provider, bookId, options);
|
||||
}
|
||||
}
|
||||
|
||||
for (const req of requests) {
|
||||
const options = getCached(provider, req.bookId) ?? fetched.get(req.bookId) ?? [];
|
||||
req.resolve(options);
|
||||
}
|
||||
} catch (error) {
|
||||
for (const req of requests) {
|
||||
req.reject(error);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Load book target options using automatic batching.
|
||||
*
|
||||
* Multiple calls within the same microtask are coalesced into a single
|
||||
* batch API request per provider. Results are cached client-side to avoid
|
||||
* re-fetching on view mode switches and re-renders.
|
||||
*/
|
||||
export const loadBookTargets = (
|
||||
provider: string,
|
||||
bookId: string,
|
||||
): Promise<BookTargetOption[]> => {
|
||||
const cached = getCached(provider, bookId);
|
||||
if (cached) {
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
|
||||
return new Promise<BookTargetOption[]>((resolve, reject) => {
|
||||
let list = pendingByProvider.get(provider);
|
||||
if (!list) {
|
||||
list = [];
|
||||
pendingByProvider.set(provider, list);
|
||||
}
|
||||
list.push({ bookId, resolve, reject });
|
||||
|
||||
if (!flushScheduled) {
|
||||
flushScheduled = true;
|
||||
// Use queueMicrotask so all synchronous mounts in the same tick are batched
|
||||
queueMicrotask(() => void flush());
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -2,16 +2,12 @@ from shelfmark.metadata_providers.hardcover import HardcoverProvider
|
||||
|
||||
|
||||
class TestHardcoverFieldOptions:
|
||||
def test_search_fields_enable_typeahead_for_author_title_and_series(self):
|
||||
def test_search_fields_enable_typeahead_for_series_only(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["author"].suggestions_endpoint is None
|
||||
assert fields_by_key["title"].suggestions_endpoint is None
|
||||
assert fields_by_key["series"].suggestions_endpoint == (
|
||||
"/api/metadata/field-options?provider=hardcover&field=series"
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from shelfmark.core.cache import cache_key
|
||||
from shelfmark.metadata_providers import MetadataSearchOptions, SearchResult
|
||||
from shelfmark.metadata_providers.hardcover import (
|
||||
HARDCOVER_WANT_TO_READ_STATUS_ID,
|
||||
@@ -5,6 +6,22 @@ from shelfmark.metadata_providers.hardcover import (
|
||||
)
|
||||
|
||||
|
||||
class CacheStub:
|
||||
"""Minimal cache stub that records invalidation calls."""
|
||||
|
||||
def __init__(self):
|
||||
self.invalidated: list[str] = []
|
||||
self.invalidated_prefixes: list[str] = []
|
||||
|
||||
def invalidate(self, key: str) -> bool:
|
||||
self.invalidated.append(key)
|
||||
return True
|
||||
|
||||
def invalidate_prefix(self, prefix: str) -> int:
|
||||
self.invalidated_prefixes.append(prefix)
|
||||
return 1
|
||||
|
||||
|
||||
class TestHardcoverLists:
|
||||
def test_fetch_user_lists_includes_want_to_read_shelf(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
@@ -133,3 +150,189 @@ class TestHardcoverLists:
|
||||
assert len(result.books) == 1
|
||||
assert result.books[0].title == "Dune"
|
||||
assert result.books[0].authors == ["Frank Herbert"]
|
||||
|
||||
def test_get_book_targets_marks_checked_memberships(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"get_user_lists",
|
||||
lambda: [
|
||||
{
|
||||
"value": f"status:{HARDCOVER_WANT_TO_READ_STATUS_ID}",
|
||||
"label": "Want to Read (7)",
|
||||
"group": "My Books",
|
||||
},
|
||||
{
|
||||
"value": "id:42",
|
||||
"label": "Sci-Fi Favourites (12)",
|
||||
"group": "My Lists",
|
||||
},
|
||||
{
|
||||
"value": "id:99",
|
||||
"label": "Followed List (3)",
|
||||
"group": "Followed Lists",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_execute_query",
|
||||
lambda query, variables, raise_on_error=False: {
|
||||
"me": {
|
||||
"user_books": [{"id": 55, "status_id": HARDCOVER_WANT_TO_READ_STATUS_ID}],
|
||||
"lists": [
|
||||
{"id": 42, "list_books": [{"id": 500}]},
|
||||
{"id": 99, "list_books": [{"id": 900}]},
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
options = provider.get_book_targets("123")
|
||||
|
||||
assert options == [
|
||||
{
|
||||
"value": f"status:{HARDCOVER_WANT_TO_READ_STATUS_ID}",
|
||||
"label": "Want to Read (7)",
|
||||
"group": "My Books",
|
||||
"checked": True,
|
||||
"writable": True,
|
||||
},
|
||||
{
|
||||
"value": "id:42",
|
||||
"label": "Sci-Fi Favourites (12)",
|
||||
"group": "My Lists",
|
||||
"checked": True,
|
||||
"writable": True,
|
||||
},
|
||||
]
|
||||
|
||||
def test_set_book_target_state_updates_existing_status(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
captured: dict[str, object] = {}
|
||||
cache_stub = CacheStub()
|
||||
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"get_user_lists",
|
||||
lambda: [
|
||||
{
|
||||
"value": f"status:{HARDCOVER_WANT_TO_READ_STATUS_ID}",
|
||||
"label": "Want to Read (7)",
|
||||
"group": "My Books",
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(provider, "_resolve_current_user_id", lambda: "user-123")
|
||||
monkeypatch.setattr("shelfmark.metadata_providers.hardcover.get_metadata_cache", lambda: cache_stub)
|
||||
|
||||
def fake_execute(query: str, variables, raise_on_error: bool = False):
|
||||
if "query GetBookTargetMembership" in query:
|
||||
return {
|
||||
"me": {
|
||||
"user_books": [{"id": 77, "status_id": 2}],
|
||||
"lists": [],
|
||||
}
|
||||
}
|
||||
if "mutation UpdateBookStatus" in query:
|
||||
captured["variables"] = variables
|
||||
return {
|
||||
"update_user_book": {
|
||||
"id": 77,
|
||||
"error": None,
|
||||
"user_book": {"id": 77, "book_id": 123, "status_id": HARDCOVER_WANT_TO_READ_STATUS_ID},
|
||||
}
|
||||
}
|
||||
raise AssertionError(f"Unexpected query: {query}")
|
||||
|
||||
monkeypatch.setattr(provider, "_execute_query", fake_execute)
|
||||
|
||||
result = provider.set_book_target_state(
|
||||
"123",
|
||||
f"status:{HARDCOVER_WANT_TO_READ_STATUS_ID}",
|
||||
True,
|
||||
)
|
||||
|
||||
assert result == {"changed": True}
|
||||
assert captured["variables"] == {
|
||||
"userBookId": 77,
|
||||
"statusId": HARDCOVER_WANT_TO_READ_STATUS_ID,
|
||||
}
|
||||
assert cache_stub.invalidated == [
|
||||
cache_key("hardcover:user_lists", "user-123"),
|
||||
]
|
||||
assert set(cache_stub.invalidated_prefixes) == {
|
||||
cache_key("hardcover:user_books:status", "user-123", 2),
|
||||
cache_key("hardcover:user_books:status", "user-123", HARDCOVER_WANT_TO_READ_STATUS_ID),
|
||||
}
|
||||
|
||||
def test_set_book_target_state_removes_list_membership(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
cache_stub = CacheStub()
|
||||
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"get_user_lists",
|
||||
lambda: [
|
||||
{
|
||||
"value": "id:42",
|
||||
"label": "Sci-Fi Favourites (12)",
|
||||
"group": "My Lists",
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(provider, "_resolve_current_user_id", lambda: "user-123")
|
||||
monkeypatch.setattr("shelfmark.metadata_providers.hardcover.get_metadata_cache", lambda: cache_stub)
|
||||
|
||||
def fake_execute(query: str, variables, raise_on_error: bool = False):
|
||||
if "query GetBookTargetMembership" in query:
|
||||
return {
|
||||
"me": {
|
||||
"user_books": [],
|
||||
"lists": [{"id": 42, "list_books": [{"id": 500}]}],
|
||||
}
|
||||
}
|
||||
if "mutation RemoveBookFromList" in query:
|
||||
return {
|
||||
"delete_list_book": {
|
||||
"id": 500,
|
||||
"list_id": 42,
|
||||
}
|
||||
}
|
||||
raise AssertionError(f"Unexpected query: {query}")
|
||||
|
||||
monkeypatch.setattr(provider, "_execute_query", fake_execute)
|
||||
|
||||
result = provider.set_book_target_state("123", "id:42", False)
|
||||
|
||||
assert result == {"changed": True}
|
||||
assert cache_stub.invalidated == [
|
||||
cache_key("hardcover:user_lists", "user-123"),
|
||||
]
|
||||
assert cache_stub.invalidated_prefixes == [
|
||||
cache_key("hardcover:list:id", 42),
|
||||
]
|
||||
|
||||
def test_set_book_target_state_rejects_non_writable_targets(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"get_user_lists",
|
||||
lambda: [
|
||||
{
|
||||
"value": "id:99",
|
||||
"label": "Followed List (3)",
|
||||
"group": "Followed Lists",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
try:
|
||||
provider.set_book_target_state("123", "id:99", True)
|
||||
except ValueError as exc:
|
||||
assert str(exc) == "Unsupported Hardcover target"
|
||||
else:
|
||||
raise AssertionError("Expected ValueError for followed list target")
|
||||
|
||||
Reference in New Issue
Block a user