mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-04 09:01:12 +01:00
Refine UI and adjust content type settings (#705)
- Tweak manual search toggle position - Refinements to the Hardcover list dropdown behavior - Hide the content type dropdown when a content type is blocked for a user - Fixes to Hardcover author parsing to strip out initialed names - Remove `env_supported=false` for security config options.
This commit is contained in:
+177
-11
@@ -10,6 +10,7 @@ This document lists all configuration options that can be set via environment va
|
||||
- [General](#general)
|
||||
- [Search Mode](#search-mode)
|
||||
- [Downloads](#downloads)
|
||||
- [Security](#security)
|
||||
- [Network](#network)
|
||||
- [Advanced](#advanced)
|
||||
- [Prowlarr](#prowlarr)
|
||||
@@ -251,8 +252,8 @@ The release source tab to open by default in the release modal.
|
||||
| `BOOKS_OUTPUT_MODE` | Choose where completed book files are sent. | string (choice) | `folder` |
|
||||
| `INGEST_DIR` | Directory where downloaded files are saved. Use {User} for per-user folders (e.g. /books/{User}). | string | `/books` |
|
||||
| `FILE_ORGANIZATION` | Choose how downloaded book files are named and organized. | string (choice) | `rename` |
|
||||
| `TEMPLATE_RENAME` | Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. | string | `{Author} - {Title} ({Year})` |
|
||||
| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` |
|
||||
| `TEMPLATE_RENAME` | Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title} ({Year})` |
|
||||
| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` |
|
||||
| `HARDLINK_TORRENTS` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `false` |
|
||||
| `BOOKLORE_HOST` | Base URL of your Booklore instance | string | _none_ |
|
||||
| `BOOKLORE_USERNAME` | Booklore account username | string | _none_ |
|
||||
@@ -273,8 +274,8 @@ The release source tab to open by default in the release modal.
|
||||
| `EMAIL_ALLOW_UNVERIFIED_TLS` | Disable TLS certificate verification (not recommended). | boolean | `false` |
|
||||
| `DESTINATION_AUDIOBOOK` | Directory where downloaded audiobook files are saved. Leave empty to use the Books destination. | string | _none_ |
|
||||
| `FILE_ORGANIZATION_AUDIOBOOK` | Choose how downloaded audiobook files are named and organized. | string (choice) | `rename` |
|
||||
| `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. | string | `{Author} - {Title}` |
|
||||
| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}` |
|
||||
| `TEMPLATE_AUDIOBOOK_RENAME` | 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. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title}` |
|
||||
| `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` |
|
||||
@@ -318,7 +319,7 @@ Choose how downloaded book files are named and organized.
|
||||
|
||||
**Naming Template**
|
||||
|
||||
Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders.
|
||||
Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `{Author} - {Title} ({Year})`
|
||||
@@ -327,7 +328,7 @@ Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesP
|
||||
|
||||
**Path Template**
|
||||
|
||||
Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
|
||||
Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `{Author}/{Title} ({Year})`
|
||||
@@ -528,7 +529,7 @@ Choose how downloaded audiobook files are named and organized.
|
||||
|
||||
**Naming Template**
|
||||
|
||||
Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for 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. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `{Author} - {Title}`
|
||||
@@ -537,7 +538,7 @@ Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subti
|
||||
|
||||
**Path Template**
|
||||
|
||||
Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
|
||||
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.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `{Author}/{Title}`
|
||||
@@ -592,10 +593,165 @@ How long to keep completed/failed downloads in the queue display.
|
||||
|
||||
</details>
|
||||
|
||||
## Security
|
||||
|
||||
| Variable | Description | Type | Default |
|
||||
|----------|-------------|------|---------|
|
||||
| `AUTH_METHOD` | Select the authentication method for accessing Shelfmark. | string (choice) | `none` |
|
||||
| `PROXY_AUTH_USER_HEADER` | The HTTP header your proxy uses to pass the authenticated username. | string | `X-Auth-User` |
|
||||
| `PROXY_AUTH_LOGOUT_URL` | The URL to redirect users to for logging out. Leave empty to disable logout functionality. | string | _empty string_ |
|
||||
| `PROXY_AUTH_ADMIN_GROUP_HEADER` | Optional: header your proxy uses to pass user groups/roles. | string | `X-Auth-Groups` |
|
||||
| `PROXY_AUTH_ADMIN_GROUP_NAME` | Optional: users in this group are treated as admins. Leave blank to skip group-based admin detection. | string | _empty string_ |
|
||||
| `OIDC_DISCOVERY_URL` | OpenID Connect discovery endpoint URL. Usually ends with /.well-known/openid-configuration. | string | _none_ |
|
||||
| `OIDC_CLIENT_ID` | OAuth2 client ID from your identity provider. | string | _none_ |
|
||||
| `OIDC_CLIENT_SECRET` | OAuth2 client secret from your identity provider. | string (secret) | _none_ |
|
||||
| `OIDC_SCOPES` | OAuth2 scopes to request from the identity provider. Managed automatically: includes essential scopes and the group claim when using admin group authorization. | string | `openid,email,profile` |
|
||||
| `OIDC_GROUP_CLAIM` | The name of the claim in the ID token that contains user groups. | string | `groups` |
|
||||
| `OIDC_ADMIN_GROUP` | Users in this group will be given admin access (if enabled below). Leave empty to use database roles only. | string | _empty string_ |
|
||||
| `OIDC_USE_ADMIN_GROUP` | When enabled, users in the Admin Group are granted admin access. When disabled, admin access is determined solely by database roles. | boolean | `true` |
|
||||
| `OIDC_AUTO_PROVISION` | Automatically create a user account on first OIDC login. When disabled, users must be pre-created by an admin. | boolean | `true` |
|
||||
| `OIDC_BUTTON_LABEL` | Custom label for the OIDC sign-in button on the login page. | string | _empty string_ |
|
||||
|
||||
<details>
|
||||
<summary>Detailed descriptions</summary>
|
||||
|
||||
#### `AUTH_METHOD`
|
||||
|
||||
**Authentication Method**
|
||||
|
||||
Select the authentication method for accessing Shelfmark.
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** `none`
|
||||
- **Options:** `none` (No Authentication), `builtin` (Local), `proxy` (Proxy Authentication), `oidc` (OIDC (OpenID Connect)), `cwa` (Calibre-Web Database)
|
||||
|
||||
#### `PROXY_AUTH_USER_HEADER`
|
||||
|
||||
**Proxy Auth User Header**
|
||||
|
||||
The HTTP header your proxy uses to pass the authenticated username.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `X-Auth-User`
|
||||
|
||||
#### `PROXY_AUTH_LOGOUT_URL`
|
||||
|
||||
**Proxy Auth Logout URL**
|
||||
|
||||
The URL to redirect users to for logging out. Leave empty to disable logout functionality.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _empty string_
|
||||
|
||||
#### `PROXY_AUTH_ADMIN_GROUP_HEADER`
|
||||
|
||||
**Proxy Auth Admin Group Header**
|
||||
|
||||
Optional: header your proxy uses to pass user groups/roles.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `X-Auth-Groups`
|
||||
|
||||
#### `PROXY_AUTH_ADMIN_GROUP_NAME`
|
||||
|
||||
**Proxy Auth Admin Group**
|
||||
|
||||
Optional: users in this group are treated as admins. Leave blank to skip group-based admin detection.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _empty string_
|
||||
|
||||
#### `OIDC_DISCOVERY_URL`
|
||||
|
||||
**Discovery URL**
|
||||
|
||||
OpenID Connect discovery endpoint URL. Usually ends with /.well-known/openid-configuration.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
- **Required:** Yes
|
||||
|
||||
#### `OIDC_CLIENT_ID`
|
||||
|
||||
**Client ID**
|
||||
|
||||
OAuth2 client ID from your identity provider.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
- **Required:** Yes
|
||||
|
||||
#### `OIDC_CLIENT_SECRET`
|
||||
|
||||
**Client Secret**
|
||||
|
||||
OAuth2 client secret from your identity provider.
|
||||
|
||||
- **Type:** string (secret)
|
||||
- **Default:** _none_
|
||||
- **Required:** Yes
|
||||
|
||||
#### `OIDC_SCOPES`
|
||||
|
||||
**Scopes**
|
||||
|
||||
OAuth2 scopes to request from the identity provider. Managed automatically: includes essential scopes and the group claim when using admin group authorization.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `openid,email,profile`
|
||||
|
||||
#### `OIDC_GROUP_CLAIM`
|
||||
|
||||
**Group Claim Name**
|
||||
|
||||
The name of the claim in the ID token that contains user groups.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `groups`
|
||||
|
||||
#### `OIDC_ADMIN_GROUP`
|
||||
|
||||
**Admin Group Name**
|
||||
|
||||
Users in this group will be given admin access (if enabled below). Leave empty to use database roles only.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _empty string_
|
||||
|
||||
#### `OIDC_USE_ADMIN_GROUP`
|
||||
|
||||
**Use Admin Group for Authorization**
|
||||
|
||||
When enabled, users in the Admin Group are granted admin access. When disabled, admin access is determined solely by database roles.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `true`
|
||||
|
||||
#### `OIDC_AUTO_PROVISION`
|
||||
|
||||
**Auto-Provision Users**
|
||||
|
||||
Automatically create a user account on first OIDC login. When disabled, users must be pre-created by an admin.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `true`
|
||||
|
||||
#### `OIDC_BUTTON_LABEL`
|
||||
|
||||
**Login Button Label**
|
||||
|
||||
Custom label for the OIDC sign-in button on the login page.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _empty string_
|
||||
|
||||
</details>
|
||||
|
||||
## Network
|
||||
|
||||
| Variable | Description | Type | Default |
|
||||
|----------|-------------|------|---------|
|
||||
| `CERTIFICATE_VALIDATION` | Controls SSL/TLS certificate verification for outbound connections. Disable for self-signed certificates on internal services (e.g. OIDC providers, Prowlarr). | string (choice) | `enabled` |
|
||||
| `CUSTOM_DNS` | DNS provider for domain resolution. 'Auto' rotates through providers on failure. | string (choice) | `auto` |
|
||||
| `CUSTOM_DNS_MANUAL` | Comma-separated list of DNS server IP addresses (e.g., 8.8.8.8, 1.1.1.1). | string | _none_ |
|
||||
| `USE_DOH` | Use encrypted DNS queries for improved reliability and privacy. | boolean | `true` |
|
||||
@@ -609,6 +765,16 @@ How long to keep completed/failed downloads in the queue display.
|
||||
<details>
|
||||
<summary>Detailed descriptions</summary>
|
||||
|
||||
#### `CERTIFICATE_VALIDATION`
|
||||
|
||||
**Certificate Validation**
|
||||
|
||||
Controls SSL/TLS certificate verification for outbound connections. Disable for self-signed certificates on internal services (e.g. OIDC providers, Prowlarr).
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** `enabled`
|
||||
- **Options:** `enabled` (Enabled (Recommended)), `disabled_local` (Disabled for Local Addresses), `disabled` (Disabled)
|
||||
|
||||
#### `CUSTOM_DNS`
|
||||
|
||||
**DNS Provider**
|
||||
@@ -1773,7 +1939,7 @@ Timeout for external bypasser requests in milliseconds.
|
||||
| Variable | Description | Type | Default |
|
||||
|----------|-------------|------|---------|
|
||||
| `AA_BASE_URL` | Select 'Auto' to try mirrors from your list on startup and fall back on failures. Choosing a specific mirror locks Shelfmark to that mirror (no fallback). | string (choice) | `auto` |
|
||||
| `AA_MIRROR_URLS` | Editable list of AA mirrors. Used to populate the Primary Mirror dropdown and the order used when Auto is selected. Type a URL and press Enter to add. Order matters for auto-rotation | string | `https://annas-archive.gl,https://annas-archive.li` |
|
||||
| `AA_MIRROR_URLS` | Editable list of AA mirrors. Used to populate the Primary Mirror dropdown and the order used when Auto is selected. Type a URL and press Enter to add. Order matters for auto-rotation | string | `https://annas-archive.gl,https://annas-archive.pk,https://annas-archive.vg,https://annas-archive.gd` |
|
||||
| `AA_ADDITIONAL_URLS` | Deprecated. Use Mirrors instead. This is kept for backwards compatibility with existing installs and environment variables. | string | _none_ |
|
||||
| `LIBGEN_ADDITIONAL_URLS` | Comma-separated list of custom LibGen mirrors to add to the defaults. | string | _none_ |
|
||||
| `ZLIB_PRIMARY_URL` | Z-Library mirror to use for downloads. | string (choice) | `https://z-lib.fm` |
|
||||
@@ -1792,7 +1958,7 @@ Select 'Auto' to try mirrors from your list on startup and fall back on failures
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** `auto`
|
||||
- **Options:** `auto` (Auto (Recommended)), `https://annas-archive.gl` (annas-archive.gl), `https://annas-archive.li` (annas-archive.li)
|
||||
- **Options:** `auto` (Auto (Recommended)), `https://annas-archive.gl` (annas-archive.gl), `https://annas-archive.pk` (annas-archive.pk), `https://annas-archive.vg` (annas-archive.vg), `https://annas-archive.gd` (annas-archive.gd)
|
||||
|
||||
#### `AA_MIRROR_URLS`
|
||||
|
||||
@@ -1801,7 +1967,7 @@ Select 'Auto' to try mirrors from your list on startup and fall back on failures
|
||||
Editable list of AA mirrors. Used to populate the Primary Mirror dropdown and the order used when Auto is selected. Type a URL and press Enter to add. Order matters for auto-rotation
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `https://annas-archive.gl,https://annas-archive.li`
|
||||
- **Default:** `https://annas-archive.gl,https://annas-archive.pk,https://annas-archive.vg,https://annas-archive.gd`
|
||||
|
||||
#### `AA_ADDITIONAL_URLS`
|
||||
|
||||
|
||||
@@ -222,6 +222,7 @@ def generate_env_docs() -> str:
|
||||
"""Generate markdown documentation for all environment variables."""
|
||||
# Import settings modules to ensure all settings are registered
|
||||
import shelfmark.config.settings # noqa: F401
|
||||
import shelfmark.config.security # noqa: F401
|
||||
import shelfmark.release_sources.irc.settings # noqa: F401
|
||||
import shelfmark.release_sources.prowlarr.settings # noqa: F401
|
||||
import shelfmark.metadata_providers.hardcover # noqa: F401
|
||||
@@ -310,7 +311,7 @@ def generate_env_docs() -> str:
|
||||
|
||||
def _generate_tab_docs(tab, group_prefix: Optional[str] = None) -> List[str]:
|
||||
"""Generate documentation for a single settings tab."""
|
||||
from shelfmark.core.settings_registry import ActionButton, HeadingField
|
||||
from shelfmark.core.settings_registry import ActionButton, CustomComponentField, HeadingField
|
||||
|
||||
lines = []
|
||||
|
||||
@@ -327,7 +328,7 @@ def _generate_tab_docs(tab, group_prefix: Optional[str] = None) -> List[str]:
|
||||
env_fields = []
|
||||
for field in tab.fields:
|
||||
# Skip non-value fields
|
||||
if isinstance(field, (ActionButton, HeadingField)):
|
||||
if isinstance(field, (ActionButton, CustomComponentField, HeadingField)):
|
||||
continue
|
||||
|
||||
# Skip fields that don't support ENV vars
|
||||
|
||||
@@ -29,12 +29,8 @@ def _auth_condition(auth_method: str) -> dict[str, str]:
|
||||
return {"field": "AUTH_METHOD", "value": auth_method}
|
||||
|
||||
|
||||
def _ui_field(factory: Callable[..., Any], **kwargs: Any) -> Any:
|
||||
return factory(env_supported=False, **kwargs)
|
||||
|
||||
|
||||
def _auth_ui_field(factory: Callable[..., Any], auth_method: str, **kwargs: Any) -> Any:
|
||||
return _ui_field(factory, show_when=_auth_condition(auth_method), **kwargs)
|
||||
def _auth_field(factory: Callable[..., Any], auth_method: str, **kwargs: Any) -> Any:
|
||||
return factory(show_when=_auth_condition(auth_method), **kwargs)
|
||||
|
||||
|
||||
def _migrate_security_settings() -> None:
|
||||
@@ -80,22 +76,16 @@ def security_settings():
|
||||
{"label": "Local", "value": "builtin"},
|
||||
{"label": "Proxy Authentication", "value": "proxy"},
|
||||
{"label": "OIDC (OpenID Connect)", "value": "oidc"},
|
||||
{"label": "Calibre-Web Database", "value": "cwa"},
|
||||
]
|
||||
if cwa_db_available:
|
||||
auth_method_options.append({"label": "Calibre-Web Database", "value": "cwa"})
|
||||
|
||||
auth_method_description = "Select the authentication method for accessing Shelfmark."
|
||||
if not cwa_db_available:
|
||||
auth_method_description += " Calibre-Web database option requires mounting your Calibre-Web app.db to /auth/app.db."
|
||||
|
||||
fields = [
|
||||
SelectField(
|
||||
key="AUTH_METHOD",
|
||||
label="Authentication Method",
|
||||
description=auth_method_description,
|
||||
description="Select the authentication method for accessing Shelfmark.",
|
||||
options=auth_method_options,
|
||||
default="none",
|
||||
env_supported=False,
|
||||
),
|
||||
CustomComponentField(
|
||||
key="builtin_admin_requirement",
|
||||
@@ -112,6 +102,18 @@ def security_settings():
|
||||
label="A local admin account is required before OIDC can be enabled.",
|
||||
show_when=_auth_condition("oidc"),
|
||||
),
|
||||
*([] if cwa_db_available else [
|
||||
CustomComponentField(
|
||||
key="cwa_db_missing",
|
||||
component="oidc_admin_hint",
|
||||
label=(
|
||||
"Calibre-Web database not detected. Mount your app.db to "
|
||||
"/auth/app.db to enable this method. Authentication will fall "
|
||||
"back to none until the database is available."
|
||||
),
|
||||
show_when=_auth_condition("cwa"),
|
||||
),
|
||||
]),
|
||||
ActionButton(
|
||||
key="open_users_tab",
|
||||
label="Go to Users",
|
||||
@@ -119,7 +121,7 @@ def security_settings():
|
||||
style="primary",
|
||||
show_when={"field": "AUTH_METHOD", "value": ["builtin", "oidc"]},
|
||||
),
|
||||
_auth_ui_field(
|
||||
_auth_field(
|
||||
TextField,
|
||||
"proxy",
|
||||
key="PROXY_AUTH_USER_HEADER",
|
||||
@@ -128,7 +130,7 @@ def security_settings():
|
||||
placeholder="e.g. X-Auth-User",
|
||||
default="X-Auth-User",
|
||||
),
|
||||
_auth_ui_field(
|
||||
_auth_field(
|
||||
TextField,
|
||||
"proxy",
|
||||
key="PROXY_AUTH_LOGOUT_URL",
|
||||
@@ -137,7 +139,7 @@ def security_settings():
|
||||
placeholder="https://myauth.example.com/logout",
|
||||
default="",
|
||||
),
|
||||
_auth_ui_field(
|
||||
_auth_field(
|
||||
TextField,
|
||||
"proxy",
|
||||
key="PROXY_AUTH_ADMIN_GROUP_HEADER",
|
||||
@@ -146,7 +148,7 @@ def security_settings():
|
||||
placeholder="e.g. X-Auth-Groups",
|
||||
default="X-Auth-Groups",
|
||||
),
|
||||
_auth_ui_field(
|
||||
_auth_field(
|
||||
TextField,
|
||||
"proxy",
|
||||
key="PROXY_AUTH_ADMIN_GROUP_NAME",
|
||||
@@ -255,7 +257,7 @@ def security_settings():
|
||||
},
|
||||
),
|
||||
]
|
||||
fields.extend(_auth_ui_field(factory, "oidc", **spec) for factory, spec in oidc_specs)
|
||||
fields.extend(_auth_field(factory, "oidc", **spec) for factory, spec in oidc_specs)
|
||||
fields.append(
|
||||
ActionButton(
|
||||
key="test_oidc",
|
||||
|
||||
@@ -189,6 +189,16 @@ def _is_probably_series_position(subtitle: str) -> bool:
|
||||
if normalized in {"a novel", "a novella", "a story", "a memoir"}:
|
||||
return True
|
||||
|
||||
# Descriptive subtitles like "A [Name] Novel", "An [Name] Mystery", etc.
|
||||
genre_words = (
|
||||
"novel", "novella", "story", "memoir", "tale", "thriller", "mystery",
|
||||
"romance", "adventure", "epic", "saga", "chronicle", "fantasy",
|
||||
"novel-in-stories",
|
||||
)
|
||||
genre_pattern = "|".join(re.escape(w) for w in genre_words)
|
||||
if re.match(rf"^an?\s+.+\s+({genre_pattern})$", normalized):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@@ -201,10 +211,12 @@ def _simplify_author_for_search(author: str) -> Optional[str]:
|
||||
"""Return a looser author string for indexer searches.
|
||||
|
||||
Primary goal: reduce mismatch between metadata providers and indexers.
|
||||
Indexers store author names inconsistently ("R.A.", "R. A.", "Salvatore, R.A.")
|
||||
so initials add noise and hurt recall.
|
||||
|
||||
Heuristics (intentionally conservative):
|
||||
- Remove middle initials (e.g. "Robert R. McCammon" -> "Robert McCammon")
|
||||
- Remove standalone middle names that are just an initial or initial+dot
|
||||
Heuristics:
|
||||
- Strip all initials (single or compound), keeping only full names
|
||||
e.g. "R. A. Salvatore" -> "Salvatore", "George R.R. Martin" -> "George Martin"
|
||||
- Preserve suffixes like "Jr."/"Sr."/"III" as they sometimes matter
|
||||
"""
|
||||
if not author:
|
||||
@@ -238,15 +250,13 @@ def _simplify_author_for_search(author: str) -> Optional[str]:
|
||||
simplified.append(t)
|
||||
continue
|
||||
|
||||
# Drop middle initials like "R." or "R"
|
||||
is_initial = re.match(r"^[A-Za-z]\.?$", t) is not None
|
||||
is_middle_token = 0 < idx < (len(tokens) - 1)
|
||||
if is_middle_token and is_initial:
|
||||
# Drop all initials: "R.", "R", "R.R.", "J.K.", etc.
|
||||
if re.match(r"^[A-Za-z]$|^([A-Za-z]\.)+[A-Za-z]?$", t):
|
||||
continue
|
||||
|
||||
simplified.append(t)
|
||||
|
||||
if len(simplified) < 2:
|
||||
if not simplified:
|
||||
return None
|
||||
|
||||
candidate = " ".join(simplified).strip()
|
||||
@@ -288,6 +298,14 @@ def _compute_search_title(
|
||||
if normalized_subtitle and normalized_subtitle.lower() == normalized_title.lower():
|
||||
normalized_subtitle = ""
|
||||
|
||||
# If subtitle is noise, strip it from the title and use just the prefix.
|
||||
if normalized_subtitle and _is_probably_series_position(normalized_subtitle):
|
||||
match = re.match(r"^(.+?)\s*:\s*(.+)$", normalized_title)
|
||||
if match:
|
||||
suffix = _strip_parenthetical_suffix(match.group(2).strip())
|
||||
if normalized_subtitle.lower() == suffix.lower() or normalized_subtitle.lower() in suffix.lower():
|
||||
return match.group(1).strip()
|
||||
|
||||
# Prefer subtitle when it looks like the real title.
|
||||
if normalized_subtitle and not _is_probably_series_position(normalized_subtitle):
|
||||
match = re.match(r"^(.+?)\s*:\s*(.+)$", normalized_title)
|
||||
|
||||
@@ -227,6 +227,27 @@ function App() {
|
||||
|
||||
const requestRoleIsAdmin = requestPolicy ? Boolean(requestPolicy.is_admin) : false;
|
||||
|
||||
// Compute which content types this user is allowed to search for.
|
||||
// If a content type's default policy mode is 'blocked', hide it from the dropdown.
|
||||
const allowedContentTypes = useMemo((): ContentType[] => {
|
||||
// If policy not loaded yet or user is admin, allow everything
|
||||
if (!requestPolicy || requestRoleIsAdmin || !requestsPolicyEnabled) {
|
||||
return ['ebook', 'audiobook'];
|
||||
}
|
||||
const types: ContentType[] = [];
|
||||
if (getDefaultMode('ebook') !== 'blocked') types.push('ebook');
|
||||
if (getDefaultMode('audiobook') !== 'blocked') types.push('audiobook');
|
||||
// If both are blocked, still show both (user can see results, just can't download)
|
||||
return types.length > 0 ? types : ['ebook', 'audiobook'];
|
||||
}, [requestPolicy, requestRoleIsAdmin, requestsPolicyEnabled, getDefaultMode]);
|
||||
|
||||
// Auto-switch content type if the current selection is blocked
|
||||
useEffect(() => {
|
||||
if (allowedContentTypes.length > 0 && !allowedContentTypes.includes(contentType)) {
|
||||
setContentType(allowedContentTypes[0]);
|
||||
}
|
||||
}, [allowedContentTypes, contentType]);
|
||||
|
||||
const {
|
||||
isLoading: isRequestsLoading,
|
||||
cancelRequest: cancelUserRequest,
|
||||
@@ -356,6 +377,7 @@ function App() {
|
||||
handleSortChange,
|
||||
searchFieldValues,
|
||||
updateSearchFieldValue,
|
||||
searchFieldLabels,
|
||||
// Pagination (universal mode)
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
@@ -1385,6 +1407,13 @@ function App() {
|
||||
});
|
||||
}, [config?.metadata_search_fields, searchFieldValues]);
|
||||
|
||||
const activeListLabel = useMemo(() => {
|
||||
if (!isListBrowsing) return '';
|
||||
const field = (config?.metadata_search_fields ?? [])
|
||||
.find((f) => f.type === 'DynamicSelectSearchField' && searchFieldValues[f.key]);
|
||||
return field ? (searchFieldLabels[field.key] || '') : '';
|
||||
}, [isListBrowsing, config?.metadata_search_fields, searchFieldValues, searchFieldLabels]);
|
||||
|
||||
// Reset manual search if policy changes to disallow it
|
||||
useEffect(() => {
|
||||
if (!manualSearchAllowed && isManualSearch) {
|
||||
@@ -1392,6 +1421,21 @@ function App() {
|
||||
}
|
||||
}, [manualSearchAllowed, isManualSearch]);
|
||||
|
||||
const handleManualSearchToggle = useCallback(() => {
|
||||
setIsManualSearch(prev => {
|
||||
if (!prev) {
|
||||
// Turning on: clear any dynamic select field values (e.g. list selection)
|
||||
const dynamicKeys = (config?.metadata_search_fields ?? [])
|
||||
.filter((f) => f.type === 'DynamicSelectSearchField')
|
||||
.map((f) => f.key);
|
||||
for (const key of dynamicKeys) {
|
||||
updateSearchFieldValue(key, '');
|
||||
}
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
}, [config?.metadata_search_fields, updateSearchFieldValue]);
|
||||
|
||||
// Unified search dispatch: intercepts manual search mode, otherwise runs normal search
|
||||
const handleSearchDispatch = useCallback(() => {
|
||||
if (isManualSearch) {
|
||||
@@ -1474,8 +1518,10 @@ function App() {
|
||||
onRemoveToast={removeToast}
|
||||
contentType={contentType}
|
||||
onContentTypeChange={setContentType}
|
||||
allowedContentTypes={allowedContentTypes}
|
||||
isManualSearch={isManualSearch}
|
||||
searchDisabled={isListBrowsing}
|
||||
activeListLabel={activeListLabel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1510,7 +1556,7 @@ function App() {
|
||||
onSearchFieldChange={updateSearchFieldValue}
|
||||
onSubmit={handleSearchDispatch}
|
||||
isManualSearch={isManualSearch}
|
||||
onManualSearchToggle={manualSearchAllowed ? () => setIsManualSearch(prev => !prev) : undefined}
|
||||
onManualSearchToggle={manualSearchAllowed ? handleManualSearchToggle : undefined}
|
||||
/>
|
||||
|
||||
<main
|
||||
@@ -1540,9 +1586,11 @@ function App() {
|
||||
onSearchFieldChange={updateSearchFieldValue}
|
||||
contentType={contentType}
|
||||
onContentTypeChange={setContentType}
|
||||
allowedContentTypes={allowedContentTypes}
|
||||
isManualSearch={isManualSearch}
|
||||
onManualSearchToggle={manualSearchAllowed ? () => setIsManualSearch(prev => !prev) : undefined}
|
||||
onManualSearchToggle={manualSearchAllowed ? handleManualSearchToggle : undefined}
|
||||
searchDisabled={isListBrowsing}
|
||||
activeListLabel={activeListLabel}
|
||||
/>
|
||||
|
||||
<ResultsSection
|
||||
|
||||
@@ -21,7 +21,7 @@ interface AdvancedFiltersProps {
|
||||
// Universal mode props
|
||||
metadataSearchFields?: MetadataSearchField[];
|
||||
searchFieldValues?: Record<string, string | number | boolean>;
|
||||
onSearchFieldChange?: (key: string, value: string | number | boolean) => void;
|
||||
onSearchFieldChange?: (key: string, value: string | number | boolean, label?: string) => void;
|
||||
// Submit handler for Enter key
|
||||
onSubmit?: () => void;
|
||||
// Manual search mode (universal only)
|
||||
@@ -85,50 +85,72 @@ export const AdvancedFilters = ({
|
||||
// If no fields and no toggle available, don't show the section
|
||||
if (!hasProviderFields && !onManualSearchToggle) return null;
|
||||
|
||||
const manualToggle = onManualSearchToggle ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<label className="text-sm font-medium">Manual search</label>
|
||||
</div>
|
||||
<div>
|
||||
<ToggleSwitch
|
||||
checked={isManualSearch}
|
||||
onChange={() => onManualSearchToggle()}
|
||||
color="emerald"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs"><span className="opacity-60">Search release sources directly</span></p>
|
||||
</div>
|
||||
) : null;
|
||||
// When formClassName is provided (initial state), the form carries its own padding;
|
||||
// otherwise use the default positioning classes for the header-bar state.
|
||||
const wrapperClassName = formClassName
|
||||
? 'px-2'
|
||||
: 'px-2 lg:ml-[calc(3rem+1rem)] lg:w-[calc(50vw+4rem)]';
|
||||
|
||||
const universalForm = (
|
||||
<form
|
||||
id="search-filters"
|
||||
className={
|
||||
formClassName ??
|
||||
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 lg:ml-[calc(3rem+1rem)] lg:w-[50vw]'
|
||||
}
|
||||
>
|
||||
{manualToggle && <div className="col-span-full">{manualToggle}</div>}
|
||||
{!isManualSearch && metadataSearchFields.map((field) => (
|
||||
<div key={field.key}>
|
||||
{field.type !== 'CheckboxSearchField' && (
|
||||
<label htmlFor={`${field.key}-input`} className="block text-sm mb-1 opacity-80">
|
||||
{field.label}
|
||||
</label>
|
||||
)}
|
||||
<SearchFieldRenderer
|
||||
field={field}
|
||||
value={searchFieldValues[field.key] ?? (field.type === 'CheckboxSearchField' ? false : '')}
|
||||
onChange={(value) => onSearchFieldChange?.(field.key, value)}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
{field.description && (
|
||||
<p className="text-xs mt-1 opacity-60">{field.description}</p>
|
||||
)}
|
||||
<div className={wrapperClassName}>
|
||||
{onManualSearchToggle && (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium opacity-70">Search Options</span>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<span className="text-xs opacity-60">Manual search</span>
|
||||
<ToggleSwitch
|
||||
checked={isManualSearch}
|
||||
onChange={() => onManualSearchToggle()}
|
||||
color="emerald"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</form>
|
||||
)}
|
||||
{isManualSearch && (
|
||||
<p className="text-xs opacity-50 mb-3">
|
||||
Manual search queries release sources directly. Some sources may return limited metadata, which can affect file naming templates.
|
||||
</p>
|
||||
)}
|
||||
{!isManualSearch && metadataSearchFields.length > 0 && (
|
||||
<form
|
||||
id="search-filters"
|
||||
className={
|
||||
formClassName ??
|
||||
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'
|
||||
}
|
||||
>
|
||||
{metadataSearchFields.map((field) => (
|
||||
<div key={field.key}>
|
||||
{field.type !== 'CheckboxSearchField' && (
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label htmlFor={`${field.key}-input`} className="text-sm opacity-80">
|
||||
{field.label}
|
||||
</label>
|
||||
{field.type === 'DynamicSelectSearchField' && searchFieldValues[field.key] && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchFieldChange?.(field.key, '')}
|
||||
className="text-xs font-medium text-sky-500 hover:text-sky-400 transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<SearchFieldRenderer
|
||||
field={field}
|
||||
value={searchFieldValues[field.key] ?? (field.type === 'CheckboxSearchField' ? false : '')}
|
||||
onChange={(value, label) => onSearchFieldChange?.(field.key, value, label)}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
{field.description && (
|
||||
<p className="text-xs mt-1 opacity-60">{field.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const wrappedUniversalForm = renderWrapper ? (
|
||||
@@ -148,7 +170,7 @@ export const AdvancedFilters = ({
|
||||
id="search-filters"
|
||||
className={
|
||||
formClassName ??
|
||||
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 lg:ml-[calc(3rem+1rem)] lg:w-[50vw]'
|
||||
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 lg:ml-[calc(3rem+1rem)] lg:w-[calc(50vw+4rem)]'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
|
||||
@@ -39,8 +39,10 @@ interface HeaderProps {
|
||||
onRemoveToast?: (id: string) => void;
|
||||
contentType?: ContentType;
|
||||
onContentTypeChange?: (type: ContentType) => void;
|
||||
allowedContentTypes?: ContentType[];
|
||||
isManualSearch?: boolean;
|
||||
searchDisabled?: boolean;
|
||||
activeListLabel?: string;
|
||||
}
|
||||
|
||||
export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
@@ -71,8 +73,10 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
onRemoveToast,
|
||||
contentType = 'ebook',
|
||||
onContentTypeChange,
|
||||
allowedContentTypes,
|
||||
isManualSearch = false,
|
||||
searchDisabled = false,
|
||||
activeListLabel,
|
||||
}, ref) => {
|
||||
const activityBadge = getActivityBadgeState(statusCounts, isAdmin);
|
||||
const settingsEnabled = canAccessSettings ?? isAdmin;
|
||||
@@ -656,8 +660,10 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
isLoading={isLoading}
|
||||
contentType={contentType}
|
||||
onContentTypeChange={onContentTypeChange}
|
||||
allowedContentTypes={allowedContentTypes}
|
||||
isManualSearch={isManualSearch}
|
||||
disabled={searchDisabled}
|
||||
activeListLabel={activeListLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,9 +24,11 @@ interface SearchBarProps {
|
||||
// Content type selector props
|
||||
contentType?: ContentType;
|
||||
onContentTypeChange?: (type: ContentType) => void;
|
||||
allowedContentTypes?: ContentType[];
|
||||
// Manual search mode
|
||||
isManualSearch?: boolean;
|
||||
disabled?: boolean;
|
||||
activeListLabel?: string;
|
||||
}
|
||||
|
||||
export interface SearchBarHandle {
|
||||
@@ -54,8 +56,10 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
enterKeyHint = 'search',
|
||||
contentType = 'ebook',
|
||||
onContentTypeChange,
|
||||
allowedContentTypes,
|
||||
isManualSearch = false,
|
||||
disabled = false,
|
||||
activeListLabel,
|
||||
}, ref) => {
|
||||
const { searchMode, isUniversalMode } = useSearchMode();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -65,14 +69,18 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
|
||||
// Content type dropdown state
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const showContentTypeSelector = isUniversalMode && !!onContentTypeChange;
|
||||
const hasMultipleContentTypes = !allowedContentTypes || allowedContentTypes.length !== 1;
|
||||
const showContentTypeSelector = isUniversalMode && !!onContentTypeChange && hasMultipleContentTypes;
|
||||
|
||||
// Dynamic placeholder based on content type and manual search
|
||||
const effectivePlaceholder = isManualSearch
|
||||
? 'Search releases directly...'
|
||||
: showContentTypeSelector
|
||||
? (contentType === 'ebook' ? 'Search Books' : 'Search Audiobooks')
|
||||
: placeholder;
|
||||
// Dynamic placeholder based on content type, manual search, and list browsing
|
||||
const isContentTypeAware = isUniversalMode && !!onContentTypeChange;
|
||||
const effectivePlaceholder = activeListLabel
|
||||
? `${activeListLabel} selected`
|
||||
: isManualSearch
|
||||
? 'Search releases directly...'
|
||||
: isContentTypeAware
|
||||
? (contentType === 'ebook' ? 'Search Books' : 'Search Audiobooks')
|
||||
: placeholder;
|
||||
|
||||
// Close dropdown on click outside or escape
|
||||
useEffect(() => {
|
||||
@@ -162,7 +170,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
<div
|
||||
className="flex items-stretch rounded-full border"
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
background: disabled ? 'var(--bg)' : 'var(--bg-soft)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -21,13 +21,15 @@ interface SearchSectionProps {
|
||||
// Universal mode props
|
||||
metadataSearchFields?: MetadataSearchField[];
|
||||
searchFieldValues?: Record<string, string | number | boolean>;
|
||||
onSearchFieldChange?: (key: string, value: string | number | boolean) => void;
|
||||
onSearchFieldChange?: (key: string, value: string | number | boolean, label?: string) => void;
|
||||
contentType?: ContentType;
|
||||
onContentTypeChange?: (type: ContentType) => void;
|
||||
allowedContentTypes?: ContentType[];
|
||||
// Manual search mode (universal only)
|
||||
isManualSearch?: boolean;
|
||||
onManualSearchToggle?: () => void;
|
||||
searchDisabled?: boolean;
|
||||
activeListLabel?: string;
|
||||
}
|
||||
|
||||
export const SearchSection = ({
|
||||
@@ -49,9 +51,11 @@ export const SearchSection = ({
|
||||
onSearchFieldChange,
|
||||
contentType = 'ebook',
|
||||
onContentTypeChange,
|
||||
allowedContentTypes,
|
||||
isManualSearch = false,
|
||||
onManualSearchToggle,
|
||||
searchDisabled = false,
|
||||
activeListLabel,
|
||||
}: SearchSectionProps) => {
|
||||
const { searchMode } = useSearchMode();
|
||||
|
||||
@@ -93,8 +97,10 @@ export const SearchSection = ({
|
||||
onAdvancedToggle={onAdvancedToggle}
|
||||
contentType={contentType}
|
||||
onContentTypeChange={onContentTypeChange}
|
||||
allowedContentTypes={allowedContentTypes}
|
||||
isManualSearch={isManualSearch}
|
||||
disabled={searchDisabled}
|
||||
activeListLabel={activeListLabel}
|
||||
/>
|
||||
<AdvancedFilters
|
||||
visible={showAdvanced}
|
||||
@@ -103,7 +109,7 @@ export const SearchSection = ({
|
||||
supportedFormats={supportedFormats}
|
||||
filters={advancedFilters}
|
||||
onFiltersChange={onAdvancedFiltersChange}
|
||||
formClassName="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2"
|
||||
formClassName="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||
renderWrapper={form => form}
|
||||
metadataSearchFields={metadataSearchFields}
|
||||
searchFieldValues={searchFieldValues}
|
||||
|
||||
@@ -2,26 +2,30 @@ import { useEffect, useState } from 'react';
|
||||
import { getAdminUsers } from '../../../services/api';
|
||||
import { CustomSettingsFieldRendererProps } from './types';
|
||||
|
||||
const ADMIN_CHECK_KEYS = new Set(['builtin_admin_requirement', 'oidc_admin_requirement']);
|
||||
|
||||
export const OidcAdminHint = ({ field }: CustomSettingsFieldRendererProps) => {
|
||||
const [needsAdmin, setNeedsAdmin] = useState<boolean | null>(null);
|
||||
const needsAdminCheck = ADMIN_CHECK_KEYS.has(field.key);
|
||||
const [visible, setVisible] = useState(!needsAdminCheck);
|
||||
|
||||
useEffect(() => {
|
||||
if (!needsAdminCheck) return;
|
||||
let cancelled = false;
|
||||
getAdminUsers()
|
||||
.then((users) => {
|
||||
if (!cancelled) {
|
||||
setNeedsAdmin(!users.some(u => u.role === 'admin' && u.auth_source === 'builtin'));
|
||||
setVisible(!users.some(u => u.role === 'admin' && u.auth_source === 'builtin'));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setNeedsAdmin(true);
|
||||
setVisible(true);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
}, [needsAdminCheck]);
|
||||
|
||||
if (!needsAdmin) return null;
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="text-sm px-3 py-2 rounded-lg bg-amber-500/15 text-amber-700 dark:text-amber-300">
|
||||
|
||||
@@ -5,48 +5,25 @@ import { DynamicFieldOption, fetchFieldOptions } from '../../services/api';
|
||||
interface DynamicDropdownProps {
|
||||
endpoint: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onChange: (value: string, label?: string) => void;
|
||||
placeholder?: string;
|
||||
allLabel?: string;
|
||||
}
|
||||
|
||||
const GROUP_HEADER_PREFIX = '__group__';
|
||||
|
||||
const buildOptions = (
|
||||
options: DynamicFieldOption[],
|
||||
allLabel: string
|
||||
): DropdownListOption[] => {
|
||||
const built: DropdownListOption[] = [{ value: '', label: allLabel }];
|
||||
let previousGroup: string | null = null;
|
||||
|
||||
options.forEach((option, index) => {
|
||||
if (option.group && option.group !== previousGroup) {
|
||||
previousGroup = option.group;
|
||||
built.push({
|
||||
value: `${GROUP_HEADER_PREFIX}${option.group}:${index}`,
|
||||
label: option.group,
|
||||
disabled: true,
|
||||
});
|
||||
} else if (!option.group) {
|
||||
previousGroup = null;
|
||||
}
|
||||
|
||||
built.push({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
});
|
||||
});
|
||||
|
||||
return built;
|
||||
return options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
}));
|
||||
};
|
||||
|
||||
export const DynamicDropdown = ({
|
||||
endpoint,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Select an option',
|
||||
allLabel = 'All',
|
||||
placeholder = 'Select...',
|
||||
}: DynamicDropdownProps) => {
|
||||
const [options, setOptions] = useState<DynamicFieldOption[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -87,28 +64,20 @@ export const DynamicDropdown = ({
|
||||
|
||||
const dropdownOptions = useMemo(() => {
|
||||
if (isLoading) {
|
||||
return [
|
||||
{ value: '', label: allLabel },
|
||||
{ value: '__loading', label: 'Loading...', disabled: true },
|
||||
];
|
||||
return [{ value: '__loading', label: 'Loading...', disabled: true }];
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return [
|
||||
{ value: '', label: allLabel },
|
||||
{ value: '__error', label: loadError, disabled: true },
|
||||
];
|
||||
return [{ value: '__error', label: loadError, disabled: true }];
|
||||
}
|
||||
|
||||
return buildOptions(options, allLabel);
|
||||
}, [allLabel, isLoading, loadError, options]);
|
||||
return buildOptions(options);
|
||||
}, [isLoading, loadError, options]);
|
||||
|
||||
const handleChange = (nextValue: string[] | string) => {
|
||||
const normalized = Array.isArray(nextValue) ? nextValue[0] ?? '' : nextValue;
|
||||
if (normalized.startsWith(GROUP_HEADER_PREFIX)) {
|
||||
return;
|
||||
}
|
||||
onChange(normalized);
|
||||
const match = options.find((opt) => opt.value === normalized);
|
||||
onChange(normalized, match?.label);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DynamicDropdown } from './DynamicDropdown';
|
||||
interface SearchFieldRendererProps {
|
||||
field: MetadataSearchField;
|
||||
value: string | number | boolean;
|
||||
onChange: (value: string | number | boolean) => void;
|
||||
onChange: (value: string | number | boolean, label?: string) => void;
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
||||
@@ -104,9 +104,8 @@ export const SearchFieldRenderer = ({ field, value, onChange, onSubmit }: Search
|
||||
<DynamicDropdown
|
||||
endpoint={field.options_endpoint}
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(v) => onChange(v)}
|
||||
placeholder={field.placeholder || 'Select an option'}
|
||||
allLabel="All"
|
||||
onChange={(v, label) => onChange(v, label)}
|
||||
placeholder={field.placeholder || 'Select...'}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Book, AppConfig, AdvancedFilterState, ContentType } from '../types';
|
||||
import { searchBooks, searchMetadata, AuthenticationError } from '../services/api';
|
||||
@@ -41,7 +41,8 @@ interface UseSearchReturn {
|
||||
resetSortFilter: () => void;
|
||||
// Universal mode search field values
|
||||
searchFieldValues: SearchFieldValues;
|
||||
updateSearchFieldValue: (key: string, value: string | number | boolean) => void;
|
||||
updateSearchFieldValue: (key: string, value: string | number | boolean, label?: string) => void;
|
||||
searchFieldLabels: Record<string, string>;
|
||||
// Pagination (universal mode only)
|
||||
hasMore: boolean;
|
||||
isLoadingMore: boolean;
|
||||
@@ -70,6 +71,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
|
||||
// Universal mode: provider-specific search field values
|
||||
const [searchFieldValues, setSearchFieldValues] = useState<SearchFieldValues>({});
|
||||
const [searchFieldLabels, setSearchFieldLabels] = useState<Record<string, string>>({});
|
||||
|
||||
// Pagination state (universal mode only)
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -83,31 +85,18 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
sort: string;
|
||||
fieldValues: SearchFieldValues;
|
||||
} | null>(null);
|
||||
const previousContentTypeRef = useRef<ContentType>(contentType);
|
||||
|
||||
// Switching media type starts a new search session.
|
||||
useEffect(() => {
|
||||
if (previousContentTypeRef.current === contentType) {
|
||||
return;
|
||||
}
|
||||
|
||||
previousContentTypeRef.current = contentType;
|
||||
setBooks([]);
|
||||
setLastSearchQuery('');
|
||||
setSearchFieldValues({});
|
||||
setHasMore(false);
|
||||
setTotalFound(0);
|
||||
setCurrentPage(1);
|
||||
lastSearchParamsRef.current = null;
|
||||
onSearchReset?.();
|
||||
}, [contentType, onSearchReset]);
|
||||
|
||||
const updateAdvancedFilters = useCallback((updates: Partial<AdvancedFilterState>) => {
|
||||
setAdvancedFilters(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
const updateSearchFieldValue = useCallback((key: string, value: string | number | boolean) => {
|
||||
const updateSearchFieldValue = useCallback((key: string, value: string | number | boolean, label?: string) => {
|
||||
setSearchFieldValues(prev => ({ ...prev, [key]: value }));
|
||||
if (label !== undefined) {
|
||||
setSearchFieldLabels(prev => ({ ...prev, [key]: label }));
|
||||
} else if (!value) {
|
||||
setSearchFieldLabels(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetSortFilter = useCallback(() => {
|
||||
@@ -319,6 +308,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
// Universal mode search field values
|
||||
searchFieldValues,
|
||||
updateSearchFieldValue,
|
||||
searchFieldLabels,
|
||||
// Pagination (universal mode only)
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
|
||||
Reference in New Issue
Block a user