From 9d08bb3ef182c60984c648da4b5e79925817ece1 Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Sat, 7 Mar 2026 15:33:46 +0000 Subject: [PATCH] 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 --- shelfmark/core/cache.py | 8 + shelfmark/main.py | 116 +++- shelfmark/metadata_providers/__init__.py | 31 + shelfmark/metadata_providers/hardcover.py | 560 +++++++++++++++++- src/frontend/src/App.tsx | 58 +- .../src/components/BookTargetDropdown.tsx | 248 ++++++++ src/frontend/src/components/DetailsModal.tsx | 61 +- src/frontend/src/components/Dropdown.tsx | 39 +- src/frontend/src/components/DropdownList.tsx | 11 +- src/frontend/src/components/ReleaseModal.tsx | 12 + .../src/components/ResultsSection.tsx | 35 +- src/frontend/src/components/SearchBar.tsx | 42 +- .../src/components/ToastContainer.tsx | 2 +- .../src/components/resultsViews/CardView.tsx | 143 +++-- .../components/resultsViews/CompactView.tsx | 135 +++-- .../src/components/resultsViews/ListView.tsx | 20 +- .../src/components/shared/DynamicDropdown.tsx | 18 +- src/frontend/src/hooks/useSearch.ts | 10 +- src/frontend/src/services/api.ts | 112 +++- src/frontend/src/utils/bookTargetEvents.ts | 19 + src/frontend/src/utils/bookTargetLoader.ts | 145 +++++ .../metadata/test_hardcover_field_options.py | 10 +- tests/metadata/test_hardcover_lists.py | 203 +++++++ 23 files changed, 1824 insertions(+), 214 deletions(-) create mode 100644 src/frontend/src/components/BookTargetDropdown.tsx create mode 100644 src/frontend/src/utils/bookTargetEvents.ts create mode 100644 src/frontend/src/utils/bookTargetLoader.ts diff --git a/shelfmark/core/cache.py b/shelfmark/core/cache.py index 66111ddb..762a8628 100644 --- a/shelfmark/core/cache.py +++ b/shelfmark/core/cache.py @@ -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: diff --git a/shelfmark/main.py b/shelfmark/main.py index e2ae98c3..438b91ec 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -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//', 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///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//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///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]]: diff --git a/shelfmark/metadata_providers/__init__.py b/shelfmark/metadata_providers/__init__.py index cf2054b3..0f187e4f 100644 --- a/shelfmark/metadata_providers/__init__.py +++ b/shelfmark/metadata_providers/__init__.py @@ -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]] = {} diff --git a/shelfmark/metadata_providers/hardcover.py b/shelfmark/metadata_providers/hardcover.py index 4f6c6874..cf8215ee 100644 --- a/shelfmark/metadata_providers/hardcover.py +++ b/shelfmark/metadata_providers/hardcover.py @@ -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]: diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index a218f640..f18077db 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -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(null); const [actingAsUser, setActingAsUser] = useState(null); const [pendingOnBehalfDownload, setPendingOnBehalfDownload] = useState(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 => { 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 => { @@ -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} /> )} diff --git a/src/frontend/src/components/BookTargetDropdown.tsx b/src/frontend/src/components/BookTargetDropdown.tsx new file mode 100644 index 00000000..eba57aaf --- /dev/null +++ b/src/frontend/src/components/BookTargetDropdown.tsx @@ -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 }) => ( + +); + +const renderSummary = (selectedOptions: DropdownListOption[]) => { + const count = selectedOptions.length; + + return ( + + + Hardcover Lists{count > 0 ? ` (${count})` : ''} + + ); +}; + +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([]); + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [pendingTargets, setPendingTargets] = useState>(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(() => { + 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 ( + + ); + } + : variant === 'icon' + ? ({ toggle }: { isOpen: boolean; toggle: () => void }) => { + const count = selectedValues.length; + return ( + + ); + } + : undefined; + + return ( + renderSummary(selectedOptions)} + renderTrigger={customTrigger} + onOpenChange={onOpenChange} + /> + ); +}; diff --git a/src/frontend/src/components/DetailsModal.tsx b/src/frontend/src/components/DetailsModal.tsx index a6f746b9..4fb7fdbe 100644 --- a/src/frontend/src/components/DetailsModal.tsx +++ b/src/frontend/src/components/DetailsModal.tsx @@ -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))' }} > -
+
{/* Source link - shown for both Universal and Direct Download modes */} {book.source_url && ( View on {isMetadata ? providerDisplay : "Source"} @@ -339,22 +352,32 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea )} - {/* Action button - mirrors search result action state/flow */} - +
+ {hasBookTargets && book.provider_id && ( + + )} + {/* Action button - mirrors search result action state/flow */} + +
diff --git a/src/frontend/src/components/Dropdown.tsx b/src/frontend/src/components/Dropdown.tsx index f1c5d063..4abcef1c 100644 --- a/src/frontend/src/components/Dropdown.tsx +++ b/src/frontend/src/components/Dropdown.tsx @@ -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(null); const panelRef = useRef(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 && (
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 }) => (
diff --git a/src/frontend/src/components/ReleaseModal.tsx b/src/frontend/src/components/ReleaseModal.tsx index 903531cd..3b16c66a 100644 --- a/src/frontend/src/components/ReleaseModal.tsx +++ b/src/frontend/src/components/ReleaseModal.tsx @@ -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'} )} + {bookSupportsTargets(book) && ( + + )}
diff --git a/src/frontend/src/components/ResultsSection.tsx b/src/frontend/src/components/ResultsSection.tsx index ce43831b..bb425151 100644 --- a/src/frontend/src/components/ResultsSection.tsx +++ b/src/frontend/src/components/ResultsSection.tsx @@ -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 (
- - + {showSortControl ? ( + + ) : resultsSourceUrl ? ( + + View list on Hardcover + + + + + ) : null} + {/* View toggle buttons - Desktop: show all 3, Mobile: show Compact and List only */} -
+
{isDesktop && (
{viewMode === 'list' ? ( - + ) : (
) : ( ); })} diff --git a/src/frontend/src/components/SearchBar.tsx b/src/frontend/src/components/SearchBar.tsx index ca35657d..e1772e15 100644 --- a/src/frontend/src/components/SearchBar.tsx +++ b/src/frontend/src/components/SearchBar.tsx @@ -187,6 +187,7 @@ export const SearchBar = forwardRef(({ const selectTriggerRef = useRef(null); const selectPanelRef = useRef(null); const autocompletePanelRef = useRef(null); + const selectorHoverTimeout = useRef | null>(null); const deferredTextInputValue = useDeferredValue(textInputValue); const hasMultipleContentTypes = !allowedContentTypes || allowedContentTypes.length !== 1; @@ -210,9 +211,22 @@ export const SearchBar = forwardRef(({ 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(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(({ onFocus={() => { if (autocompleteEndpoint && textInputValue.trim().length >= autocompleteMinQueryLength) { setIsAutocompleteOpen(true); + setIsSelectorOpen(false); } }} onKeyDown={handleKeyDown} @@ -496,6 +511,7 @@ export const SearchBar = forwardRef(({ if (!disabled && !isDynamicLoading) { setIsSelectOpen((prev) => !prev); setIsSelectorOpen(false); + setIsAutocompleteOpen(false); } }} disabled={disabled} @@ -559,10 +575,28 @@ export const SearchBar = forwardRef(({ }} > {showQueryTargetSelector && ( -
+
{ + 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); + }} + > + +
diff --git a/src/frontend/src/components/resultsViews/CompactView.tsx b/src/frontend/src/components/resultsViews/CompactView.tsx index 2d467b58..73cf4d39 100644 --- a/src/frontend/src/components/resultsViews/CompactView.tsx +++ b/src/frontend/src/components/resultsViews/CompactView.tsx @@ -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 (
setIsHovered(false)} >
- {/* Series position badge */} - {showSeriesPosition && book.series_position != null && ( -
- #{book.series_position} -
- )} - {book.preview && !imageError ? ( - <> - {!imageLoaded && ( -
- -
- )} - {book.title + {/* Series position badge */} + {showSeriesPosition && book.series_position != null && ( +
setImageLoaded(true)} - onError={() => setImageError(true)} - /> - - ) : ( -
- No Cover -
- )} + > + #{book.series_position} +
+ )} + {book.preview && !imageError ? ( + <> + {!imageLoaded && ( +
+ +
+ )} + {book.title setImageLoaded(true)} + onError={() => setImageError(true)} + /> + + ) : ( +
+ No Cover +
+ )} -
+
+
{!showDetailsButton && ( - + +
)}
diff --git a/src/frontend/src/components/resultsViews/ListView.tsx b/src/frontend/src/components/resultsViews/ListView.tsx index 94affbdd..8f10f477 100644 --- a/src/frontend/src/components/resultsViews/ListView.tsx +++ b/src/frontend/src/components/resultsViews/ListView.tsx @@ -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(null); const [releasesLoadingId, setReleasesLoadingId] = useState(null); + const [openDropdownBookId, setOpenDropdownBookId] = useState(null); if (books.length === 0) { return null; @@ -77,7 +81,7 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto return (
+ {bookSupportsTargets(book) && ( + setOpenDropdownBookId(isOpen ? book.id : null)} + /> + )}