mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 18:31:43 +01:00
Various requested small features (#741)
- Added torrent removal option - Pass Prowlarr seedtimes to download clients (excluding rTorrent) - Split default release source option by content type - Split download to browser option by content type - Add "hide links" option
This commit is contained in:
@@ -186,9 +186,11 @@ Default language filter for searches.
|
||||
|----------|-------------|------|---------|
|
||||
| `SEARCH_MODE` | How you want to search for and download books. | string (choice) | `direct` |
|
||||
| `AA_DEFAULT_SORT` | Default sort order for search results. | string (choice) | `relevance` |
|
||||
| `SHOW_RELEASE_SOURCE_LINKS` | Show clickable release-source links in release and details modals. Metadata provider links stay enabled. | boolean | `true` |
|
||||
| `METADATA_PROVIDER` | Choose which metadata provider to use for book searches. | string (choice) | `openlibrary` |
|
||||
| `METADATA_PROVIDER_AUDIOBOOK` | Metadata provider for audiobook searches. Uses the book provider if not set. | string (choice) | _empty string_ |
|
||||
| `DEFAULT_RELEASE_SOURCE` | The release source tab to open by default in the release modal. | string (choice) | `direct_download` |
|
||||
| `DEFAULT_RELEASE_SOURCE` | The release source tab to open by default in the release modal for books. | string (choice) | `direct_download` |
|
||||
| `DEFAULT_RELEASE_SOURCE_AUDIOBOOK` | The release source tab to open by default in the release modal for audiobooks. Uses the book release source if not set. | string (choice) | _empty string_ |
|
||||
|
||||
<details>
|
||||
<summary>Detailed descriptions</summary>
|
||||
@@ -213,6 +215,15 @@ Default sort order for search results.
|
||||
- **Default:** `relevance`
|
||||
- **Options:** `relevance` (Most relevant), `newest` (Newest (publication year)), `oldest` (Oldest (publication year)), `largest` (Largest (filesize)), `smallest` (Smallest (filesize)), `newest_added` (Newest (open sourced)), `oldest_added` (Oldest (open sourced))
|
||||
|
||||
#### `SHOW_RELEASE_SOURCE_LINKS`
|
||||
|
||||
**Show Release Source Links**
|
||||
|
||||
Show clickable release-source links in release and details modals. Metadata provider links stay enabled.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `true`
|
||||
|
||||
#### `METADATA_PROVIDER`
|
||||
|
||||
**Book Metadata Provider**
|
||||
@@ -221,7 +232,7 @@ Choose which metadata provider to use for book searches.
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** `openlibrary`
|
||||
- **Options:** `""` (No providers enabled)
|
||||
- **Options:** `hardcover` (Hardcover), `openlibrary` (Open Library), `googlebooks` (Google Books)
|
||||
|
||||
#### `METADATA_PROVIDER_AUDIOBOOK`
|
||||
|
||||
@@ -231,17 +242,27 @@ Metadata provider for audiobook searches. Uses the book provider if not set.
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** _empty string_
|
||||
- **Options:** `""` (Use book provider), `""` (No providers enabled)
|
||||
- **Options:** `""` (Use book provider), `hardcover` (Hardcover), `openlibrary` (Open Library), `googlebooks` (Google Books)
|
||||
|
||||
#### `DEFAULT_RELEASE_SOURCE`
|
||||
|
||||
**Default Release Source**
|
||||
**Default Book Release Source**
|
||||
|
||||
The release source tab to open by default in the release modal.
|
||||
The release source tab to open by default in the release modal for books.
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** `direct_download`
|
||||
- **Options:** `direct_download` (Direct Download), `prowlarr` (Prowlarr), `audiobookbay` (AudiobookBay)
|
||||
- **Options:** `direct_download` (Direct Download), `prowlarr` (Prowlarr)
|
||||
|
||||
#### `DEFAULT_RELEASE_SOURCE_AUDIOBOOK`
|
||||
|
||||
**Default Audiobook Release Source**
|
||||
|
||||
The release source tab to open by default in the release modal for audiobooks. Uses the book release source if not set.
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** _empty string_
|
||||
- **Options:** `""` (Use book release source), `prowlarr` (Prowlarr), `audiobookbay` (AudiobookBay)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -278,7 +299,7 @@ The release source tab to open by default in the release modal.
|
||||
| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}` |
|
||||
| `HARDLINK_TORRENTS_AUDIOBOOK` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `true` |
|
||||
| `AUTO_OPEN_DOWNLOADS_SIDEBAR` | Automatically open the downloads sidebar when a new download is queued. | boolean | `false` |
|
||||
| `DOWNLOAD_TO_BROWSER` | Automatically download completed files to your browser. | boolean | `false` |
|
||||
| `DOWNLOAD_TO_BROWSER_CONTENT_TYPES` | Automatically download completed files to your browser for the selected content types. | string (comma-separated) | _empty list_ |
|
||||
| `MAX_CONCURRENT_DOWNLOADS` | Maximum number of simultaneous downloads. | number | `3` |
|
||||
| `STATUS_TIMEOUT` | How long to keep completed/failed downloads in the queue display. | number | `3600` |
|
||||
|
||||
@@ -561,14 +582,14 @@ Automatically open the downloads sidebar when a new download is queued.
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
#### `DOWNLOAD_TO_BROWSER`
|
||||
#### `DOWNLOAD_TO_BROWSER_CONTENT_TYPES`
|
||||
|
||||
**Download to Browser**
|
||||
|
||||
Automatically download completed files to your browser.
|
||||
Automatically download completed files to your browser for the selected content types.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
- **Type:** string (comma-separated)
|
||||
- **Default:** _empty list_
|
||||
|
||||
#### `MAX_CONCURRENT_DOWNLOADS`
|
||||
|
||||
@@ -1249,6 +1270,7 @@ How long to keep cached search results before they expire.
|
||||
| `RTORRENT_PASSWORD` | HTTP Basic auth password | string (secret) | _none_ |
|
||||
| `RTORRENT_LABEL` | Label to assign to book downloads in rTorrent | string | `cwabd` |
|
||||
| `RTORRENT_DOWNLOAD_DIR` | Server-side directory where torrents are downloaded (optional, uses rTorrent default if not specified) | string | _none_ |
|
||||
| `PROWLARR_TORRENT_ACTION` | Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding | string (choice) | `keep` |
|
||||
| `PROWLARR_USENET_CLIENT` | Choose which usenet client to use | string (choice) | _empty string_ |
|
||||
| `NZBGET_URL` | URL of your NZBGet instance | string | _none_ |
|
||||
| `NZBGET_USERNAME` | NZBGet control username | string | `nzbget` |
|
||||
@@ -1490,6 +1512,16 @@ Server-side directory where torrents are downloaded (optional, uses rTorrent def
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
|
||||
#### `PROWLARR_TORRENT_ACTION`
|
||||
|
||||
**Torrent Completion Action**
|
||||
|
||||
Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** `keep`
|
||||
- **Options:** `keep` (Keep), `remove` (Remove)
|
||||
|
||||
#### `PROWLARR_USENET_CLIENT`
|
||||
|
||||
**Usenet Client**
|
||||
|
||||
@@ -185,6 +185,23 @@ _AUDIOBOOK_FORMAT_OPTIONS = [
|
||||
{"value": "rar", "label": "RAR"},
|
||||
]
|
||||
|
||||
_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_OPTIONS = [
|
||||
{
|
||||
"value": "book",
|
||||
"label": "Books",
|
||||
"description": "Automatically download completed book files to this browser.",
|
||||
},
|
||||
{
|
||||
"value": "audiobook",
|
||||
"label": "Audiobooks",
|
||||
"description": "Automatically download completed audiobook files to this browser.",
|
||||
},
|
||||
]
|
||||
|
||||
_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_VALUES = {
|
||||
option["value"] for option in _DOWNLOAD_TO_BROWSER_CONTENT_TYPE_OPTIONS
|
||||
}
|
||||
|
||||
|
||||
def _get_metadata_provider_options():
|
||||
"""Build metadata provider options dynamically from enabled providers only."""
|
||||
@@ -210,17 +227,30 @@ def _get_metadata_provider_options_with_none():
|
||||
return [{"value": "", "label": "Use book provider"}] + _get_metadata_provider_options()
|
||||
|
||||
|
||||
def _get_release_source_options():
|
||||
"""Build release source options dynamically from registered sources."""
|
||||
def _get_release_source_options_for_content_type(content_type: str):
|
||||
"""Build release source options dynamically for a specific content type."""
|
||||
from shelfmark.release_sources import list_available_sources
|
||||
|
||||
return [
|
||||
{"value": source["name"], "label": source["display_name"]}
|
||||
for source in list_available_sources()
|
||||
if source.get("can_be_default", True)
|
||||
and content_type in source.get("supported_content_types", ["ebook", "audiobook"])
|
||||
]
|
||||
|
||||
|
||||
def _get_book_release_source_options():
|
||||
"""Build default release source options for book searches."""
|
||||
return _get_release_source_options_for_content_type("ebook")
|
||||
|
||||
|
||||
def _get_audiobook_release_source_options():
|
||||
"""Build default release source options for audiobook searches."""
|
||||
return [{"value": "", "label": "Use book release source"}] + _get_release_source_options_for_content_type(
|
||||
"audiobook"
|
||||
)
|
||||
|
||||
|
||||
|
||||
_LANGUAGE_OPTIONS = [{"value": lang["code"], "label": lang["language"]} for lang in _SUPPORTED_BOOK_LANGUAGE]
|
||||
|
||||
@@ -429,6 +459,15 @@ def search_mode_settings():
|
||||
default="relevance",
|
||||
show_when={"field": "SEARCH_MODE", "value": "direct"},
|
||||
),
|
||||
CheckboxField(
|
||||
key="SHOW_RELEASE_SOURCE_LINKS",
|
||||
label="Show Release Source Links",
|
||||
description=(
|
||||
"Show clickable release-source links in release and details modals. "
|
||||
"Metadata provider links stay enabled."
|
||||
),
|
||||
default=True,
|
||||
),
|
||||
HeadingField(
|
||||
key="universal_mode_heading",
|
||||
title="Universal Mode Settings",
|
||||
@@ -455,13 +494,22 @@ def search_mode_settings():
|
||||
),
|
||||
SelectField(
|
||||
key="DEFAULT_RELEASE_SOURCE",
|
||||
label="Default Release Source",
|
||||
description="The release source tab to open by default in the release modal.",
|
||||
options=_get_release_source_options, # Callable - evaluated lazily to avoid circular imports
|
||||
label="Default Book Release Source",
|
||||
description="The release source tab to open by default in the release modal for books.",
|
||||
options=_get_book_release_source_options, # Callable - evaluated lazily to avoid circular imports
|
||||
default="direct_download",
|
||||
show_when={"field": "SEARCH_MODE", "value": "universal"},
|
||||
user_overridable=True,
|
||||
),
|
||||
SelectField(
|
||||
key="DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
|
||||
label="Default Audiobook Release Source",
|
||||
description="The release source tab to open by default in the release modal for audiobooks. Uses the book release source if not set.",
|
||||
options=_get_audiobook_release_source_options, # Callable - evaluated lazily to avoid circular imports
|
||||
default="",
|
||||
show_when={"field": "SEARCH_MODE", "value": "universal"},
|
||||
user_overridable=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -619,6 +667,41 @@ def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]:
|
||||
effective: dict[str, Any] = dict(existing)
|
||||
effective.update(values)
|
||||
|
||||
if "DOWNLOAD_TO_BROWSER_CONTENT_TYPES" in effective:
|
||||
raw_content_types = effective.get("DOWNLOAD_TO_BROWSER_CONTENT_TYPES")
|
||||
if raw_content_types is None:
|
||||
normalized_content_types: list[str] = []
|
||||
elif isinstance(raw_content_types, list):
|
||||
normalized_content_types = [
|
||||
str(value).strip().lower()
|
||||
for value in raw_content_types
|
||||
if str(value).strip()
|
||||
]
|
||||
else:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "Download to Browser must be a list.",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
deduped_content_types: list[str] = []
|
||||
for content_type in normalized_content_types:
|
||||
if content_type not in _DOWNLOAD_TO_BROWSER_CONTENT_TYPE_VALUES:
|
||||
allowed = ", ".join(sorted(_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_VALUES))
|
||||
return {
|
||||
"error": True,
|
||||
"message": (
|
||||
"Download to Browser contains an unsupported content type "
|
||||
f"'{content_type}'. Supported values: {allowed}"
|
||||
),
|
||||
"values": values,
|
||||
}
|
||||
if content_type not in deduped_content_types:
|
||||
deduped_content_types.append(content_type)
|
||||
|
||||
values["DOWNLOAD_TO_BROWSER_CONTENT_TYPES"] = deduped_content_types
|
||||
effective["DOWNLOAD_TO_BROWSER_CONTENT_TYPES"] = deduped_content_types
|
||||
|
||||
# Books: only validate templates when saving to a folder.
|
||||
books_output_mode = effective.get("BOOKS_OUTPUT_MODE", "folder")
|
||||
if books_output_mode == "folder" and effective.get("FILE_ORGANIZATION", "rename") == "rename":
|
||||
@@ -1106,11 +1189,14 @@ def download_settings():
|
||||
description="Automatically open the downloads sidebar when a new download is queued.",
|
||||
default=False,
|
||||
),
|
||||
CheckboxField(
|
||||
key="DOWNLOAD_TO_BROWSER",
|
||||
MultiSelectField(
|
||||
key="DOWNLOAD_TO_BROWSER_CONTENT_TYPES",
|
||||
label="Download to Browser",
|
||||
description="Automatically download completed files to your browser.",
|
||||
default=False,
|
||||
description="Automatically download completed files to your browser for the selected content types.",
|
||||
options=_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_OPTIONS,
|
||||
default=[],
|
||||
variant="dropdown",
|
||||
user_overridable=True,
|
||||
),
|
||||
NumberField(
|
||||
key="MAX_CONCURRENT_DOWNLOADS",
|
||||
|
||||
@@ -76,6 +76,7 @@ _SEARCH_PREFERENCE_PROVIDER_KEYS = {"METADATA_PROVIDER", "METADATA_PROVIDER_AUDI
|
||||
_SEARCH_PREFERENCE_VALIDATABLE_KEYS = {
|
||||
"SEARCH_MODE",
|
||||
"DEFAULT_RELEASE_SOURCE",
|
||||
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
|
||||
*_SEARCH_PREFERENCE_PROVIDER_KEYS,
|
||||
}
|
||||
|
||||
@@ -117,6 +118,18 @@ def _get_request_source_options():
|
||||
return options
|
||||
|
||||
|
||||
def _get_valid_release_source_names_for_content_type(content_type: str) -> set[str]:
|
||||
"""Return registered release source names that support the requested content type."""
|
||||
from shelfmark.release_sources import list_available_sources
|
||||
|
||||
valid_sources: set[str] = set()
|
||||
for source in list_available_sources():
|
||||
supported_types = source.get("supported_content_types", ["ebook", "audiobook"])
|
||||
if content_type in supported_types:
|
||||
valid_sources.add(source["name"])
|
||||
return valid_sources
|
||||
|
||||
|
||||
def _get_request_policy_rule_columns():
|
||||
source_capabilities = get_source_content_type_capabilities()
|
||||
content_type_options = []
|
||||
@@ -189,16 +202,16 @@ def validate_search_preference_value(key: str, value: Any) -> tuple[Any, str | N
|
||||
)
|
||||
return normalized_value, None
|
||||
|
||||
if key == "DEFAULT_RELEASE_SOURCE":
|
||||
if key in {"DEFAULT_RELEASE_SOURCE", "DEFAULT_RELEASE_SOURCE_AUDIOBOOK"}:
|
||||
if normalized_value == "":
|
||||
return "", None
|
||||
from shelfmark.release_sources import list_available_sources
|
||||
|
||||
valid_sources = {source["name"] for source in list_available_sources()}
|
||||
valid_sources = _get_valid_release_source_names_for_content_type(
|
||||
"audiobook" if key == "DEFAULT_RELEASE_SOURCE_AUDIOBOOK" else "ebook"
|
||||
)
|
||||
if normalized_value not in valid_sources:
|
||||
return (
|
||||
value,
|
||||
"DEFAULT_RELEASE_SOURCE must be a valid release source name or empty",
|
||||
f"{key} must be a valid release source name or empty",
|
||||
)
|
||||
return normalized_value, None
|
||||
|
||||
|
||||
@@ -78,10 +78,39 @@ def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], li
|
||||
"METADATA_PROVIDER",
|
||||
"METADATA_PROVIDER_AUDIOBOOK",
|
||||
"DEFAULT_RELEASE_SOURCE",
|
||||
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
|
||||
}:
|
||||
valid[key] = normalized_search_value
|
||||
continue
|
||||
|
||||
if key == "DOWNLOAD_TO_BROWSER_CONTENT_TYPES":
|
||||
if not isinstance(value, list):
|
||||
errors.append(f"Invalid value for {key}: must be a list")
|
||||
continue
|
||||
|
||||
candidate_values = [
|
||||
str(entry).strip().lower()
|
||||
for entry in value
|
||||
if str(entry).strip()
|
||||
]
|
||||
normalized_values: list[str] = []
|
||||
has_invalid_value = False
|
||||
for entry in candidate_values:
|
||||
if entry not in {"book", "audiobook"}:
|
||||
errors.append(
|
||||
f"Invalid value for {key}: unsupported content type '{entry}'"
|
||||
)
|
||||
has_invalid_value = True
|
||||
continue
|
||||
if entry not in normalized_values:
|
||||
normalized_values.append(entry)
|
||||
|
||||
if has_invalid_value:
|
||||
continue
|
||||
|
||||
valid[key] = normalized_values
|
||||
continue
|
||||
|
||||
valid[key] = value
|
||||
|
||||
return valid, errors
|
||||
|
||||
@@ -481,6 +481,7 @@ def sync_env_to_config() -> None:
|
||||
logger.debug(f"Synced {len(values_to_sync)} ENV values to {tab.name} config: {list(values_to_sync.keys())}")
|
||||
|
||||
migrate_legacy_settings()
|
||||
migrate_download_to_browser_settings()
|
||||
migrate_mirror_settings()
|
||||
|
||||
|
||||
@@ -691,6 +692,57 @@ def migrate_legacy_settings() -> None:
|
||||
logger.info(f"Migrated content-type routing settings: {list(migrated_sources.keys())}")
|
||||
|
||||
|
||||
def migrate_download_to_browser_settings() -> None:
|
||||
"""Migrate the legacy download-to-browser toggle to content-type selection."""
|
||||
downloads_config = load_config_file("downloads")
|
||||
legacy_key = "DOWNLOAD_TO_BROWSER"
|
||||
new_key = "DOWNLOAD_TO_BROWSER_CONTENT_TYPES"
|
||||
config_path = _get_config_file_path("downloads")
|
||||
|
||||
legacy_value: Any = None
|
||||
legacy_present = False
|
||||
|
||||
if legacy_key in downloads_config:
|
||||
legacy_value = downloads_config.get(legacy_key)
|
||||
legacy_present = True
|
||||
elif new_key not in downloads_config and os.environ.get(new_key) is None and legacy_key in os.environ:
|
||||
legacy_value = os.environ.get(legacy_key)
|
||||
legacy_present = True
|
||||
|
||||
if not legacy_present and legacy_key not in downloads_config:
|
||||
return
|
||||
|
||||
updated_downloads = dict(downloads_config)
|
||||
changed = False
|
||||
|
||||
if new_key not in updated_downloads and legacy_present:
|
||||
enabled = False
|
||||
if isinstance(legacy_value, bool):
|
||||
enabled = legacy_value
|
||||
elif isinstance(legacy_value, str):
|
||||
enabled = legacy_value.strip().lower() in {"true", "1", "yes", "on"}
|
||||
else:
|
||||
enabled = bool(legacy_value)
|
||||
|
||||
updated_downloads[new_key] = ["book", "audiobook"] if enabled else []
|
||||
changed = True
|
||||
|
||||
if legacy_key in updated_downloads:
|
||||
updated_downloads.pop(legacy_key, None)
|
||||
changed = True
|
||||
|
||||
if not changed:
|
||||
return
|
||||
|
||||
try:
|
||||
_ensure_config_dir("downloads")
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(updated_downloads, f, indent=2)
|
||||
logger.info("Migrated download-to-browser setting to content-type selection")
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to migrate download-to-browser settings: {exc}")
|
||||
|
||||
|
||||
def get_setting_value(field: SettingsField, tab_name: str) -> Any:
|
||||
if isinstance(field, (ActionButton, HeadingField, CustomComponentField)):
|
||||
return None # Actions and headings don't have values
|
||||
|
||||
@@ -38,6 +38,8 @@ class DownloadRequest:
|
||||
protocol: str
|
||||
release_name: str
|
||||
expected_hash: Optional[str]
|
||||
seeding_time_limit: Optional[int] = None # minutes
|
||||
ratio_limit: Optional[float] = None
|
||||
|
||||
|
||||
def _diagnose_path_issue(path: str) -> str:
|
||||
@@ -140,20 +142,28 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
return
|
||||
|
||||
client, download_id, protocol = client_ref
|
||||
if protocol != "usenet":
|
||||
return
|
||||
|
||||
# "Move" means copy into ingest then let the usenet client delete its own files.
|
||||
if config.get("PROWLARR_USENET_ACTION", "move") != "move":
|
||||
return
|
||||
if protocol == "usenet":
|
||||
# "Move" means copy into ingest then let the usenet client delete its own files.
|
||||
if config.get("PROWLARR_USENET_ACTION", "move") != "move":
|
||||
return
|
||||
try:
|
||||
self._delete_local_download_data(client, download_id)
|
||||
self._remove_usenet_download(client, download_id, delete_files=True, archive=True)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to cleanup usenet download {download_id} in {getattr(client, 'name', 'client')}: {e}"
|
||||
)
|
||||
|
||||
try:
|
||||
self._delete_local_download_data(client, download_id)
|
||||
self._remove_usenet_download(client, download_id, delete_files=True, archive=True)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to cleanup usenet download {download_id} in {getattr(client, 'name', 'client')}: {e}"
|
||||
)
|
||||
elif protocol == "torrent":
|
||||
if config.get("PROWLARR_TORRENT_ACTION", "keep") != "remove":
|
||||
return
|
||||
try:
|
||||
client.remove(download_id, delete_files=False)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to remove torrent {download_id} from {getattr(client, 'name', 'client')}: {e}"
|
||||
)
|
||||
|
||||
def _remove_usenet_download(
|
||||
self,
|
||||
@@ -553,6 +563,8 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
name=request.release_name,
|
||||
category=category,
|
||||
expected_hash=request.expected_hash,
|
||||
seeding_time_limit=request.seeding_time_limit,
|
||||
ratio_limit=request.ratio_limit,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add to {client.name}: {e}")
|
||||
|
||||
@@ -237,6 +237,15 @@ class DelugeClient(DownloadClient):
|
||||
if self._download_dir:
|
||||
options["download_location"] = self._download_dir
|
||||
|
||||
# Per-torrent seeding limits from indexer
|
||||
seeding_time_limit = kwargs.get("seeding_time_limit")
|
||||
if seeding_time_limit is not None:
|
||||
options["seed_time_limit"] = int(seeding_time_limit)
|
||||
ratio_limit = kwargs.get("ratio_limit")
|
||||
if ratio_limit is not None:
|
||||
options["stop_at_ratio"] = float(ratio_limit)
|
||||
options["stop_at_ratio_enabled"] = True
|
||||
|
||||
if torrent_info.is_magnet:
|
||||
magnet_url = torrent_info.magnet_url or url
|
||||
torrent_id = self._rpc_call("core.add_torrent_magnet", magnet_url, options)
|
||||
|
||||
@@ -61,6 +61,23 @@ def _normalize_tags(raw_tags: object) -> list[str]:
|
||||
return tags
|
||||
|
||||
|
||||
def _normalize_add_result(raw_result: object) -> str:
|
||||
"""Normalize qBittorrent add responses to a comparable string."""
|
||||
if raw_result is None:
|
||||
return ""
|
||||
|
||||
if isinstance(raw_result, bytes):
|
||||
return raw_result.decode("utf-8", errors="replace").strip()
|
||||
|
||||
return str(raw_result).strip()
|
||||
|
||||
|
||||
def _is_explicit_add_failure(raw_result: object) -> bool:
|
||||
"""Detect add responses that clearly indicate failure."""
|
||||
normalized = _normalize_add_result(raw_result).rstrip(".").lower()
|
||||
return normalized in {"fail", "fails", "error", "errors"}
|
||||
|
||||
|
||||
@register_client("torrent")
|
||||
class QBittorrentClient(DownloadClient):
|
||||
"""qBittorrent download client."""
|
||||
@@ -291,13 +308,16 @@ class QBittorrentClient(DownloadClient):
|
||||
tags = self._tags
|
||||
|
||||
# Ensure category exists (may already exist, which is fine)
|
||||
try:
|
||||
self._client.torrents_create_category(name=category)
|
||||
except Exception as e:
|
||||
# Conflict409Error means category exists - that's expected
|
||||
# Log other errors but continue since download may still work
|
||||
if "Conflict" not in type(e).__name__ and "409" not in str(e):
|
||||
logger.debug(f"Could not create category '{category}': {type(e).__name__}: {e}")
|
||||
if category:
|
||||
try:
|
||||
self._client.torrents_create_category(name=category)
|
||||
except Exception as e:
|
||||
# Conflict409Error means category exists - that's expected
|
||||
# Log other errors but continue since download may still work
|
||||
if "Conflict" not in type(e).__name__ and "409" not in str(e):
|
||||
logger.debug(
|
||||
f"Could not create category '{category}': {type(e).__name__}: {e}"
|
||||
)
|
||||
|
||||
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
|
||||
expected_hash = torrent_info.info_hash
|
||||
@@ -305,14 +325,23 @@ class QBittorrentClient(DownloadClient):
|
||||
|
||||
# Add the torrent - use file content if we have it, otherwise URL
|
||||
add_kwargs = {
|
||||
"category": category,
|
||||
"rename": name,
|
||||
}
|
||||
if category:
|
||||
add_kwargs["category"] = category
|
||||
if self._download_dir:
|
||||
add_kwargs["save_path"] = self._download_dir
|
||||
if tags:
|
||||
add_kwargs["tags"] = ",".join(tags)
|
||||
|
||||
# Per-torrent seeding limits from indexer
|
||||
seeding_time_limit = kwargs.get("seeding_time_limit")
|
||||
if seeding_time_limit is not None:
|
||||
add_kwargs["seeding_time_limit"] = int(seeding_time_limit)
|
||||
ratio_limit = kwargs.get("ratio_limit")
|
||||
if ratio_limit is not None:
|
||||
add_kwargs["ratio_limit"] = float(ratio_limit)
|
||||
|
||||
if torrent_data:
|
||||
result = self._client.torrents_add(
|
||||
torrent_files=torrent_data,
|
||||
@@ -326,29 +355,32 @@ class QBittorrentClient(DownloadClient):
|
||||
**add_kwargs,
|
||||
)
|
||||
|
||||
logger.debug(f"qBittorrent add result: {result}")
|
||||
result_text = _normalize_add_result(result)
|
||||
logger.debug(f"qBittorrent add result: {result_text}")
|
||||
|
||||
if result == "Ok.":
|
||||
if not expected_hash:
|
||||
raise Exception("Could not determine torrent hash from URL")
|
||||
if not expected_hash:
|
||||
raise Exception("Could not determine torrent hash from URL")
|
||||
|
||||
# Wait for torrent to appear in client.
|
||||
# Use `/torrents/properties?hash=` rather than relying on `torrents/info`
|
||||
# listing being immediately consistent.
|
||||
for _ in range(10):
|
||||
loaded, error = self._is_torrent_loaded(expected_hash)
|
||||
if error:
|
||||
logger.debug(f"qBittorrent add_download: {error}")
|
||||
if loaded:
|
||||
logger.info(f"Added torrent: {expected_hash}")
|
||||
return expected_hash.lower()
|
||||
time.sleep(0.5)
|
||||
if _is_explicit_add_failure(result):
|
||||
raise Exception(f"Failed to add torrent: {result_text}")
|
||||
|
||||
# Client said Ok, trust it
|
||||
logger.warning(f"Torrent not yet visible, returning expected hash")
|
||||
return expected_hash
|
||||
# Some qBittorrent-compatible clients return HTTP 200 with an empty body
|
||||
# instead of qBittorrent's literal "Ok." response. Prefer verifying that
|
||||
# the torrent becomes visible over trusting the response body alone.
|
||||
for _ in range(10):
|
||||
loaded, error = self._is_torrent_loaded(expected_hash)
|
||||
if error:
|
||||
logger.debug(f"qBittorrent add_download: {error}")
|
||||
if loaded:
|
||||
logger.info(f"Added torrent: {expected_hash}")
|
||||
return expected_hash.lower()
|
||||
time.sleep(0.5)
|
||||
|
||||
raise Exception(f"Failed to add torrent: {result}")
|
||||
logger.warning(
|
||||
"Torrent add was not confirmed within the visibility grace period "
|
||||
f"(response={result_text or '<empty>'}), returning expected hash"
|
||||
)
|
||||
return expected_hash
|
||||
except Exception as e:
|
||||
logger.error(f"qBittorrent add failed: {e}")
|
||||
raise
|
||||
|
||||
@@ -610,7 +610,17 @@ def prowlarr_clients_settings():
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
|
||||
),
|
||||
# Note: Torrent client download path must be mounted identically in both containers.
|
||||
# Torrents are always copied (not moved) to preserve seeding capability.
|
||||
SelectField(
|
||||
key="PROWLARR_TORRENT_ACTION",
|
||||
label="Torrent Completion Action",
|
||||
description="Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding",
|
||||
options=[
|
||||
{"value": "keep", "label": "Keep"},
|
||||
{"value": "remove", "label": "Remove"},
|
||||
],
|
||||
default="keep",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True},
|
||||
),
|
||||
|
||||
# --- Usenet Client Selection ---
|
||||
HeadingField(
|
||||
|
||||
@@ -188,6 +188,22 @@ class TransmissionClient(DownloadClient):
|
||||
torrent_hash = torrent.hashString.lower()
|
||||
logger.info(f"Added torrent to Transmission: {torrent_hash}")
|
||||
|
||||
# Apply per-torrent seeding limits from indexer
|
||||
seed_kwargs = {}
|
||||
seeding_time_limit = kwargs.get("seeding_time_limit")
|
||||
if seeding_time_limit is not None:
|
||||
seed_kwargs["seed_idle_limit"] = int(seeding_time_limit)
|
||||
seed_kwargs["seed_idle_mode"] = 1 # per-torrent
|
||||
ratio_limit = kwargs.get("ratio_limit")
|
||||
if ratio_limit is not None:
|
||||
seed_kwargs["seed_ratio_limit"] = float(ratio_limit)
|
||||
seed_kwargs["seed_ratio_mode"] = 1 # per-torrent
|
||||
if seed_kwargs:
|
||||
try:
|
||||
self._client.change_torrent(ids=torrent_hash, **seed_kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to set seeding limits for {torrent_hash}: {e}")
|
||||
|
||||
return torrent_hash
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -88,6 +88,7 @@ def run_blocking_io(func: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
||||
|
||||
|
||||
_VERIFY_IO_WAIT_SECONDS = 3.0
|
||||
_PUBLISH_VERIFY_RETRY_SECONDS = 0.25
|
||||
|
||||
|
||||
def _verify_transfer_size(
|
||||
@@ -119,6 +120,44 @@ def _verify_transfer_size(
|
||||
)
|
||||
|
||||
|
||||
def _is_stale_handle_error(error: Exception) -> bool:
|
||||
return isinstance(error, OSError) and error.errno == getattr(errno, "ESTALE", 116)
|
||||
|
||||
|
||||
def _verify_published_file(
|
||||
dest: Path,
|
||||
expected_size: int,
|
||||
action: str,
|
||||
) -> None:
|
||||
"""Best-effort verify after publishing a temp file into place.
|
||||
|
||||
The temp file was already verified before publish. Some NFS mounts can report
|
||||
a transient stale handle immediately after `os.replace()` makes the final path
|
||||
visible, so retry once and then trust the successful publish instead of
|
||||
turning the handoff into a false failure.
|
||||
"""
|
||||
try:
|
||||
_verify_transfer_size(dest, expected_size, action)
|
||||
return
|
||||
except OSError as error:
|
||||
if not _is_stale_handle_error(error):
|
||||
raise
|
||||
|
||||
time.sleep(_PUBLISH_VERIFY_RETRY_SECONDS)
|
||||
|
||||
try:
|
||||
_verify_transfer_size(dest, expected_size, action)
|
||||
except OSError as retry_error:
|
||||
if not _is_stale_handle_error(retry_error):
|
||||
raise
|
||||
logger.warning(
|
||||
"Skipping post-publish verification for %s after stale handle on %s: %s",
|
||||
action,
|
||||
dest,
|
||||
retry_error,
|
||||
)
|
||||
|
||||
|
||||
def atomic_write(dest_path: Path, data: bytes, max_attempts: int = 100) -> Path:
|
||||
"""Write data to a file with atomic collision detection.
|
||||
|
||||
@@ -417,7 +456,7 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
continue
|
||||
|
||||
try:
|
||||
_verify_transfer_size(try_path, expected_size, "move")
|
||||
_verify_published_file(try_path, expected_size, "move")
|
||||
except Exception:
|
||||
run_blocking_io(try_path.unlink, missing_ok=True)
|
||||
raise
|
||||
@@ -594,7 +633,7 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
continue
|
||||
|
||||
try:
|
||||
_verify_transfer_size(try_path, expected_size, "copy")
|
||||
_verify_published_file(try_path, expected_size, "copy")
|
||||
except Exception:
|
||||
run_blocking_io(try_path.unlink, missing_ok=True)
|
||||
raise
|
||||
|
||||
+12
-1
@@ -981,6 +981,11 @@ def api_config() -> Union[Response, Tuple[Response, int]]:
|
||||
"direct_download",
|
||||
user_id=db_user_id,
|
||||
)
|
||||
default_release_source_audiobook = app_config.get(
|
||||
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
|
||||
"",
|
||||
user_id=db_user_id,
|
||||
)
|
||||
configured_metadata_provider = app_config.get(
|
||||
"METADATA_PROVIDER",
|
||||
"",
|
||||
@@ -1007,9 +1012,15 @@ def api_config() -> Union[Response, Tuple[Response, int]]:
|
||||
"metadata_sort_options": get_provider_sort_options(metadata_ui_provider),
|
||||
"metadata_search_fields": get_provider_search_fields(metadata_ui_provider),
|
||||
"default_release_source": default_release_source,
|
||||
"default_release_source_audiobook": default_release_source_audiobook,
|
||||
"show_release_source_links": app_config.get("SHOW_RELEASE_SOURCE_LINKS", True),
|
||||
"books_output_mode": app_config.get("BOOKS_OUTPUT_MODE", "folder"),
|
||||
"auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", True),
|
||||
"download_to_browser": app_config.get("DOWNLOAD_TO_BROWSER", False),
|
||||
"download_to_browser_content_types": app_config.get(
|
||||
"DOWNLOAD_TO_BROWSER_CONTENT_TYPES",
|
||||
[],
|
||||
user_id=db_user_id,
|
||||
),
|
||||
"settings_enabled": _is_config_dir_writable(),
|
||||
"onboarding_complete": _get_onboarding_complete(),
|
||||
# Default sort orders
|
||||
|
||||
@@ -74,11 +74,19 @@ class ProwlarrHandler(ExternalClientHandler):
|
||||
release_name = prowlarr_result.get("title") or task.title or "Unknown"
|
||||
expected_hash = str(prowlarr_result.get("infoHash") or "").strip() or None
|
||||
|
||||
# Seed criteria from the indexer (Torznab attributes)
|
||||
raw_seed_time = prowlarr_result.get("minimumSeedTime")
|
||||
seeding_time_limit = int(raw_seed_time) if raw_seed_time is not None else None
|
||||
raw_ratio = prowlarr_result.get("minimumRatio")
|
||||
ratio_limit = float(raw_ratio) if raw_ratio is not None else None
|
||||
|
||||
return DownloadRequest(
|
||||
url=download_url,
|
||||
protocol=protocol,
|
||||
release_name=release_name,
|
||||
expected_hash=expected_hash,
|
||||
seeding_time_limit=seeding_time_limit,
|
||||
ratio_limit=ratio_limit,
|
||||
)
|
||||
|
||||
def _on_download_complete(self, task: DownloadTask) -> None:
|
||||
|
||||
@@ -556,6 +556,16 @@ function App() {
|
||||
const detectChanges = useCallback((prev: StatusData, curr: StatusData) => {
|
||||
if (!prev || Object.keys(prev).length === 0) return;
|
||||
|
||||
const autoDownloadContentTypes = Array.isArray(config?.download_to_browser_content_types)
|
||||
? config.download_to_browser_content_types
|
||||
: [];
|
||||
const canAutoDownloadContentType = (contentType?: string): boolean => {
|
||||
const contentTypeKey = String(contentType || '').trim().toLowerCase() === 'audiobook'
|
||||
? 'audiobook'
|
||||
: 'book';
|
||||
return autoDownloadContentTypes.includes(contentTypeKey);
|
||||
};
|
||||
|
||||
// Check for new items in queue
|
||||
const prevQueued = prev.queued || {};
|
||||
const currQueued = curr.queued || {};
|
||||
@@ -592,7 +602,7 @@ function App() {
|
||||
showToast(`${book.title || 'Book'} completed`, 'success');
|
||||
|
||||
// Auto-download to browser if enabled
|
||||
if (config?.download_to_browser && book.download_path) {
|
||||
if (book.download_path && canAutoDownloadContentType(book.content_type)) {
|
||||
const link = document.createElement('a');
|
||||
link.href = withBasePath(`/api/localdownload?id=${encodeURIComponent(bookId)}`);
|
||||
link.download = '';
|
||||
@@ -2232,6 +2242,7 @@ function App() {
|
||||
? getUniversalActionButtonState(selectedBook.id)
|
||||
: getDirectActionButtonState(selectedBook.id)
|
||||
}
|
||||
showReleaseSourceLinks={config?.show_release_source_links !== false}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2255,9 +2266,11 @@ function App() {
|
||||
bookLanguages={bookLanguages}
|
||||
currentStatus={statusForButtonState}
|
||||
defaultReleaseSource={config?.default_release_source}
|
||||
defaultAudiobookReleaseSource={config?.default_release_source_audiobook}
|
||||
onSearchSeries={isBrowseFulfilMode || !canSearchSeriesForBook(activeReleaseBook) ? undefined : handleSearchSeries}
|
||||
defaultShowManualQuery={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'}
|
||||
isRequestMode={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'}
|
||||
showReleaseSourceLinks={config?.show_release_source_links !== false}
|
||||
onShowToast={showToast}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -194,7 +194,7 @@ export const AdvancedFilters = ({
|
||||
return renderWrapper ? (
|
||||
renderWrapper(settingsForm)
|
||||
) : (
|
||||
<div className="w-full border-b-hairline pt-6 pb-4 mb-4" style={{ borderColor: 'var(--border-muted)' }}>
|
||||
<div className="w-full border-b pt-6 pb-4 mb-4" style={{ borderColor: 'var(--border-muted)' }}>
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8">{settingsForm}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -73,7 +73,7 @@ export const ConfigSetupBanner = ({
|
||||
{/* Modal */}
|
||||
<div
|
||||
className={`relative w-full max-w-lg rounded-xl
|
||||
border-hairline border-(--border-muted) shadow-2xl
|
||||
border border-(--border-muted) shadow-2xl
|
||||
overflow-hidden
|
||||
${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
|
||||
style={{ background: 'var(--bg)' }}
|
||||
@@ -82,7 +82,7 @@ export const ConfigSetupBanner = ({
|
||||
aria-label="Settings Setup Information"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b-hairline border-(--border-muted)">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-(--border-muted)">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{showContinueButton ? 'Config Volume Required' : 'New Feature: Settings Page'}
|
||||
</h2>
|
||||
@@ -113,8 +113,8 @@ export const ConfigSetupBanner = ({
|
||||
</p>
|
||||
|
||||
{/* Code snippet */}
|
||||
<div className="rounded-lg overflow-hidden border-hairline border-(--border-muted)">
|
||||
<div className="px-3 py-1.5 text-xs font-medium opacity-60 border-b-hairline border-(--border-muted)"
|
||||
<div className="rounded-lg overflow-hidden border border-(--border-muted)">
|
||||
<div className="px-3 py-1.5 text-xs font-medium opacity-60 border-b border-(--border-muted)"
|
||||
style={{ background: 'var(--bg-soft)' }}>
|
||||
docker-compose.yml
|
||||
</div>
|
||||
@@ -139,13 +139,13 @@ export const ConfigSetupBanner = ({
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-4 border-t-hairline border-(--border-muted) flex justify-end gap-3">
|
||||
<div className="px-5 py-4 border-t border-(--border-muted) flex justify-end gap-3">
|
||||
{showContinueButton ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium
|
||||
bg-(--bg-soft) border-hairline border-(--border-muted)
|
||||
bg-(--bg-soft) border border-(--border-muted)
|
||||
hover:bg-(--hover-surface) transition-colors"
|
||||
>
|
||||
Close
|
||||
|
||||
@@ -12,6 +12,7 @@ interface DetailsModalProps {
|
||||
onFindDownloads?: (book: Book) => void; // For Universal mode
|
||||
onSearchSeries?: (seriesName: string, seriesId?: string) => void; // Callback to search for series
|
||||
buttonState: ButtonStateInfo;
|
||||
showReleaseSourceLinks?: boolean;
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
}
|
||||
|
||||
@@ -22,6 +23,7 @@ export const DetailsModal = ({
|
||||
onFindDownloads,
|
||||
onSearchSeries,
|
||||
buttonState,
|
||||
showReleaseSourceLinks = true,
|
||||
onShowToast,
|
||||
}: DetailsModalProps) => {
|
||||
const [isQueuing, setIsQueuing] = useState(false);
|
||||
@@ -91,6 +93,7 @@ export const DetailsModal = ({
|
||||
|
||||
// Determine if this is a metadata book (Universal mode) vs a release (Direct Download)
|
||||
const isMetadata = isMetadataBook(book);
|
||||
const showBookSourceLink = Boolean(book.source_url) && (isMetadata || showReleaseSourceLinks);
|
||||
const metadataActionText =
|
||||
isMetadata && buttonState.state === 'download' && buttonState.text === 'Get'
|
||||
? 'Find Downloads'
|
||||
@@ -135,7 +138,7 @@ export const DetailsModal = ({
|
||||
})
|
||||
: [];
|
||||
const extendedInfoEntries = [[publisherInfo.label, publisherInfo.value], ...additionalInfo];
|
||||
const infoCardClass = 'rounded-2xl border-hairline border-(--border-muted) px-4 py-3 text-sm bg-(--bg-soft) sm:bg-(--bg)';
|
||||
const infoCardClass = 'rounded-2xl border border-(--border-muted) px-4 py-3 text-sm bg-(--bg-soft) sm:bg-(--bg)';
|
||||
const infoLabelClass = 'text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400';
|
||||
const infoValueClass = 'text-gray-900 dark:text-gray-100';
|
||||
|
||||
@@ -152,8 +155,8 @@ export const DetailsModal = ({
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<div className="flex h-full sm:h-[90vh] sm:max-h-[90vh] flex-col overflow-hidden rounded-none sm:rounded-2xl border-0 sm:border-hairline border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) text-(--text) shadow-none sm:shadow-2xl">
|
||||
<header className="flex items-start gap-4 border-b-hairline border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) px-5 py-4">
|
||||
<div className="flex h-full sm:h-[90vh] sm:max-h-[90vh] flex-col overflow-hidden rounded-none sm:rounded-2xl border-0 sm:border border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) text-(--text) shadow-none sm:shadow-2xl">
|
||||
<header className="flex items-start gap-4 border-b border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) px-5 py-4">
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Book</p>
|
||||
<h3 id={titleId} className="text-lg font-semibold leading-snug">
|
||||
@@ -198,7 +201,7 @@ export const DetailsModal = ({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex w-full items-center justify-center rounded-xl border-hairline border-dashed border-(--border-muted) bg-(--bg)/60 p-6 text-sm text-gray-500 lg:h-full lg:max-w-none"
|
||||
className="flex w-full items-center justify-center rounded-xl border border-dashed border-(--border-muted) bg-(--bg)/60 p-6 text-sm text-gray-500 lg:h-full lg:max-w-none"
|
||||
style={{ maxHeight: artworkMaxHeight, maxWidth: artworkMaxWidth }}
|
||||
>
|
||||
No cover
|
||||
@@ -329,12 +332,11 @@ export const DetailsModal = ({
|
||||
</div>
|
||||
|
||||
<footer
|
||||
className="border-t-hairline border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) px-5 py-4"
|
||||
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
|
||||
className="border-t border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) px-5 py-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 && (
|
||||
{showBookSourceLink && (
|
||||
<a
|
||||
href={book.source_url}
|
||||
target="_blank"
|
||||
@@ -352,7 +354,7 @@ export const DetailsModal = ({
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
<div className="flex w-full flex-col gap-2 sm:ml-auto sm:w-auto sm:flex-row sm:items-center">
|
||||
<div className="flex w-full flex-col gap-3 sm:ml-auto sm:w-auto sm:flex-row sm:items-center">
|
||||
{hasBookTargets && book.provider_id && (
|
||||
<BookTargetDropdown
|
||||
provider={book.provider!}
|
||||
|
||||
@@ -184,7 +184,7 @@ export const Dropdown = ({
|
||||
type="button"
|
||||
onClick={toggleOpen}
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-sm border-hairline flex items-center justify-between gap-2 text-left focus:outline-hidden focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 ${triggerChrome !== 'minimal' ? 'dropdown-trigger' : ''} ${buttonClassName}`}
|
||||
className={`w-full px-3 py-2 text-sm border flex items-center justify-between gap-2 text-left focus:outline-hidden focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 ${triggerChrome !== 'minimal' ? 'dropdown-trigger' : ''} ${buttonClassName}`}
|
||||
style={{
|
||||
color: 'var(--text)',
|
||||
borderColor: triggerChrome === 'minimal' ? 'transparent' : 'var(--border-muted)',
|
||||
@@ -222,7 +222,7 @@ export const Dropdown = ({
|
||||
panelDirection === 'down'
|
||||
? renderTrigger ? 'mt-2' : ''
|
||||
: renderTrigger ? 'bottom-full mb-2' : 'bottom-full'
|
||||
} border-hairline z-20 ${panelDirection === 'down' ? 'shadow-lg' : ''} ${panelClassName || widthClassName}`}
|
||||
} border z-20 ${panelDirection === 'down' ? 'shadow-lg' : ''} ${panelClassName || widthClassName}`}
|
||||
style={{
|
||||
background: 'var(--bg)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
|
||||
@@ -399,7 +399,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
</svg>
|
||||
{actingAsUser && (
|
||||
<span
|
||||
className="absolute top-1 right-1 h-2 w-2 rounded-full bg-sky-500 border-hairline border-(--bg)"
|
||||
className="absolute top-1 right-1 h-2 w-2 rounded-full bg-sky-500 border border-(--bg)"
|
||||
title={`Downloading as ${formatActingAsUserName(actingAsUser)}`}
|
||||
/>
|
||||
)}
|
||||
@@ -408,7 +408,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
{/* Dropdown Menu */}
|
||||
{(isDropdownOpen || isClosing) && (
|
||||
<div
|
||||
className={`absolute right-0 mt-2 ${dropdownPanelWidthClass} rounded-lg shadow-lg border-hairline z-50 ${
|
||||
className={`absolute right-0 mt-2 ${dropdownPanelWidthClass} rounded-lg shadow-lg border z-50 ${
|
||||
isClosing ? 'animate-fade-out-up' : shouldAnimateIn ? 'animate-fade-in-down' : ''
|
||||
}`}
|
||||
style={{
|
||||
@@ -550,7 +550,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
{/* User Footer */}
|
||||
{authRequired && isAuthenticated && username && (
|
||||
<div
|
||||
className="border-t-hairline"
|
||||
className="border-t"
|
||||
style={{ borderColor: 'var(--border-muted)' }}
|
||||
>
|
||||
<div className="px-4 py-3 flex items-center gap-2.5">
|
||||
@@ -581,7 +581,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
|
||||
{isAdmin && onActingAsUserChange && (
|
||||
<div
|
||||
className="border-t-hairline px-4 py-3 space-y-2"
|
||||
className="border-t px-4 py-3 space-y-2"
|
||||
style={{ borderColor: 'var(--border-muted)' }}
|
||||
>
|
||||
<div className="text-xs font-medium uppercase tracking-wide opacity-70">
|
||||
|
||||
@@ -112,7 +112,7 @@ const PasswordLoginForm = ({
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
onKeyDown={handleUsernameKeyDown}
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-2.5 rounded-lg border-hairline focus:outline-hidden focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="w-full px-4 py-2.5 rounded-lg border focus:outline-hidden focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--input-background)',
|
||||
borderColor: 'var(--border-color)',
|
||||
@@ -141,7 +141,7 @@ const PasswordLoginForm = ({
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-2.5 rounded-lg border-hairline focus:outline-hidden focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed pr-10 transition-colors"
|
||||
className="w-full px-4 py-2.5 rounded-lg border focus:outline-hidden focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed pr-10 transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--input-background)',
|
||||
borderColor: 'var(--border-color)',
|
||||
@@ -283,7 +283,7 @@ export const LoginForm = ({
|
||||
{!hideLocalAuth && (
|
||||
<>
|
||||
<div className="flex items-center mt-5 mb-2">
|
||||
<div className="flex-1 border-t-hairline" style={{ borderColor: 'var(--border-color)' }} />
|
||||
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPasswordLogin((prev) => !prev)}
|
||||
@@ -291,7 +291,7 @@ export const LoginForm = ({
|
||||
>
|
||||
{showPasswordLogin ? 'Hide' : 'Use password'}
|
||||
</button>
|
||||
<div className="flex-1 border-t-hairline" style={{ borderColor: 'var(--border-color)' }} />
|
||||
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
|
||||
</div>
|
||||
|
||||
{showPasswordLogin && (
|
||||
|
||||
@@ -93,13 +93,13 @@ export const OnBehalfConfirmationModal = ({
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`relative w-full max-w-lg rounded-xl border-hairline border-(--border-muted) shadow-2xl ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
|
||||
className={`relative w-full max-w-lg rounded-xl border border-(--border-muted) shadow-2xl ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
|
||||
style={{ background: 'var(--bg)' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<header className="flex items-center justify-between border-b-hairline border-(--border-muted) px-6 py-4">
|
||||
<header className="flex items-center justify-between border-b border-(--border-muted) px-6 py-4">
|
||||
<h3 id={titleId} className="text-lg font-semibold">
|
||||
Download as {actingAsName}?
|
||||
</h3>
|
||||
@@ -120,18 +120,18 @@ export const OnBehalfConfirmationModal = ({
|
||||
<p className="text-sm opacity-90">
|
||||
This download will use {actingAsName}'s output preferences and destination settings.
|
||||
</p>
|
||||
<div className="rounded-xl border-hairline border-(--border-muted) bg-(--bg-soft) px-4 py-3">
|
||||
<div className="rounded-xl border border-(--border-muted) bg-(--bg-soft) px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide opacity-60">Title</p>
|
||||
<p className="text-sm font-medium mt-1 wrap-break-word">{itemTitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-end gap-3 border-t-hairline border-(--border-muted) px-6 py-4">
|
||||
<footer className="flex items-center justify-end gap-3 border-t border-(--border-muted) px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-(--bg-soft) border-hairline border-(--border-muted) hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-(--bg-soft) border border-(--border-muted) hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -390,7 +390,7 @@ export const OnboardingModal = ({
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium
|
||||
bg-(--bg-soft) border-hairline border-(--border-muted)
|
||||
bg-(--bg-soft) border border-(--border-muted)
|
||||
hover:bg-(--hover-surface) transition-colors"
|
||||
>
|
||||
Close
|
||||
@@ -416,7 +416,7 @@ export const OnboardingModal = ({
|
||||
{/* Modal */}
|
||||
<div
|
||||
className={`relative w-full max-w-xl rounded-xl
|
||||
border-hairline border-(--border-muted) shadow-2xl
|
||||
border border-(--border-muted) shadow-2xl
|
||||
${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
|
||||
style={{ background: 'var(--bg)' }}
|
||||
role="dialog"
|
||||
@@ -424,7 +424,7 @@ export const OnboardingModal = ({
|
||||
aria-label="Setup Wizard"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b-hairline border-(--border-muted)">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-(--border-muted)">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-sky-500/20 text-sky-500 text-sm font-medium">
|
||||
{currentStepIndex + 1}
|
||||
@@ -481,7 +481,7 @@ export const OnboardingModal = ({
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t-hairline border-(--border-muted) flex items-center justify-between h-[68px]">
|
||||
<div className="px-6 py-4 border-t border-(--border-muted) flex items-center justify-between h-[68px]">
|
||||
<div>
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
@@ -499,7 +499,7 @@ export const OnboardingModal = ({
|
||||
onClick={handleBack}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium
|
||||
bg-(--bg-soft) border-hairline border-(--border-muted)
|
||||
bg-(--bg-soft) border border-(--border-muted)
|
||||
hover:bg-(--hover-surface) transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
SearchStatusData,
|
||||
ContentType,
|
||||
RequestPolicyMode,
|
||||
isMetadataBook,
|
||||
} from '../types';
|
||||
import { getReleases, getReleaseSources } from '../services/api';
|
||||
import { useSocket } from '../contexts/SocketContext';
|
||||
@@ -93,10 +94,12 @@ interface ReleaseModalProps {
|
||||
defaultLanguages: string[];
|
||||
bookLanguages: Language[];
|
||||
currentStatus: StatusData;
|
||||
defaultReleaseSource?: string; // Default tab to show (e.g., 'direct_download')
|
||||
defaultReleaseSource?: string; // Default book tab to show (e.g., 'direct_download')
|
||||
defaultAudiobookReleaseSource?: string; // Default audiobook tab to show
|
||||
onSearchSeries?: (seriesName: string, seriesId?: string) => void; // Callback to search for series
|
||||
defaultShowManualQuery?: boolean;
|
||||
isRequestMode?: boolean;
|
||||
showReleaseSourceLinks?: boolean;
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
}
|
||||
|
||||
@@ -154,7 +157,7 @@ const ReleaseThumbnail = ({ preview, title }: { preview?: string; title?: string
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-7 h-10 sm:w-8 sm:h-12 rounded-sm overflow-hidden bg-zinc-100 dark:bg-zinc-800 border-hairline border-white/40 dark:border-zinc-700/70 shrink-0">
|
||||
<div className="relative w-7 h-10 sm:w-8 sm:h-12 rounded-sm overflow-hidden bg-zinc-100 dark:bg-zinc-800 border border-white/40 dark:border-zinc-700/70 shrink-0">
|
||||
{!imageLoaded && (
|
||||
<div className="absolute inset-0 bg-linear-to-r from-gray-200 via-gray-100 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
|
||||
)}
|
||||
@@ -224,6 +227,7 @@ const ReleaseRow = ({
|
||||
gridTemplate,
|
||||
leadingCell,
|
||||
onlineServers,
|
||||
showReleaseSourceLinks,
|
||||
}: {
|
||||
release: Release;
|
||||
index: number;
|
||||
@@ -233,6 +237,7 @@ const ReleaseRow = ({
|
||||
gridTemplate: string;
|
||||
leadingCell?: LeadingCellConfig;
|
||||
onlineServers?: string[];
|
||||
showReleaseSourceLinks: boolean;
|
||||
}) => {
|
||||
const author = release.extra?.author as string | undefined;
|
||||
|
||||
@@ -271,7 +276,7 @@ const ReleaseRow = ({
|
||||
{/* Fixed: Title and author */}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium line-clamp-2" title={release.title}>
|
||||
{release.info_url ? (
|
||||
{showReleaseSourceLinks && release.info_url ? (
|
||||
<a
|
||||
href={release.info_url}
|
||||
target="_blank"
|
||||
@@ -318,7 +323,7 @@ const ReleaseRow = ({
|
||||
<div className="min-w-0">
|
||||
{/* Title and author on same line */}
|
||||
<p className="text-sm leading-tight line-clamp-2" title={release.title}>
|
||||
{release.info_url ? (
|
||||
{showReleaseSourceLinks && release.info_url ? (
|
||||
<a
|
||||
href={release.info_url}
|
||||
target="_blank"
|
||||
@@ -529,15 +534,20 @@ export const ReleaseModal = ({
|
||||
bookLanguages,
|
||||
currentStatus,
|
||||
defaultReleaseSource,
|
||||
defaultAudiobookReleaseSource,
|
||||
onSearchSeries,
|
||||
defaultShowManualQuery = false,
|
||||
isRequestMode = false,
|
||||
showReleaseSourceLinks = true,
|
||||
onShowToast,
|
||||
}: ReleaseModalProps) => {
|
||||
// Use audiobook formats when in audiobook mode
|
||||
const effectiveFormats = contentType === 'audiobook' && supportedAudiobookFormats.length > 0
|
||||
? supportedAudiobookFormats
|
||||
: supportedFormats;
|
||||
const preferredDefaultReleaseSource = contentType === 'audiobook'
|
||||
? (defaultAudiobookReleaseSource || defaultReleaseSource)
|
||||
: defaultReleaseSource;
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [isRequestingBook, setIsRequestingBook] = useState(false);
|
||||
|
||||
@@ -790,15 +800,15 @@ export const ReleaseModal = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Set active tab: prefer defaultReleaseSource if enabled and supports content type
|
||||
// Set active tab: prefer the configured default source if enabled and supports content type
|
||||
if (supportedSources.length > 0) {
|
||||
const enabledSources = supportedSources.filter(s => s.enabled);
|
||||
const defaultIsEnabled = defaultReleaseSource &&
|
||||
enabledSources.some(s => s.name === defaultReleaseSource);
|
||||
const defaultIsEnabled = preferredDefaultReleaseSource &&
|
||||
enabledSources.some(s => s.name === preferredDefaultReleaseSource);
|
||||
|
||||
let defaultSource: string;
|
||||
if (defaultIsEnabled) {
|
||||
defaultSource = defaultReleaseSource;
|
||||
defaultSource = preferredDefaultReleaseSource;
|
||||
} else if (enabledSources.length > 0) {
|
||||
defaultSource = enabledSources[0].name;
|
||||
} else {
|
||||
@@ -822,7 +832,7 @@ export const ReleaseModal = ({
|
||||
};
|
||||
|
||||
fetchSources();
|
||||
}, [book, defaultReleaseSource, contentType]);
|
||||
}, [book, preferredDefaultReleaseSource, contentType]);
|
||||
|
||||
// Fetch releases when active tab changes (with caching)
|
||||
// Initial fetch always uses ISBN-first search; expansion is handled by handleExpandSearch
|
||||
@@ -948,16 +958,16 @@ export const ReleaseModal = ({
|
||||
});
|
||||
|
||||
// Sort so default source appears first
|
||||
if (defaultReleaseSource) {
|
||||
if (preferredDefaultReleaseSource) {
|
||||
enabledTabs.sort((a, b) => {
|
||||
if (a.name === defaultReleaseSource) return -1;
|
||||
if (b.name === defaultReleaseSource) return 1;
|
||||
if (a.name === preferredDefaultReleaseSource) return -1;
|
||||
if (b.name === preferredDefaultReleaseSource) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return enabledTabs;
|
||||
}, [availableSources, book?.provider, defaultReleaseSource, contentType]);
|
||||
}, [availableSources, book?.provider, preferredDefaultReleaseSource, contentType]);
|
||||
|
||||
// Update tab indicator position when active tab changes
|
||||
useEffect(() => {
|
||||
@@ -1276,6 +1286,7 @@ export const ReleaseModal = ({
|
||||
const providerDisplay =
|
||||
book.provider_display_name ||
|
||||
(book.provider ? book.provider.charAt(0).toUpperCase() + book.provider.slice(1) : 'Unknown');
|
||||
const showBookSourceLink = Boolean(book.source_url) && (isMetadataBook(book) || showReleaseSourceLinks);
|
||||
|
||||
const currentTabLoading = loadingBySource[activeTab] ?? false;
|
||||
const currentTabError = errorBySource[activeTab] ?? null;
|
||||
@@ -1297,13 +1308,35 @@ export const ReleaseModal = ({
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<div className="flex h-full sm:h-[90vh] sm:max-h-[90vh] flex-col overflow-hidden rounded-none sm:rounded-2xl border-0 sm:border-hairline border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) text-(--text) shadow-none sm:shadow-2xl">
|
||||
<div className="flex h-full sm:h-[90vh] sm:max-h-[90vh] flex-col overflow-hidden rounded-none sm:rounded-2xl border-0 sm:border border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) text-(--text) shadow-none sm:shadow-2xl">
|
||||
{/* Header */}
|
||||
<header className="flex items-start gap-3 border-b-hairline border-(--border-muted) px-5 py-4">
|
||||
{/* Animated thumbnail that appears when scrolling */}
|
||||
<header className="flex items-start gap-3 border-b border-(--border-muted) px-5 py-4">
|
||||
{/* Mobile: static thumbnail always visible */}
|
||||
{!isRequestMode && (
|
||||
<div className="sm:hidden shrink-0">
|
||||
{book.preview ? (
|
||||
<img
|
||||
src={book.preview}
|
||||
alt=""
|
||||
width={46}
|
||||
height={68}
|
||||
className="rounded-sm shadow-md object-cover object-top"
|
||||
style={{ width: 46, height: 68, minWidth: 46 }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-sm border border-dashed border-(--border-muted) bg-(--bg)/60 flex items-center justify-center text-[7px] text-zinc-500"
|
||||
style={{ width: 46, height: 68, minWidth: 46 }}
|
||||
>
|
||||
No cover
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Desktop: animated thumbnail that appears when scrolling */}
|
||||
{!isRequestMode && (
|
||||
<div
|
||||
className="shrink-0 overflow-hidden transition-[width,margin] duration-300 ease-out"
|
||||
className="hidden sm:block shrink-0 overflow-hidden transition-[width,margin] duration-300 ease-out"
|
||||
style={{
|
||||
width: showHeaderThumb ? 46 : 0,
|
||||
marginRight: showHeaderThumb ? 0 : -12,
|
||||
@@ -1324,7 +1357,7 @@ export const ReleaseModal = ({
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-sm border-hairline border-dashed border-(--border-muted) bg-(--bg)/60 flex items-center justify-center text-[7px] text-zinc-500"
|
||||
className="rounded-sm border border-dashed border-(--border-muted) bg-(--bg)/60 flex items-center justify-center text-[7px] text-zinc-500"
|
||||
style={{ width: 46, height: 68, minWidth: 46 }}
|
||||
>
|
||||
No cover
|
||||
@@ -1364,15 +1397,15 @@ export const ReleaseModal = ({
|
||||
<div ref={scrollContainerRef} className="flex-1 min-h-0 overflow-y-auto">
|
||||
{/* Book summary - scrolls with content */}
|
||||
{!isRequestMode && (
|
||||
<div ref={bookSummaryRef} className="flex gap-4 px-5 py-4 border-b-hairline border-(--border-muted)">
|
||||
<div ref={bookSummaryRef} className="flex gap-4 px-5 py-4 border-b border-(--border-muted)">
|
||||
{book.preview ? (
|
||||
<img
|
||||
src={book.preview}
|
||||
alt="Book cover"
|
||||
className={`rounded-lg shadow-md object-cover object-top shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}
|
||||
className={`hidden sm:block rounded-lg shadow-md object-cover object-top shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}
|
||||
/>
|
||||
) : (
|
||||
<div className={`rounded-lg border-hairline border-dashed border-(--border-muted) bg-(--bg)/60 flex items-center justify-center text-[10px] text-zinc-500 shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}>
|
||||
<div className={`hidden sm:flex rounded-lg border border-dashed border-(--border-muted) bg-(--bg)/60 items-center justify-center text-[10px] text-zinc-500 shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}>
|
||||
No cover
|
||||
</div>
|
||||
)}
|
||||
@@ -1467,7 +1500,7 @@ export const ReleaseModal = ({
|
||||
ISBN: {book.isbn_13 || book.isbn_10}
|
||||
</span>
|
||||
)}
|
||||
{book.source_url && (
|
||||
{showBookSourceLink && (
|
||||
<a
|
||||
href={book.source_url}
|
||||
target="_blank"
|
||||
@@ -1480,28 +1513,32 @@ export const ReleaseModal = ({
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{onRequestBook && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleRequestBook();
|
||||
}}
|
||||
disabled={isRequestingBook}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{isRequestingBook ? 'Adding...' : 'Add to requests'}
|
||||
</button>
|
||||
)}
|
||||
{bookSupportsTargets(book) && (
|
||||
<BookTargetDropdown
|
||||
provider={book.provider!}
|
||||
bookId={book.provider_id!}
|
||||
onShowToast={onShowToast}
|
||||
variant="pill"
|
||||
/>
|
||||
{(onRequestBook || bookSupportsTargets(book)) && (
|
||||
<span className="inline-flex items-center gap-3">
|
||||
{onRequestBook && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleRequestBook();
|
||||
}}
|
||||
disabled={isRequestingBook}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{isRequestingBook ? 'Adding...' : 'Add to requests'}
|
||||
</button>
|
||||
)}
|
||||
{bookSupportsTargets(book) && (
|
||||
<BookTargetDropdown
|
||||
provider={book.provider!}
|
||||
bookId={book.provider_id!}
|
||||
onShowToast={onShowToast}
|
||||
variant="pill"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1509,7 +1546,7 @@ export const ReleaseModal = ({
|
||||
)}
|
||||
|
||||
{/* Source tabs + filters - sticky within scroll container */}
|
||||
<div className="sticky top-0 z-10 border-b-hairline border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft)">
|
||||
<div className="sticky top-0 z-10 border-b border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft)">
|
||||
{sourcesLoading ? (
|
||||
<div className="flex gap-1 px-5 py-2">
|
||||
<div className="h-10 w-32 animate-pulse bg-zinc-200 dark:bg-zinc-700 rounded-sm" />
|
||||
@@ -1654,7 +1691,7 @@ export const ReleaseModal = ({
|
||||
{availableFormats.length > 1 && (
|
||||
<>
|
||||
{allSortOptions.length > 0 && (
|
||||
<div className="mx-2 my-1 border-t-hairline border-zinc-200 dark:border-zinc-700" />
|
||||
<div className="mx-2 my-1 border-t border-zinc-200 dark:border-zinc-700" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1852,7 +1889,7 @@ export const ReleaseModal = ({
|
||||
|
||||
{/* Manual query panel (below source tabs) */}
|
||||
{showManualQuery && (
|
||||
<div className="px-5 py-3 border-b-hairline border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft)">
|
||||
<div className="px-5 py-3 border-b border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft)">
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
onSubmit={async (e) => {
|
||||
@@ -1907,7 +1944,7 @@ export const ReleaseModal = ({
|
||||
value={manualQuery}
|
||||
onChange={(e) => setManualQuery(e.target.value)}
|
||||
placeholder="Type a custom search query (overrides all sources)"
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border-hairline border-(--border-muted) bg-(--bg) text-(--text)"
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border border-(--border-muted) bg-(--bg) text-(--text)"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -1981,6 +2018,7 @@ export const ReleaseModal = ({
|
||||
gridTemplate={columnConfig.grid_template}
|
||||
leadingCell={columnConfig.leading_cell}
|
||||
onlineServers={columnConfig.online_servers}
|
||||
showReleaseSourceLinks={showReleaseSourceLinks}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -2019,7 +2057,7 @@ export const ReleaseModal = ({
|
||||
{/* Sticky search status indicator - stays at bottom of visible scroll area */}
|
||||
{searchStatus && searchStatus.source === activeTab && currentTabLoading && (
|
||||
<div className="sticky bottom-0 z-10 flex items-center justify-center pointer-events-none pb-4 pt-2">
|
||||
<div className="flex items-center gap-2.5 px-4 py-2 rounded-xl bg-(--bg-soft) border-hairline border-(--border-muted) text-zinc-500 dark:text-zinc-400 text-sm shadow-lg pointer-events-auto">
|
||||
<div className="flex items-center gap-2.5 px-4 py-2 rounded-xl bg-(--bg-soft) border border-(--border-muted) text-zinc-500 dark:text-zinc-400 text-sm shadow-lg pointer-events-auto">
|
||||
{searchStatus.phase !== 'complete' && searchStatus.phase !== 'error' && (
|
||||
<div className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
|
||||
@@ -139,13 +139,13 @@ export const RequestConfirmationModal = ({
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`relative w-full max-w-xl rounded-xl border-hairline border-(--border-muted) shadow-2xl ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
|
||||
className={`relative w-full max-w-xl rounded-xl border border-(--border-muted) shadow-2xl ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
|
||||
style={{ background: 'var(--bg)' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<header className="flex items-center justify-between border-b-hairline border-(--border-muted) px-6 py-4">
|
||||
<header className="flex items-center justify-between border-b border-(--border-muted) px-6 py-4">
|
||||
<h3 id={titleId} className="text-lg font-semibold">
|
||||
Request Book
|
||||
</h3>
|
||||
@@ -163,9 +163,9 @@ export const RequestConfirmationModal = ({
|
||||
</header>
|
||||
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
<div className="rounded-xl border-hairline border-(--border-muted) bg-(--bg-soft) px-4 py-4">
|
||||
<div className="rounded-xl border border-(--border-muted) bg-(--bg-soft) px-4 py-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="w-16 h-24 shrink-0 rounded-lg overflow-hidden border-hairline border-(--border-muted) bg-(--bg)">
|
||||
<div className="w-16 h-24 shrink-0 rounded-lg overflow-hidden border border-(--border-muted) bg-(--bg)">
|
||||
{preview.preview ? (
|
||||
<img
|
||||
src={preview.preview}
|
||||
@@ -212,7 +212,7 @@ export const RequestConfirmationModal = ({
|
||||
onChange={(event) => setNote(truncateRequestNote(event.target.value))}
|
||||
maxLength={MAX_REQUEST_NOTE_LENGTH}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 rounded-lg border-hairline border-(--border-muted) bg-(--bg) text-sm resize-y min-h-[96px] focus:outline-hidden focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500"
|
||||
className="w-full px-3 py-2 rounded-lg border border-(--border-muted) bg-(--bg) text-sm resize-y min-h-[96px] focus:outline-hidden focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500"
|
||||
placeholder="Add context for admins reviewing this request..."
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
@@ -223,12 +223,12 @@ export const RequestConfirmationModal = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-end gap-3 border-t-hairline border-(--border-muted) px-6 py-4">
|
||||
<footer className="flex items-center justify-end gap-3 border-t border-(--border-muted) px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-(--bg-soft) border-hairline border-(--border-muted) hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-(--bg-soft) border border-(--border-muted) hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -414,7 +414,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
Boolean(autocompleteEndpoint)
|
||||
&& isAutocompleteOpen
|
||||
&& textInputValue.trim().length >= autocompleteMinQueryLength;
|
||||
const wrapperClasses = ['relative flex items-center rounded-full border-hairline', className].filter(Boolean).join(' ').trim();
|
||||
const wrapperClasses = ['relative flex items-center rounded-full border', className].filter(Boolean).join(' ').trim();
|
||||
const controlsClasses = [
|
||||
'flex items-center gap-1 pr-2 shrink-0',
|
||||
controlsClassName,
|
||||
@@ -640,7 +640,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
|
||||
{isSelectorOpen && (
|
||||
<div
|
||||
className="absolute left-0 top-full z-50 mt-2 w-[min(20rem,calc(100vw-2rem))] overflow-hidden rounded-2xl border-hairline shadow-2xl animate-fade-in-down"
|
||||
className="absolute left-0 top-full z-50 mt-2 w-[min(20rem,calc(100vw-2rem))] overflow-hidden rounded-2xl border shadow-2xl animate-fade-in-down"
|
||||
style={{
|
||||
background: 'var(--bg)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
@@ -650,7 +650,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
>
|
||||
<div className="max-h-[min(24rem,calc(100vh-8rem))] overflow-y-auto p-3">
|
||||
{showContentTypeSelector && (
|
||||
<div className="border-b-hairline pb-3" style={{ borderColor: 'var(--border-muted)' }}>
|
||||
<div className="border-b pb-3" style={{ borderColor: 'var(--border-muted)' }}>
|
||||
<div className="px-1 pb-2 text-xs font-medium uppercase tracking-wide opacity-60">
|
||||
Content
|
||||
</div>
|
||||
@@ -658,7 +658,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleContentTypeSelect('ebook')}
|
||||
className={`flex items-center gap-2 rounded-xl border-hairline px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||
className={`flex items-center gap-2 rounded-xl border px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||
contentType === 'ebook' ? 'bg-emerald-600 text-white' : 'hover-surface'
|
||||
}`}
|
||||
style={contentType !== 'ebook'
|
||||
@@ -671,7 +671,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleContentTypeSelect('audiobook')}
|
||||
className={`flex items-center gap-2 rounded-xl border-hairline px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||
className={`flex items-center gap-2 rounded-xl border px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||
contentType === 'audiobook' ? 'bg-emerald-600 text-white' : 'hover-surface'
|
||||
}`}
|
||||
style={contentType !== 'audiobook'
|
||||
@@ -699,7 +699,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
onClick={() => handleQueryTargetSelect(target.key)}
|
||||
title={target.description || target.label}
|
||||
aria-label={target.label}
|
||||
className={`min-w-0 rounded-xl border-hairline px-3 py-2.5 text-sm font-medium transition-colors flex items-center gap-2 ${
|
||||
className={`min-w-0 rounded-xl border px-3 py-2.5 text-sm font-medium transition-colors flex items-center gap-2 ${
|
||||
isActive ? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white` : 'hover-surface'
|
||||
}`}
|
||||
style={isActive
|
||||
@@ -715,7 +715,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
</div>
|
||||
|
||||
{onAdvancedToggle && (
|
||||
<div className="border-t-hairline pt-3 mt-3" style={{ borderColor: 'var(--border-muted)' }}>
|
||||
<div className="border-t pt-3 mt-3" style={{ borderColor: 'var(--border-muted)' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -826,7 +826,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
{selectDropdownOpen && (
|
||||
<div
|
||||
ref={selectPanelRef}
|
||||
className="absolute top-full left-0 right-0 z-50 mt-2 rounded-2xl border-hairline shadow-xl overflow-hidden animate-fade-in-down"
|
||||
className="absolute top-full left-0 right-0 z-50 mt-2 rounded-2xl border shadow-xl overflow-hidden animate-fade-in-down"
|
||||
style={{ background: 'var(--bg)', borderColor: 'var(--border-muted)' }}
|
||||
role="listbox"
|
||||
aria-label={effectiveInputAriaLabel}
|
||||
@@ -869,7 +869,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
{autocompleteDropdownOpen && (
|
||||
<div
|
||||
ref={autocompletePanelRef}
|
||||
className="absolute top-full left-0 right-0 z-50 mt-2 rounded-2xl border-hairline shadow-xl overflow-hidden animate-fade-in-down"
|
||||
className="absolute top-full left-0 right-0 z-50 mt-2 rounded-2xl border shadow-xl overflow-hidden animate-fade-in-down"
|
||||
style={{ background: 'var(--bg)', borderColor: 'var(--border-muted)' }}
|
||||
role="listbox"
|
||||
aria-label={`${effectiveInputAriaLabel} suggestions`}
|
||||
|
||||
@@ -746,7 +746,7 @@ export const ActivityCard = ({
|
||||
type="button"
|
||||
onClick={handleReviewManualApproval}
|
||||
disabled={isReviewSubmitting}
|
||||
className="px-2.5 py-1.5 rounded-md text-xs border-hairline border-(--border-muted) hover:bg-(--hover-surface) transition-colors disabled:opacity-50"
|
||||
className="px-2.5 py-1.5 rounded-md text-xs border border-(--border-muted) hover:bg-(--hover-surface) transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isReviewSubmitting ? 'Working...' : 'Manually Mark as Approved'}
|
||||
</button>
|
||||
@@ -756,7 +756,7 @@ export const ActivityCard = ({
|
||||
type="button"
|
||||
onClick={handleReviewBrowseAlternatives}
|
||||
disabled={isReviewSubmitting}
|
||||
className="px-2.5 py-1.5 rounded-md text-xs border-hairline border-(--border-muted) hover:bg-(--hover-surface) transition-colors disabled:opacity-50"
|
||||
className="px-2.5 py-1.5 rounded-md text-xs border border-(--border-muted) hover:bg-(--hover-surface) transition-colors disabled:opacity-50"
|
||||
>
|
||||
Browse Alternatives
|
||||
</button>
|
||||
@@ -776,7 +776,7 @@ export const ActivityCard = ({
|
||||
rows={3}
|
||||
maxLength={MAX_ADMIN_NOTE_LENGTH}
|
||||
placeholder="Optional note shown to the user"
|
||||
className="w-full px-2.5 py-2 rounded-md border-hairline border-(--border-muted) bg-(--bg) text-xs resize-y min-h-[72px] focus:outline-hidden focus:ring-2 focus:ring-red-500/30 focus:border-red-500"
|
||||
className="w-full px-2.5 py-2 rounded-md border border-(--border-muted) bg-(--bg) text-xs resize-y min-h-[72px] focus:outline-hidden focus:ring-2 focus:ring-red-500/30 focus:border-red-500"
|
||||
disabled={isRejectSubmitting}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -735,7 +735,7 @@ export const ActivitySidebar = ({
|
||||
</div>
|
||||
|
||||
{activeTab !== 'history' && (
|
||||
<div className="mt-2 border-b-hairline border-(--border-muted) -mx-4 px-4">
|
||||
<div className="mt-2 border-b border-(--border-muted) -mx-4 px-4">
|
||||
<div className="relative flex gap-1">
|
||||
{/* Sliding indicator */}
|
||||
<div
|
||||
@@ -961,7 +961,7 @@ export const ActivitySidebar = ({
|
||||
|
||||
{(activeTab === 'downloads' || activeTab === 'all') && hasTerminalDownloadItems && clearCompletedTargets.length > 0 && (
|
||||
<div
|
||||
className="p-3 border-t-hairline flex items-center justify-center"
|
||||
className="p-3 border-t flex items-center justify-center"
|
||||
style={{
|
||||
borderColor: 'var(--border-muted)',
|
||||
paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))',
|
||||
@@ -979,7 +979,7 @@ export const ActivitySidebar = ({
|
||||
|
||||
{activeTab === 'history' && historyItems.length > 0 && (
|
||||
<div
|
||||
className="p-3 border-t-hairline flex items-center justify-center"
|
||||
className="p-3 border-t flex items-center justify-center"
|
||||
style={{
|
||||
borderColor: 'var(--border-muted)',
|
||||
paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))',
|
||||
|
||||
@@ -67,7 +67,7 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
|
||||
{/* 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-hairline border-emerald-700"
|
||||
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)',
|
||||
@@ -177,7 +177,7 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
|
||||
|
||||
<div className="flex gap-1.5 sm:hidden">
|
||||
<button
|
||||
className="px-2 py-1.5 rounded-sm border-hairline text-xs flex-1 flex items-center justify-center gap-1"
|
||||
className="px-2 py-1.5 rounded-sm border text-xs flex-1 flex items-center justify-center gap-1"
|
||||
onClick={() => handleDetails(book.id)}
|
||||
style={{ borderColor: 'var(--border-muted)' }}
|
||||
disabled={isLoadingDetails}
|
||||
|
||||
@@ -68,7 +68,7 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
|
||||
{/* 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-hairline border-emerald-700"
|
||||
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)',
|
||||
@@ -177,7 +177,7 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
|
||||
{showDetailsButton ? (
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
className="px-2 py-1.5 rounded-sm border-hairline text-xs shrink-0 flex items-center justify-center gap-1"
|
||||
className="px-2 py-1.5 rounded-sm border text-xs shrink-0 flex items-center justify-center gap-1"
|
||||
onClick={() => handleDetails(book.id)}
|
||||
style={{ borderColor: 'var(--border-muted)' }}
|
||||
disabled={isLoadingDetails}
|
||||
|
||||
@@ -34,7 +34,7 @@ const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: strin
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-7 h-10 sm:w-10 sm:h-14 rounded-sm overflow-hidden bg-gray-100 dark:bg-gray-800 border-hairline border-white/40 dark:border-gray-700/70">
|
||||
<div className="relative w-7 h-10 sm:w-10 sm:h-14 rounded-sm overflow-hidden bg-gray-100 dark:bg-gray-800 border border-white/40 dark:border-gray-700/70">
|
||||
{!imageLoaded && (
|
||||
<div className="absolute inset-0 bg-linear-to-r from-gray-200 via-gray-100 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
|
||||
)}
|
||||
@@ -129,7 +129,7 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
|
||||
<h3 className="font-semibold text-xs min-[400px]:text-sm sm:text-base leading-tight line-clamp-1 sm:line-clamp-2 flex items-center gap-2" title={book.title || 'Untitled'}>
|
||||
{showSeriesPosition && book.series_position != null && (
|
||||
<span
|
||||
className="inline-flex mr-1.5 px-1.5 py-0.5 text-[10px] sm:text-xs font-bold text-white bg-emerald-600 rounded-sm border-hairline border-emerald-700 shrink-0"
|
||||
className="inline-flex mr-1.5 px-1.5 py-0.5 text-[10px] sm:text-xs font-bold text-white bg-emerald-600 rounded-sm border border-emerald-700 shrink-0"
|
||||
style={{
|
||||
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.3)',
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
|
||||
|
||||
@@ -252,13 +252,13 @@ export const SelfSettingsModal = ({
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`relative w-full max-w-3xl h-[85vh] max-h-[750px] rounded-xl border-hairline border-(--border-muted) shadow-2xl flex flex-col overflow-hidden ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
|
||||
className={`relative w-full max-w-3xl h-[85vh] max-h-[750px] rounded-xl border border-(--border-muted) shadow-2xl flex flex-col overflow-hidden ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
|
||||
style={{ background: 'var(--bg)' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<header className="flex items-center justify-between border-b-hairline border-(--border-muted) px-6 py-4">
|
||||
<header className="flex items-center justify-between border-b border-(--border-muted) px-6 py-4">
|
||||
<h3 id={titleId} className="sr-only">My Account</h3>
|
||||
{editingUser ? (
|
||||
<UserIdentityHeader
|
||||
@@ -295,7 +295,7 @@ export const SelfSettingsModal = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void loadEditContext(); }}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium border-hairline border-(--border-muted) bg-(--bg-soft) hover:bg-(--hover-surface) transition-colors"
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium border border-(--border-muted) bg-(--bg-soft) hover:bg-(--hover-surface) transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
@@ -350,7 +350,7 @@ export const SelfSettingsModal = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-end gap-3 border-t-hairline border-(--border-muted) px-6 py-4">
|
||||
<footer className="flex items-center justify-end gap-3 border-t border-(--border-muted) px-6 py-4">
|
||||
<UserEditActions
|
||||
variant="modalFooter"
|
||||
onSave={() => {
|
||||
|
||||
@@ -12,7 +12,7 @@ export const SettingsHeader = ({
|
||||
onClose,
|
||||
}: SettingsHeaderProps) => (
|
||||
<header
|
||||
className="flex items-center gap-3 px-5 py-4 border-b-hairline border-(--border-muted) shrink-0"
|
||||
className="flex items-center gap-3 px-5 py-4 border-b border-(--border-muted) shrink-0"
|
||||
style={{ paddingTop: 'calc(1rem + env(safe-area-inset-top))' }}
|
||||
>
|
||||
{showBack && (
|
||||
|
||||
@@ -380,7 +380,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium
|
||||
bg-(--bg-soft) border-hairline border-(--border-muted) hover:bg-(--hover-surface) transition-colors"
|
||||
bg-(--bg-soft) border border-(--border-muted) hover:bg-(--hover-surface) transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
@@ -440,7 +440,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
|
||||
{/* Modal */}
|
||||
<div
|
||||
className={`relative w-full max-w-4xl h-[85vh] max-h-[750px] rounded-xl
|
||||
border-hairline border-(--border-muted) shadow-2xl
|
||||
border border-(--border-muted) shadow-2xl
|
||||
flex flex-col overflow-hidden
|
||||
${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
|
||||
style={{ background: 'var(--bg)' }}
|
||||
|
||||
@@ -219,7 +219,7 @@ export const SettingsSidebar = ({
|
||||
<span className="flex-1">{item.tab.displayName}</span>
|
||||
</button>
|
||||
{itemIndex < sidebarItems.length - 1 && (
|
||||
<div className="ml-14 mr-5 border-b-hairline border-(--border-muted)" />
|
||||
<div className="ml-14 mr-5 border-b border-(--border-muted)" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -251,7 +251,7 @@ export const SettingsSidebar = ({
|
||||
<span className="flex-1 text-[15px]">{tab.displayName}</span>
|
||||
</button>
|
||||
{index < item.tabs.length - 1 && (
|
||||
<div className="ml-14 mr-5 border-b-hairline border-(--border-muted)" />
|
||||
<div className="ml-14 mr-5 border-b border-(--border-muted)" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -259,7 +259,7 @@ export const SettingsSidebar = ({
|
||||
)}
|
||||
|
||||
{itemIndex < sidebarItems.length - 1 && (
|
||||
<div className="ml-14 mr-5 border-b-hairline border-(--border-muted)" />
|
||||
<div className="ml-14 mr-5 border-b border-(--border-muted)" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -270,7 +270,7 @@ export const SettingsSidebar = ({
|
||||
|
||||
// Desktop: Sidebar navigation
|
||||
return (
|
||||
<nav className="w-60 border-r-hairline border-(--border-muted) py-2 shrink-0 overflow-y-auto">
|
||||
<nav className="w-60 border-r border-(--border-muted) py-2 shrink-0 overflow-y-auto">
|
||||
{sidebarItems.map((item) => {
|
||||
if (item.type === 'section') {
|
||||
return (
|
||||
@@ -318,7 +318,7 @@ export const SettingsSidebar = ({
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="ml-[22px] border-l-hairline border-(--border-muted) flex flex-col gap-0.5 pl-3">
|
||||
<div className="ml-[22px] border-l border-(--border-muted) flex flex-col gap-0.5 pl-3">
|
||||
{item.tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.name}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { CustomSettingsFieldRendererProps } from './types';
|
||||
|
||||
export const OidcEnvInfo = (_props: CustomSettingsFieldRendererProps) => {
|
||||
return (
|
||||
<div className="rounded-lg overflow-hidden border-hairline border-(--border-muted)">
|
||||
<div className="rounded-lg overflow-hidden border border-(--border-muted)">
|
||||
<div
|
||||
className="px-3 py-1.5 text-xs font-medium opacity-60 border-b-hairline border-(--border-muted)"
|
||||
className="px-3 py-1.5 text-xs font-medium opacity-60 border-b border-(--border-muted)"
|
||||
style={{ background: 'var(--bg-soft)' }}
|
||||
>
|
||||
docker-compose.yml
|
||||
|
||||
@@ -31,7 +31,7 @@ export const ActionButton = ({ field, onAction, disabled }: ActionButtonProps) =
|
||||
|
||||
const styleClasses = {
|
||||
default:
|
||||
'bg-(--bg-soft) border-hairline border-(--border-muted) hover:bg-(--hover-surface)',
|
||||
'bg-(--bg-soft) border border-(--border-muted) hover:bg-(--hover-surface)',
|
||||
primary: 'bg-sky-600 text-white hover:bg-sky-700',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700',
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ interface HeadingFieldProps {
|
||||
}
|
||||
|
||||
export const HeadingField = ({ field }: HeadingFieldProps) => (
|
||||
<div className="pb-1 not-first:pt-5 not-first:mt-1 not-first:border-t-hairline not-first:border-(--border-muted)">
|
||||
<div className="pb-1 not-first:pt-5 not-first:mt-1 not-first:border-t not-first:border-(--border-muted)">
|
||||
<h3 className="text-base font-semibold mb-1">{field.title}</h3>
|
||||
{field.description && (
|
||||
<p className="text-sm opacity-70">
|
||||
|
||||
@@ -163,7 +163,7 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele
|
||||
|
||||
if (isDisabled) {
|
||||
return (
|
||||
<div className="w-full px-3 py-2 rounded-lg border-hairline border-(--border-muted) bg-(--bg-soft) text-sm opacity-60 cursor-not-allowed">
|
||||
<div className="w-full px-3 py-2 rounded-lg border border-(--border-muted) bg-(--bg-soft) text-sm opacity-60 cursor-not-allowed">
|
||||
{summaryFormatter()}
|
||||
</div>
|
||||
);
|
||||
@@ -277,7 +277,7 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele
|
||||
onClick={() => toggleOption(opt.value)}
|
||||
disabled={isDisabled}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium
|
||||
transition-colors border-hairline
|
||||
transition-colors border
|
||||
disabled:opacity-60 disabled:cursor-not-allowed
|
||||
${
|
||||
isSelected
|
||||
|
||||
@@ -20,7 +20,7 @@ export const NumberField = ({ field, value, onChange, disabled }: NumberFieldPro
|
||||
max={field.max}
|
||||
step={field.step ?? 1}
|
||||
disabled={isDisabled}
|
||||
className="w-full px-3 py-2 rounded-lg border-hairline border-(--border-muted) bg-(--bg-soft) text-sm
|
||||
className="w-full px-3 py-2 rounded-lg border border-(--border-muted) bg-(--bg-soft) text-sm
|
||||
focus:outline-hidden focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
|
||||
disabled:opacity-60 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
|
||||
@@ -233,7 +233,7 @@ export const OrderableListField = ({
|
||||
flex items-center gap-3 p-3 rounded-lg
|
||||
transition-all duration-150
|
||||
${isDragging ? 'opacity-50 cursor-grabbing' : isPinned ? 'cursor-default' : 'cursor-grab'}
|
||||
border-hairline border-(--border-muted) ${isDisabled ? 'opacity-60' : !isPinned ? 'hover:bg-(--hover-surface)' : ''}
|
||||
border border-(--border-muted) ${isDisabled ? 'opacity-60' : !isPinned ? 'hover:bg-(--hover-surface)' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Reorder Controls - hidden for pinned items */}
|
||||
|
||||
@@ -21,7 +21,7 @@ export const PasswordField = ({ field, value, onChange, disabled }: PasswordFiel
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={isDisabled}
|
||||
className="w-full px-3 py-2 pr-10 rounded-lg border-hairline border-(--border-muted) bg-(--bg-soft) text-sm
|
||||
className="w-full px-3 py-2 pr-10 rounded-lg border border-(--border-muted) bg-(--bg-soft) text-sm
|
||||
focus:outline-hidden focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
|
||||
disabled:opacity-60 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
|
||||
@@ -69,7 +69,7 @@ export const SelectField = ({ field, value, onChange, disabled, filterValue }: S
|
||||
// When disabled, show a static display instead of the dropdown
|
||||
const selectedOption = filteredOptions.find((opt) => opt.value === effectiveValue);
|
||||
return (
|
||||
<div className="w-full px-3 py-2 rounded-lg border-hairline border-(--border-muted) bg-(--bg-soft) text-sm opacity-60 cursor-not-allowed">
|
||||
<div className="w-full px-3 py-2 rounded-lg border border-(--border-muted) bg-(--bg-soft) text-sm opacity-60 cursor-not-allowed">
|
||||
{selectedOption?.label || 'Select...'}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -175,7 +175,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
|
||||
onClick={addRow}
|
||||
disabled={isDisabled}
|
||||
className="px-3 py-2 rounded-lg text-sm font-medium
|
||||
bg-(--bg-soft) border-hairline border-(--border-muted) hover-action transition-colors
|
||||
bg-(--bg-soft) border border-(--border-muted) hover-action transition-colors
|
||||
disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{field.addLabel || 'Add'}
|
||||
@@ -236,7 +236,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
|
||||
<div key={col.key} className="flex flex-col gap-1 min-w-0">
|
||||
{mobileLabel}
|
||||
{isDisabled ? (
|
||||
<div className="w-full px-3 py-2 rounded-lg border-hairline border-(--border-muted) shadow-sm bg-(--bg-soft) text-sm opacity-60 cursor-not-allowed">
|
||||
<div className="w-full px-3 py-2 rounded-lg border border-(--border-muted) shadow-sm bg-(--bg-soft) text-sm opacity-60 cursor-not-allowed">
|
||||
{options.find((o) => o.value === String(cellValue ?? ''))?.label || 'Select...'}
|
||||
</div>
|
||||
) : (
|
||||
@@ -300,7 +300,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
|
||||
onChange={(e) => updateCell(rowIndex, col.key, e.target.value)}
|
||||
placeholder={col.placeholder}
|
||||
disabled={isDisabled}
|
||||
className="w-full px-3 py-2 rounded-lg border-hairline border-(--border-muted) bg-(--bg-soft) text-sm
|
||||
className="w-full px-3 py-2 rounded-lg border border-(--border-muted) bg-(--bg-soft) text-sm
|
||||
focus:outline-hidden focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
|
||||
disabled:opacity-60 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
@@ -331,7 +331,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="col-span-full border-t-hairline border-(--border-muted) opacity-60" />
|
||||
<div className="col-span-full border-t border-(--border-muted) opacity-60" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -341,7 +341,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
|
||||
onClick={addRow}
|
||||
disabled={isDisabled}
|
||||
className="px-3 py-2 rounded-lg text-sm font-medium
|
||||
bg-(--bg-soft) border-hairline border-(--border-muted) hover-action transition-colors
|
||||
bg-(--bg-soft) border border-(--border-muted) hover-action transition-colors
|
||||
disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{field.addLabel || 'Add'}
|
||||
|
||||
@@ -82,7 +82,7 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full px-3 py-2 rounded-lg border-hairline border-(--border-muted) bg-(--bg-soft) text-sm
|
||||
className={`w-full px-3 py-2 rounded-lg border border-(--border-muted) bg-(--bg-soft) text-sm
|
||||
focus-within:outline-hidden focus-within:ring-2 focus-within:ring-sky-500/50 focus-within:border-sky-500
|
||||
transition-colors
|
||||
${isDisabled ? 'opacity-60 cursor-not-allowed' : 'cursor-text'}`}
|
||||
@@ -96,7 +96,7 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
|
||||
<span
|
||||
key={`${tag}-${idx}`}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md
|
||||
border-hairline border-(--border-muted) bg-(--bg)
|
||||
border border-(--border-muted) bg-(--bg)
|
||||
max-w-full"
|
||||
title={tag}
|
||||
>
|
||||
|
||||
@@ -19,7 +19,7 @@ export const TextField = ({ field, value, onChange, disabled }: TextFieldProps)
|
||||
placeholder={field.placeholder}
|
||||
maxLength={field.maxLength}
|
||||
disabled={isDisabled}
|
||||
className="w-full px-3 py-2 rounded-lg border-hairline border-(--border-muted) bg-(--bg-soft) text-sm
|
||||
className="w-full px-3 py-2 rounded-lg border border-(--border-muted) bg-(--bg-soft) text-sm
|
||||
focus:outline-hidden focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
|
||||
disabled:opacity-60 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
|
||||
@@ -78,7 +78,7 @@ interface FieldWrapperProps {
|
||||
const DisabledBadge = ({ reason }: { reason?: string }) => (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded
|
||||
bg-zinc-500/20 text-zinc-400 border-hairline border-zinc-500/30"
|
||||
bg-zinc-500/20 text-zinc-400 border border-zinc-500/30"
|
||||
title={reason || 'This setting is not available'}
|
||||
>
|
||||
<svg
|
||||
@@ -103,7 +103,7 @@ const DisabledBadge = ({ reason }: { reason?: string }) => (
|
||||
const RestartRequiredBadge = () => (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded
|
||||
bg-amber-500/20 text-amber-600 dark:text-amber-400 border-hairline border-amber-500/30"
|
||||
bg-amber-500/20 text-amber-600 dark:text-amber-400 border border-amber-500/30"
|
||||
title="Changing this setting requires a container restart to take effect"
|
||||
>
|
||||
<svg
|
||||
@@ -162,7 +162,7 @@ const UserOverriddenBadge = ({
|
||||
<Tooltip content={content} position="top">
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded
|
||||
bg-sky-500/15 text-sky-500 dark:text-sky-400 border-hairline border-sky-500/30"
|
||||
bg-sky-500/15 text-sky-500 dark:text-sky-400 border border-sky-500/30"
|
||||
>
|
||||
User overridden{count > 1 ? ` (${count})` : ''}
|
||||
</span>
|
||||
|
||||
@@ -5,7 +5,7 @@ interface SettingsSaveBarProps {
|
||||
|
||||
export const SettingsSaveBar = ({ onSave, isSaving }: SettingsSaveBarProps) => (
|
||||
<div
|
||||
className="shrink-0 px-6 py-4 border-t-hairline border-(--border-muted) bg-(--bg) animate-slide-up"
|
||||
className="shrink-0 px-6 py-4 border-t border-(--border-muted) bg-(--bg) animate-slide-up"
|
||||
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -142,16 +142,16 @@ export const RequestPolicyGrid = ({
|
||||
type="button"
|
||||
onClick={onClearOverrides}
|
||||
disabled={clearOverridesDisabled}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium border-hairline border-(--border-muted) bg-(--bg) hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium border border-(--border-muted) bg-(--bg) hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Clear all overrides
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border-hairline border-(--border-muted)">
|
||||
<div className="rounded-lg border border-(--border-muted)">
|
||||
{/* Header */}
|
||||
<div className="hidden sm:grid sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1fr)] gap-3 px-3 py-2 bg-(--bg-soft) text-xs font-medium opacity-60 border-b-hairline border-(--border-muted) rounded-t-lg">
|
||||
<div className="hidden sm:grid sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1fr)] gap-3 px-3 py-2 bg-(--bg-soft) text-xs font-medium opacity-60 border-b border-(--border-muted) rounded-t-lg">
|
||||
<span>Source</span>
|
||||
<span>Ebook</span>
|
||||
<span>Audiobook</span>
|
||||
@@ -178,7 +178,7 @@ export const RequestPolicyGrid = ({
|
||||
<div key={contentType} className="flex items-center gap-1.5">
|
||||
{mobileLabel}
|
||||
{isDisabled ? (
|
||||
<div className="w-full px-3 py-2 rounded-lg border-hairline border-(--border-muted) bg-(--bg) text-sm opacity-60 cursor-not-allowed">
|
||||
<div className="w-full px-3 py-2 rounded-lg border border-(--border-muted) bg-(--bg) text-sm opacity-60 cursor-not-allowed">
|
||||
{REQUEST_POLICY_MODE_LABELS[mode]}
|
||||
</div>
|
||||
) : (
|
||||
@@ -221,7 +221,7 @@ export const RequestPolicyGrid = ({
|
||||
<div
|
||||
key={sourceRow.source}
|
||||
className={`grid grid-cols-1 sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1fr)] gap-3 px-3 py-2.5 items-center ${
|
||||
index > 0 ? 'border-t-hairline border-(--border-muted)' : ''
|
||||
index > 0 ? 'border-t border-(--border-muted)' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
@@ -295,7 +295,7 @@ export const RequestPolicyGrid = ({
|
||||
}`}
|
||||
>
|
||||
{rulesDisabled ? (
|
||||
<div className="w-full px-3 py-2 rounded-lg border-hairline border-(--border-muted) bg-(--bg-soft) text-sm opacity-60 cursor-not-allowed">
|
||||
<div className="w-full px-3 py-2 rounded-lg border border-(--border-muted) bg-(--bg-soft) text-sm opacity-60 cursor-not-allowed">
|
||||
{REQUEST_POLICY_MODE_LABELS[effectiveMode]}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -9,7 +9,7 @@ import { FieldWrapper } from '../shared';
|
||||
import { CreateUserFormState } from './types';
|
||||
|
||||
const UserCardShell = ({ title, children }: { title: string; children: ReactNode }) => (
|
||||
<div className="space-y-5 p-4 rounded-lg border-hairline border-(--border-muted) shadow-sm bg-(--bg)">
|
||||
<div className="space-y-5 p-4 rounded-lg border border-(--border-muted) bg-(--bg)">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
@@ -251,7 +251,7 @@ export const UserEditActions = ({
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={cancelDisabled}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-(--bg-soft) border-hairline border-(--border-muted) shadow-sm hover-action transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-(--bg-soft) border border-(--border-muted) shadow-sm hover-action transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -278,7 +278,7 @@ export const UserEditActions = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pt-3 border-t-hairline border-(--border-muted) sm:flex-row sm:items-center">
|
||||
<div className="flex flex-col gap-2 pt-3 border-t border-(--border-muted) sm:flex-row sm:items-center">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={onSave}
|
||||
@@ -290,7 +290,7 @@ export const UserEditActions = ({
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={cancelDisabled}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium border-hairline border-(--border-muted) bg-(--bg) hover:bg-(--hover-surface) transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium border border-(--border-muted) bg-(--bg) hover:bg-(--hover-surface) transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -309,7 +309,7 @@ export const UserEditActions = ({
|
||||
<button
|
||||
onClick={onCancelDelete}
|
||||
disabled={deleting}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium border-hairline border-(--border-muted)
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium border border-(--border-muted)
|
||||
bg-(--bg) hover:bg-(--hover-surface) transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
@@ -319,7 +319,7 @@ export const UserEditActions = ({
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
border-hairline border-red-500/40 text-red-600 hover:bg-red-500/10"
|
||||
border border-red-500/40 text-red-600 hover:bg-red-500/10"
|
||||
>
|
||||
Delete User
|
||||
</button>
|
||||
@@ -400,7 +400,7 @@ export const UserCreateCard = ({
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium border-hairline border-(--border-muted) bg-(--bg) hover:bg-(--hover-surface) transition-colors"
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium border border-(--border-muted) bg-(--bg) hover:bg-(--hover-surface) transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -572,7 +572,7 @@ export const UserAccountCardContent = ({
|
||||
{preferencesContent && preferencesPlacement === 'before' && (
|
||||
<>
|
||||
{preferencesContent}
|
||||
<div className="border-t-hairline border-(--border-muted)" />
|
||||
<div className="border-t border-(--border-muted)" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -596,7 +596,7 @@ export const UserAccountCardContent = ({
|
||||
|
||||
{preferencesContent && preferencesPlacement === 'after' && (
|
||||
<>
|
||||
<div className="border-t-hairline border-(--border-muted)" />
|
||||
<div className="border-t border-(--border-muted)" />
|
||||
{preferencesContent}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -96,7 +96,7 @@ export const UserListView = ({
|
||||
<p className="text-sm opacity-60">{loadError}</p>
|
||||
<button
|
||||
onClick={onRetryLoadUsers}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium border-hairline border-(--border-muted) bg-(--bg-soft) hover:bg-(--hover-surface) transition-colors"
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium border border-(--border-muted) bg-(--bg-soft) hover:bg-(--hover-surface) transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
@@ -117,7 +117,7 @@ export const UserListView = ({
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
className={`rounded-lg border-hairline border-(--border-muted) shadow-sm bg-(--bg-soft) transition-colors ${active ? '' : 'opacity-60'}`}
|
||||
className={`rounded-lg border border-(--border-muted) bg-(--bg-soft) transition-colors ${active ? '' : 'opacity-60'}`}
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
@@ -143,7 +143,7 @@ export const UserListView = ({
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between p-3 cursor-pointer hover-surface rounded-t-lg ${isEditingRow ? 'border-b-hairline border-(--border-muted)' : 'rounded-b-lg'}`}
|
||||
className={`flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between p-3 cursor-pointer hover-surface rounded-t-lg ${isEditingRow ? 'border-b border-(--border-muted)' : 'rounded-b-lg'}`}
|
||||
aria-expanded={isEditingRow}
|
||||
aria-label={isEditingRow ? 'Collapse user editor' : `Expand ${user.username} editor`}
|
||||
>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { DeliveryPreferencesResponse } from '../../../services/api';
|
||||
import {
|
||||
HeadingFieldConfig,
|
||||
MultiSelectFieldConfig,
|
||||
SelectFieldConfig,
|
||||
TextFieldConfig,
|
||||
} from '../../../types/settings';
|
||||
import { HeadingField, SelectField, TextField } from '../fields';
|
||||
import { HeadingField, MultiSelectField, SelectField, TextField } from '../fields';
|
||||
import { FieldWrapper } from '../shared';
|
||||
import { getFieldByKey } from './fieldHelpers';
|
||||
import { PerUserSettings } from './types';
|
||||
@@ -76,6 +77,19 @@ const fallbackEmailRecipientField: TextFieldConfig = {
|
||||
placeholder: 'reader@example.com',
|
||||
};
|
||||
|
||||
const fallbackBrowserDownloadField: MultiSelectFieldConfig = {
|
||||
type: 'MultiSelectField',
|
||||
key: 'DOWNLOAD_TO_BROWSER_CONTENT_TYPES',
|
||||
label: 'Download to Browser',
|
||||
description: 'Automatically download completed files to this browser for the selected content types.',
|
||||
value: [],
|
||||
variant: 'dropdown',
|
||||
options: [
|
||||
{ value: 'book', label: 'Books' },
|
||||
{ value: 'audiobook', label: 'Audiobooks' },
|
||||
],
|
||||
};
|
||||
|
||||
type DeliverySettingKey = keyof PerUserSettings;
|
||||
|
||||
function normalizeMode(value: unknown): 'folder' | 'booklore' | 'email' {
|
||||
@@ -138,11 +152,26 @@ export const UserOverridesSection = ({
|
||||
const bookloreLibraryField = getFieldByKey<SelectFieldConfig>(fields, 'BOOKLORE_LIBRARY_ID', fallbackBookloreLibraryField);
|
||||
const booklorePathField = getFieldByKey<SelectFieldConfig>(fields, 'BOOKLORE_PATH_ID', fallbackBooklorePathField);
|
||||
const emailRecipientFieldSource = getFieldByKey<TextFieldConfig>(fields, 'EMAIL_RECIPIENT', fallbackEmailRecipientField);
|
||||
const browserDownloadField = getFieldByKey<MultiSelectFieldConfig>(
|
||||
fields,
|
||||
'DOWNLOAD_TO_BROWSER_CONTENT_TYPES',
|
||||
fallbackBrowserDownloadField,
|
||||
);
|
||||
const emailRecipientField: TextFieldConfig = {
|
||||
...emailRecipientFieldSource,
|
||||
label: 'Email Recipient',
|
||||
description: 'Email address used for this user in Email output mode.',
|
||||
};
|
||||
const browserDownloadGlobalValue = Array.isArray(globalValues.DOWNLOAD_TO_BROWSER_CONTENT_TYPES)
|
||||
? globalValues.DOWNLOAD_TO_BROWSER_CONTENT_TYPES
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter((entry) => entry.length > 0)
|
||||
: [];
|
||||
const browserDownloadUserValue = Array.isArray(userSettings.DOWNLOAD_TO_BROWSER_CONTENT_TYPES)
|
||||
? userSettings.DOWNLOAD_TO_BROWSER_CONTENT_TYPES
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter((entry) => entry.length > 0)
|
||||
: [];
|
||||
|
||||
const isOverridden = (key: DeliverySettingKey): boolean => {
|
||||
if (
|
||||
@@ -158,6 +187,12 @@ export const UserOverridesSection = ({
|
||||
return userValue !== globalValue;
|
||||
};
|
||||
|
||||
const isBrowserDownloadOverridden = (
|
||||
Object.prototype.hasOwnProperty.call(userSettings, 'DOWNLOAD_TO_BROWSER_CONTENT_TYPES')
|
||||
&& userSettings.DOWNLOAD_TO_BROWSER_CONTENT_TYPES !== null
|
||||
&& JSON.stringify(browserDownloadUserValue) !== JSON.stringify(browserDownloadGlobalValue)
|
||||
);
|
||||
|
||||
const resetKeys = (keys: DeliverySettingKey[]) => {
|
||||
setUserSettings((prev) => {
|
||||
const next = { ...prev };
|
||||
@@ -181,6 +216,9 @@ export const UserOverridesSection = ({
|
||||
const outputModeValue = readValue('BOOKS_OUTPUT_MODE', 'folder');
|
||||
const effectiveOutputMode = normalizeMode(outputModeValue);
|
||||
|
||||
const browserDownloadContentTypes = isBrowserDownloadOverridden
|
||||
? browserDownloadUserValue
|
||||
: browserDownloadGlobalValue;
|
||||
const destinationValue = readValue('DESTINATION');
|
||||
const destinationAudiobookValue = readValue('DESTINATION_AUDIOBOOK');
|
||||
const libraryValue = readValue('BOOKLORE_LIBRARY_ID');
|
||||
@@ -194,6 +232,7 @@ export const UserOverridesSection = ({
|
||||
const hasAudiobookDeliveryOverride = availableAudiobookPreferenceKeys.some((key) => isOverridden(key));
|
||||
|
||||
const canOverrideOutputMode = isUserOverridable('BOOKS_OUTPUT_MODE');
|
||||
const canOverrideBrowserDownload = isUserOverridable('DOWNLOAD_TO_BROWSER_CONTENT_TYPES');
|
||||
const canOverrideDestination = isUserOverridable('DESTINATION');
|
||||
const canOverrideAudiobookDestination = isUserOverridable('DESTINATION_AUDIOBOOK');
|
||||
const canOverrideBookloreLibrary = isUserOverridable('BOOKLORE_LIBRARY_ID');
|
||||
@@ -207,6 +246,31 @@ export const UserOverridesSection = ({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<HeadingField field={deliveryHeading} />
|
||||
|
||||
{canOverrideBrowserDownload && (
|
||||
<FieldWrapper
|
||||
field={browserDownloadField}
|
||||
resetAction={
|
||||
isBrowserDownloadOverridden
|
||||
? {
|
||||
disabled: Boolean(browserDownloadField.fromEnv),
|
||||
onClick: () => resetKeys(['DOWNLOAD_TO_BROWSER_CONTENT_TYPES']),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<MultiSelectField
|
||||
field={browserDownloadField}
|
||||
value={browserDownloadContentTypes}
|
||||
onChange={(value) => setUserSettings((prev) => ({
|
||||
...prev,
|
||||
DOWNLOAD_TO_BROWSER_CONTENT_TYPES: value,
|
||||
}))}
|
||||
disabled={Boolean(browserDownloadField.fromEnv)}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
)}
|
||||
|
||||
<HeadingField field={booksHeading} />
|
||||
|
||||
{canOverrideOutputMode && (
|
||||
|
||||
@@ -199,7 +199,7 @@ export const UserOverridesSections = ({
|
||||
<div className="space-y-5">
|
||||
{sectionNodes.map(({ id, node }, index) => (
|
||||
<Fragment key={id}>
|
||||
{index > 0 && <div className="border-t-hairline border-(--border-muted)" />}
|
||||
{index > 0 && <div className="border-t border-(--border-muted)" />}
|
||||
{node}
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
@@ -38,7 +38,7 @@ export const UserOverridesView = ({
|
||||
<div>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border-hairline border-(--border-muted) bg-(--bg) hover:bg-(--hover-surface) transition-colors"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border border-(--border-muted) bg-(--bg) hover:bg-(--hover-surface) transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
|
||||
@@ -16,7 +16,8 @@ type SearchSettingKey =
|
||||
| 'SEARCH_MODE'
|
||||
| 'METADATA_PROVIDER'
|
||||
| 'METADATA_PROVIDER_AUDIOBOOK'
|
||||
| 'DEFAULT_RELEASE_SOURCE';
|
||||
| 'DEFAULT_RELEASE_SOURCE'
|
||||
| 'DEFAULT_RELEASE_SOURCE_AUDIOBOOK';
|
||||
|
||||
const fallbackSearchModeField: SelectFieldConfig = {
|
||||
type: 'SelectField',
|
||||
@@ -51,12 +52,22 @@ const fallbackAudiobookMetadataProviderField: SelectFieldConfig = {
|
||||
const fallbackDefaultReleaseSourceField: SelectFieldConfig = {
|
||||
type: 'SelectField',
|
||||
key: 'DEFAULT_RELEASE_SOURCE',
|
||||
label: 'Default Release Source',
|
||||
description: 'The release source tab to open by default in the release modal.',
|
||||
label: 'Default Book Release Source',
|
||||
description: 'The release source tab to open by default in the release modal for books.',
|
||||
value: 'direct_download',
|
||||
options: [],
|
||||
};
|
||||
|
||||
const fallbackDefaultAudiobookReleaseSourceField: SelectFieldConfig = {
|
||||
type: 'SelectField',
|
||||
key: 'DEFAULT_RELEASE_SOURCE_AUDIOBOOK',
|
||||
label: 'Default Audiobook Release Source',
|
||||
description:
|
||||
'The release source tab to open by default in the release modal for audiobooks. Uses the book release source if not set.',
|
||||
value: '',
|
||||
options: [{ value: '', label: 'Use book release source' }],
|
||||
};
|
||||
|
||||
const searchHeading: HeadingFieldConfig = {
|
||||
type: 'HeadingField',
|
||||
key: 'search_preferences_heading',
|
||||
@@ -110,6 +121,11 @@ export const UserSearchPreferencesSection = ({
|
||||
'DEFAULT_RELEASE_SOURCE',
|
||||
fallbackDefaultReleaseSourceField
|
||||
);
|
||||
const defaultAudiobookReleaseSourceField = getFieldByKey<SelectFieldConfig>(
|
||||
fields,
|
||||
'DEFAULT_RELEASE_SOURCE_AUDIOBOOK',
|
||||
fallbackDefaultAudiobookReleaseSourceField
|
||||
);
|
||||
|
||||
const isOverridden = (key: SearchSettingKey): boolean => {
|
||||
if (
|
||||
@@ -148,6 +164,7 @@ export const UserSearchPreferencesSection = ({
|
||||
const metadataProviderValue = readValue('METADATA_PROVIDER');
|
||||
const metadataProviderAudiobookValue = readValue('METADATA_PROVIDER_AUDIOBOOK');
|
||||
const defaultReleaseSourceValue = readValue('DEFAULT_RELEASE_SOURCE', 'direct_download');
|
||||
const defaultAudiobookReleaseSourceValue = readValue('DEFAULT_RELEASE_SOURCE_AUDIOBOOK');
|
||||
|
||||
const canOverrideSearchMode = isUserOverridable('SEARCH_MODE') && preferenceKeySet.has('SEARCH_MODE');
|
||||
const canOverrideMetadataProvider = isUserOverridable('METADATA_PROVIDER')
|
||||
@@ -156,12 +173,15 @@ export const UserSearchPreferencesSection = ({
|
||||
&& preferenceKeySet.has('METADATA_PROVIDER_AUDIOBOOK');
|
||||
const canOverrideDefaultReleaseSource = isUserOverridable('DEFAULT_RELEASE_SOURCE')
|
||||
&& preferenceKeySet.has('DEFAULT_RELEASE_SOURCE');
|
||||
const canOverrideDefaultAudiobookReleaseSource = isUserOverridable('DEFAULT_RELEASE_SOURCE_AUDIOBOOK')
|
||||
&& preferenceKeySet.has('DEFAULT_RELEASE_SOURCE_AUDIOBOOK');
|
||||
|
||||
if (
|
||||
!canOverrideSearchMode
|
||||
&& !canOverrideMetadataProvider
|
||||
&& !canOverrideAudiobookMetadataProvider
|
||||
&& !canOverrideDefaultReleaseSource
|
||||
&& !canOverrideDefaultAudiobookReleaseSource
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -253,6 +273,30 @@ export const UserSearchPreferencesSection = ({
|
||||
/>
|
||||
</FieldWrapper>
|
||||
)}
|
||||
|
||||
{effectiveSearchMode === 'universal' && canOverrideDefaultAudiobookReleaseSource && (
|
||||
<FieldWrapper
|
||||
field={defaultAudiobookReleaseSourceField}
|
||||
resetAction={
|
||||
isOverridden('DEFAULT_RELEASE_SOURCE_AUDIOBOOK')
|
||||
? {
|
||||
disabled: Boolean(defaultAudiobookReleaseSourceField.fromEnv),
|
||||
onClick: () => resetKeys(['DEFAULT_RELEASE_SOURCE_AUDIOBOOK']),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SelectField
|
||||
field={defaultAudiobookReleaseSourceField}
|
||||
value={defaultAudiobookReleaseSourceValue}
|
||||
onChange={(value) => setUserSettings((prev) => ({
|
||||
...prev,
|
||||
DEFAULT_RELEASE_SOURCE_AUDIOBOOK: value,
|
||||
}))}
|
||||
disabled={Boolean(defaultAudiobookReleaseSourceField.fromEnv)}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,10 +8,12 @@ export interface PerUserSettings {
|
||||
BOOKLORE_LIBRARY_ID?: string;
|
||||
BOOKLORE_PATH_ID?: string;
|
||||
EMAIL_RECIPIENT?: string;
|
||||
DOWNLOAD_TO_BROWSER_CONTENT_TYPES?: string[];
|
||||
SEARCH_MODE?: string;
|
||||
METADATA_PROVIDER?: string;
|
||||
METADATA_PROVIDER_AUDIOBOOK?: string;
|
||||
DEFAULT_RELEASE_SOURCE?: string;
|
||||
DEFAULT_RELEASE_SOURCE_AUDIOBOOK?: string;
|
||||
USER_NOTIFICATION_ROUTES?: Array<Record<string, unknown>>;
|
||||
REQUESTS_ENABLED?: boolean;
|
||||
REQUEST_POLICY_DEFAULT_EBOOK?: string;
|
||||
|
||||
@@ -11,7 +11,7 @@ interface SearchFieldRendererProps {
|
||||
}
|
||||
|
||||
const baseInputClass =
|
||||
'w-full px-3 py-2 rounded-md border-hairline border-(--border-muted) ' +
|
||||
'w-full px-3 py-2 rounded-md border border-(--border-muted) ' +
|
||||
'bg-(--bg-soft) text-sm ' +
|
||||
'focus:outline-hidden focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 ' +
|
||||
'transition-colors';
|
||||
|
||||
@@ -23,6 +23,5 @@ export const CONTENT_OPTIONS = [
|
||||
{ value: 'standards_document', label: 'Standards document' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
{ value: 'musical_score', label: 'Musical score' },
|
||||
{ value: 'audiobook', label: 'Audiobook' },
|
||||
];
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export const LoginPage = ({ onLogin, error, isLoading, authMode, oidcButtonLabel
|
||||
>
|
||||
<div className="w-full max-w-md">
|
||||
<div
|
||||
className="rounded-lg shadow-2xl p-6 border-hairline"
|
||||
className="rounded-lg shadow-2xl p-6 border"
|
||||
style={{
|
||||
backgroundColor: 'var(--card-background)',
|
||||
borderColor: 'var(--border-color)',
|
||||
|
||||
@@ -28,10 +28,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--border-width-hairline: var(--theme-border-width-hairline);
|
||||
}
|
||||
|
||||
@utility scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
@@ -72,9 +68,6 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
/* Hairline border: 0.5px on retina, 1px fallback */
|
||||
--theme-border-width-hairline: 1px;
|
||||
|
||||
/* Light theme variables */
|
||||
--primary-color: oklch(44.3% 0.11 240.79);
|
||||
--primary-dark: oklch(39.1% 0.09 240.876);
|
||||
@@ -117,14 +110,6 @@
|
||||
|
||||
}
|
||||
|
||||
@media (-webkit-min-device-pixel-ratio: 2),
|
||||
(min-resolution: 192dpi),
|
||||
(min-resolution: 2dppx) {
|
||||
:root {
|
||||
--theme-border-width-hairline: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Global scrollbar styling - thin, subtle, theme-aware */
|
||||
* {
|
||||
/* Firefox */
|
||||
@@ -165,8 +150,6 @@
|
||||
/* Typography */
|
||||
html, body {
|
||||
min-height: 100vh;
|
||||
/* Support for iOS notch/dynamic island - extend to full viewport */
|
||||
min-height: 100dvh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -197,14 +180,12 @@
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
transition: background-color 0.2s ease;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
min-height: 100%;
|
||||
background: var(--background-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -443,8 +424,8 @@
|
||||
.modal-overlay {
|
||||
padding: 0;
|
||||
align-items: stretch;
|
||||
/* Extend background into iOS safe areas */
|
||||
background: var(--bg-soft);
|
||||
/* Match page background so iOS PWA safe areas stay consistent */
|
||||
background: var(--background-color);
|
||||
}
|
||||
|
||||
.details-container {
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface Book {
|
||||
progress?: number;
|
||||
status_message?: string; // Detailed status message (e.g., "Trying Libgen (2/5)")
|
||||
added_time?: number; // Timestamp when added to queue
|
||||
content_type?: string; // "ebook", "audiobook", or related book subtype
|
||||
source?: string; // Release source handler (e.g., "direct_download", "prowlarr")
|
||||
source_display_name?: string; // Human-readable source name (e.g., "Direct Download")
|
||||
// Metadata provider fields (used in universal search mode)
|
||||
@@ -259,9 +260,11 @@ export interface AppConfig {
|
||||
metadata_sort_options: SortOption[];
|
||||
metadata_search_fields: MetadataSearchField[];
|
||||
default_release_source?: string; // Default tab in ReleaseModal (e.g., 'direct_download')
|
||||
default_release_source_audiobook?: string; // Default tab in ReleaseModal for audiobooks
|
||||
show_release_source_links: boolean;
|
||||
books_output_mode: BooksOutputMode;
|
||||
auto_open_downloads_sidebar: boolean; // Auto-open sidebar when download is queued
|
||||
download_to_browser: boolean; // Auto-download completed files to browser
|
||||
download_to_browser_content_types: string[]; // Auto-download completed files to browser for selected content types
|
||||
settings_enabled: boolean; // Whether config directory is mounted and writable
|
||||
onboarding_complete: boolean; // Whether the user has completed initial setup
|
||||
default_sort: string; // Default sort for direct mode
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Tests for search mode settings definitions."""
|
||||
|
||||
from shelfmark.config.settings import search_mode_settings
|
||||
|
||||
|
||||
def test_search_mode_settings_include_release_source_links_toggle():
|
||||
fields = {
|
||||
field.key: field
|
||||
for field in search_mode_settings()
|
||||
if hasattr(field, "key")
|
||||
}
|
||||
|
||||
field = fields["SHOW_RELEASE_SOURCE_LINKS"]
|
||||
|
||||
assert field.label == "Show Release Source Links"
|
||||
assert field.default is True
|
||||
assert field.user_overridable is False
|
||||
|
||||
@@ -369,8 +369,24 @@ def test_on_save_users_rejects_invalid_default_release_source_override(monkeypat
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.list_available_sources",
|
||||
lambda: [
|
||||
{"name": "direct_download", "display_name": "Direct Download", "enabled": True},
|
||||
{"name": "prowlarr", "display_name": "Prowlarr", "enabled": True},
|
||||
{
|
||||
"name": "direct_download",
|
||||
"display_name": "Direct Download",
|
||||
"enabled": True,
|
||||
"supported_content_types": ["ebook"],
|
||||
},
|
||||
{
|
||||
"name": "prowlarr",
|
||||
"display_name": "Prowlarr",
|
||||
"enabled": True,
|
||||
"supported_content_types": ["ebook", "audiobook"],
|
||||
},
|
||||
{
|
||||
"name": "audiobookbay",
|
||||
"display_name": "AudiobookBay",
|
||||
"enabled": True,
|
||||
"supported_content_types": ["audiobook"],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
@@ -378,3 +394,55 @@ def test_on_save_users_rejects_invalid_default_release_source_override(monkeypat
|
||||
|
||||
assert result["error"] is True
|
||||
assert "DEFAULT_RELEASE_SOURCE must be a valid release source name or empty" in result["message"]
|
||||
|
||||
|
||||
def test_on_save_users_rejects_audiobook_only_source_for_book_default(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.list_available_sources",
|
||||
lambda: [
|
||||
{
|
||||
"name": "direct_download",
|
||||
"display_name": "Direct Download",
|
||||
"enabled": True,
|
||||
"supported_content_types": ["ebook"],
|
||||
},
|
||||
{
|
||||
"name": "audiobookbay",
|
||||
"display_name": "AudiobookBay",
|
||||
"enabled": True,
|
||||
"supported_content_types": ["audiobook"],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
result = users_settings_module._on_save_users({"DEFAULT_RELEASE_SOURCE": "audiobookbay"})
|
||||
|
||||
assert result["error"] is True
|
||||
assert "DEFAULT_RELEASE_SOURCE must be a valid release source name or empty" in result["message"]
|
||||
|
||||
|
||||
def test_on_save_users_rejects_book_only_source_for_audiobook_default(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.list_available_sources",
|
||||
lambda: [
|
||||
{
|
||||
"name": "direct_download",
|
||||
"display_name": "Direct Download",
|
||||
"enabled": True,
|
||||
"supported_content_types": ["ebook"],
|
||||
},
|
||||
{
|
||||
"name": "audiobookbay",
|
||||
"display_name": "AudiobookBay",
|
||||
"enabled": True,
|
||||
"supported_content_types": ["audiobook"],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
result = users_settings_module._on_save_users(
|
||||
{"DEFAULT_RELEASE_SOURCE_AUDIOBOOK": "direct_download"}
|
||||
)
|
||||
|
||||
assert result["error"] is True
|
||||
assert "DEFAULT_RELEASE_SOURCE_AUDIOBOOK must be a valid release source name or empty" in result["message"]
|
||||
|
||||
@@ -1175,6 +1175,7 @@ class TestAdminSearchPreferences:
|
||||
"METADATA_PROVIDER": "openlibrary",
|
||||
"METADATA_PROVIDER_AUDIOBOOK": "",
|
||||
"DEFAULT_RELEASE_SOURCE": "direct_download",
|
||||
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK": "",
|
||||
}
|
||||
(plugins_dir / "search_mode.json").write_text(json.dumps(search_mode_config))
|
||||
|
||||
@@ -1189,6 +1190,7 @@ class TestAdminSearchPreferences:
|
||||
"SEARCH_MODE": "universal",
|
||||
"METADATA_PROVIDER": "openlibrary",
|
||||
"DEFAULT_RELEASE_SOURCE": "prowlarr",
|
||||
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK": "audiobookbay",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1202,6 +1204,7 @@ class TestAdminSearchPreferences:
|
||||
"METADATA_PROVIDER",
|
||||
"METADATA_PROVIDER_AUDIOBOOK",
|
||||
"DEFAULT_RELEASE_SOURCE",
|
||||
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
|
||||
]
|
||||
|
||||
field_keys = [field["key"] for field in data["fields"]]
|
||||
@@ -1210,6 +1213,7 @@ class TestAdminSearchPreferences:
|
||||
assert data["userOverrides"]["SEARCH_MODE"] == "universal"
|
||||
assert data["userOverrides"]["METADATA_PROVIDER"] == "openlibrary"
|
||||
assert data["userOverrides"]["DEFAULT_RELEASE_SOURCE"] == "prowlarr"
|
||||
assert data["userOverrides"]["DEFAULT_RELEASE_SOURCE_AUDIOBOOK"] == "audiobookbay"
|
||||
|
||||
assert data["effective"]["SEARCH_MODE"]["source"] == "user_override"
|
||||
assert data["effective"]["SEARCH_MODE"]["value"] == "universal"
|
||||
@@ -1217,6 +1221,8 @@ class TestAdminSearchPreferences:
|
||||
assert data["effective"]["METADATA_PROVIDER_AUDIOBOOK"]["source"] in {"global_config", "default"}
|
||||
assert data["effective"]["DEFAULT_RELEASE_SOURCE"]["source"] == "user_override"
|
||||
assert data["effective"]["DEFAULT_RELEASE_SOURCE"]["value"] == "prowlarr"
|
||||
assert data["effective"]["DEFAULT_RELEASE_SOURCE_AUDIOBOOK"]["source"] == "user_override"
|
||||
assert data["effective"]["DEFAULT_RELEASE_SOURCE_AUDIOBOOK"]["value"] == "audiobookbay"
|
||||
|
||||
def test_returns_404_for_unknown_user(self, admin_client):
|
||||
resp = admin_client.get("/api/admin/users/9999/search-preferences")
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""API tests for the frontend config endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def main_module():
|
||||
"""Import `shelfmark.main` with background startup disabled."""
|
||||
with patch("shelfmark.download.orchestrator.start"):
|
||||
import shelfmark.main as main
|
||||
|
||||
importlib.reload(main)
|
||||
return main
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(main_module):
|
||||
return main_module.app.test_client()
|
||||
|
||||
|
||||
def _set_session(client, *, user_id: str, db_user_id: int, is_admin: bool) -> None:
|
||||
with client.session_transaction() as sess:
|
||||
sess["user_id"] = user_id
|
||||
sess["db_user_id"] = db_user_id
|
||||
sess["is_admin"] = is_admin
|
||||
|
||||
|
||||
def _create_user(main_module, *, prefix: str, role: str = "user") -> dict:
|
||||
username = f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
return main_module.user_db.create_user(username=username, role=role)
|
||||
|
||||
|
||||
def test_config_includes_release_source_links_toggle(main_module, client):
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
|
||||
|
||||
def fake_get(key, default=None, user_id=None): # noqa: ANN001
|
||||
if key == "SHOW_RELEASE_SOURCE_LINKS":
|
||||
return False
|
||||
return default
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
with patch.object(main_module.app_config, "get", side_effect=fake_get):
|
||||
with patch("shelfmark.metadata_providers.get_provider_sort_options", return_value=[]):
|
||||
with patch("shelfmark.metadata_providers.get_provider_search_fields", return_value=[]):
|
||||
with patch("shelfmark.metadata_providers.get_provider_default_sort", return_value="relevance"):
|
||||
resp = client.get("/api/config")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json["show_release_source_links"] is False
|
||||
@@ -224,6 +224,33 @@ class TestAtomicCopy:
|
||||
assert result.exists()
|
||||
assert result.read_text() == "content"
|
||||
|
||||
def test_copy_tolerates_post_publish_estale(self, tmp_path, monkeypatch):
|
||||
"""Treat ESTALE on the final destination as a successful NFS publish."""
|
||||
import errno
|
||||
|
||||
from shelfmark.download import fs
|
||||
|
||||
source = tmp_path / "source.txt"
|
||||
source.write_text("content")
|
||||
dest = tmp_path / "dest.txt"
|
||||
|
||||
original_verify = fs._verify_transfer_size
|
||||
|
||||
def _verify(path, expected_size, action):
|
||||
if path == dest:
|
||||
raise OSError(getattr(errno, "ESTALE", 116), "Stale file handle", str(path))
|
||||
return original_verify(path, expected_size, action)
|
||||
|
||||
monkeypatch.setattr(fs, "_verify_transfer_size", _verify)
|
||||
monkeypatch.setattr(fs.time, "sleep", lambda *_args, **_kwargs: None)
|
||||
|
||||
result = fs.atomic_copy(source, dest)
|
||||
|
||||
assert result == dest
|
||||
assert dest.exists()
|
||||
assert dest.read_text() == "content"
|
||||
assert source.exists()
|
||||
|
||||
def test_publish_does_not_depend_on_hardlinks(self, tmp_path, monkeypatch):
|
||||
"""Temp-file publish succeeds even when hardlinks are unavailable."""
|
||||
|
||||
|
||||
@@ -280,6 +280,42 @@ class TestQBittorrentClientGetStatus:
|
||||
assert status.complete is True
|
||||
assert status.file_path == "/downloads/shelfmark/Ground State - Craig Alanson/Ground State - Craig Alanson.epub"
|
||||
|
||||
def test_get_status_paused_up_complete(self, monkeypatch):
|
||||
"""qBittorrent-compatible clients may report completed items as pausedUP."""
|
||||
config_values = {
|
||||
"QBITTORRENT_URL": "http://localhost:8080",
|
||||
"QBITTORRENT_USERNAME": "admin",
|
||||
"QBITTORRENT_PASSWORD": "password",
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
mock_torrent = MockTorrent(
|
||||
hash_val="abc123",
|
||||
progress=1.0,
|
||||
state="pausedUP",
|
||||
content_path="/downloads/completed.epub",
|
||||
)
|
||||
mock_client_instance = MagicMock()
|
||||
info_payload = mock_torrent.to_dict() | {"save_path": "/downloads"}
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response([info_payload], status_code=200)
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
status = client.get_status("abc123")
|
||||
|
||||
assert status.complete is True
|
||||
assert status.state_value == "complete"
|
||||
assert status.file_path == "/downloads/completed.epub"
|
||||
|
||||
def test_get_status_complete_derives_when_content_path_equals_save_path(self, monkeypatch):
|
||||
"""Keep get_status() and get_download_path() consistent."""
|
||||
config_values = {
|
||||
@@ -586,6 +622,98 @@ class TestQBittorrentClientAddDownload:
|
||||
|
||||
mock_client_instance.torrents_create_category.assert_called_once_with(name="books")
|
||||
|
||||
def test_add_download_accepts_empty_success_response(self, monkeypatch):
|
||||
"""qBittorrent-compatible shims may return HTTP 200 with an empty body."""
|
||||
config_values = {
|
||||
"QBITTORRENT_URL": "http://localhost:8080",
|
||||
"QBITTORRENT_USERNAME": "admin",
|
||||
"QBITTORRENT_PASSWORD": "password",
|
||||
"QBITTORRENT_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.torrents_add.return_value = ""
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response({}, status_code=200)
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
magnet = f"magnet:?xt=urn:btih:{valid_hash}&dn=test"
|
||||
result = client.add_download(magnet, "Test")
|
||||
|
||||
assert result == valid_hash
|
||||
assert mock_client_instance._session.get.call_count >= 1
|
||||
|
||||
def test_add_download_explicit_failure_response_raises(self, monkeypatch):
|
||||
"""Explicit failure responses should still fail fast."""
|
||||
config_values = {
|
||||
"QBITTORRENT_URL": "http://localhost:8080",
|
||||
"QBITTORRENT_USERNAME": "admin",
|
||||
"QBITTORRENT_PASSWORD": "password",
|
||||
"QBITTORRENT_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.torrents_add.return_value = "Fails."
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
magnet = f"magnet:?xt=urn:btih:{valid_hash}&dn=test"
|
||||
|
||||
with pytest.raises(Exception, match="Failed to add torrent: Fails\\."):
|
||||
client.add_download(magnet, "Test")
|
||||
|
||||
def test_add_download_omits_empty_category(self, monkeypatch):
|
||||
"""Avoid sending empty category values to qBittorrent-compatible clients."""
|
||||
config_values = {
|
||||
"QBITTORRENT_URL": "http://localhost:8080",
|
||||
"QBITTORRENT_USERNAME": "admin",
|
||||
"QBITTORRENT_PASSWORD": "password",
|
||||
"QBITTORRENT_CATEGORY": "",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.torrents_add.return_value = "Ok."
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response({}, status_code=200)
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
magnet = f"magnet:?xt=urn:btih:{valid_hash}&dn=test"
|
||||
client.add_download(magnet, "Test")
|
||||
|
||||
mock_client_instance.torrents_create_category.assert_not_called()
|
||||
call_kwargs = mock_client_instance.torrents_add.call_args.kwargs
|
||||
assert "category" not in call_kwargs
|
||||
|
||||
def test_add_download_uses_configured_download_dir(self, monkeypatch):
|
||||
"""Test that add_download passes configured download directory."""
|
||||
config_values = {
|
||||
|
||||
Reference in New Issue
Block a user