Frontend update + Misc fixes (#735)

- Updated frontend CSS to Tailwind v4
- Reverted socket IO origin restriction
- Fixed search queries not persisting after auth redirect
- Move advanced search options to left UI selector
- Unlock IRC source to be used for audiobook content_type
- Tweaked security settings env var syncing to be prioritised
- Fix AA "all languages" query generation
- Added language-free AA query as second fallback in case of no results
- Testing moving SeleniumBase scratch files to /tmp via symlink
- Added enhanced logging for activity dismissals and other events
- Removed iFrame restrictions
This commit is contained in:
Alex
2026-03-11 18:16:34 +00:00
committed by GitHub
parent a2a5a22324
commit c59ea46540
99 changed files with 3222 additions and 2167 deletions

View File

@@ -1,6 +1,7 @@
.git
.github
.vscode
.local
.mypy_cache
README_images
.gitignore

View File

@@ -1,4 +1,4 @@
.PHONY: help install dev build preview typecheck frontend-test clean up down docker-build refresh restart
.PHONY: help install dev build preview typecheck frontend-test clean up down docker-build refresh restart build-serve
# Frontend directory
FRONTEND_DIR := src/frontend
@@ -14,6 +14,7 @@ help:
@echo " install - Install frontend dependencies"
@echo " dev - Start development server"
@echo " build - Build frontend for production"
@echo " build-serve - Build and serve via Flask (test prod build without Docker)"
@echo " preview - Preview production build"
@echo " typecheck - Run TypeScript type checking"
@echo " frontend-test - Run frontend unit tests"
@@ -41,6 +42,13 @@ build:
@echo "Building frontend for production..."
cd $(FRONTEND_DIR) && npm run build
# Build frontend and sync to frontend-dist for the running container to serve
build-serve: build
@echo "Syncing build to frontend-dist..."
@mkdir -p frontend-dist
rsync -a --delete $(FRONTEND_DIR)/dist/ frontend-dist/
@echo "Done. Hit the Flask backend (port 8084) to test the production build."
# Preview production build
preview:
@echo "Previewing production build..."

View File

@@ -20,5 +20,6 @@ services:
- ./.local/log:/var/log/shelfmark
- ./.local/tmp:/tmp/shelfmark
- ./shelfmark:/app/shelfmark:ro
- ./frontend-dist:/app/frontend-dist:ro
# Required for torrent / usenet - path must match your download client's volume exactly
# - /path/to/downloads:/path/to/downloads

View File

@@ -6,6 +6,15 @@ Shelfmark can run behind a reverse proxy at the root path (recommended) or under
If you can serve Shelfmark at the root path (`https://shelfmark.example.com/`), leave `URL_BASE` empty. This is the simplest option and avoids extra subpath configuration.
Define this once in your Nginx `http` block so websocket upgrades are only sent when the client actually requests them:
```nginx
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
```
```nginx
server {
listen 443 ssl;
@@ -19,7 +28,7 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Connection $connection_upgrade;
}
}
```
@@ -53,7 +62,7 @@ location /shelfmark/ {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_buffering off;
@@ -133,7 +142,7 @@ location /shelfmark/ {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_buffering off;
@@ -142,6 +151,18 @@ location /shelfmark/ {
---
## Troubleshooting false network errors
If login, settings saves, or downloads appear to fail in the browser but the action still completes on the server, check your proxy headers first.
- Do not force `Connection: upgrade` on every request. That can break normal `POST` and `PUT` responses while the backend still processes them.
- If your proxy UI does not support conditional websocket headers, remove the forced websocket headers entirely and let Shelfmark fall back to polling.
- Keep the standard forwarded headers: `Host`, `X-Forwarded-For`, `X-Forwarded-Proto`, and `X-Forwarded-Host` when using a subpath or OIDC.
This is especially relevant for Nginx Proxy Manager or custom advanced config snippets that add websocket headers globally.
---
## Health checks
Health checks work at `/shelfmark/api/health` when using a subpath configuration.

View File

@@ -202,14 +202,62 @@ change_ownership() {
chown -R "${RUN_UID}:${RUN_GID}" "${folder}" || echo "Failed to change ownership for ${folder}, continuing..."
}
ensure_tree_writable() {
local folder="$1"
make_writable "$folder"
if [ -d "$folder" ]; then
chmod -R u+rwX,g+rwX "$folder" || echo "Failed to relax permissions for ${folder}, continuing..."
fi
}
ensure_symlinked_dir() {
local link_path="$1"
local target_path="$2"
ensure_tree_writable "$target_path"
if [ -L "$link_path" ]; then
local current_target
current_target=$(readlink "$link_path" 2>/dev/null || echo "")
if [ "$current_target" = "$target_path" ]; then
echo "$link_path already points to $target_path"
return 0
fi
echo "Replacing symlink $link_path -> $current_target with $target_path"
rm -f "$link_path" || echo "Failed to replace symlink ${link_path}, continuing..."
elif [ -d "$link_path" ]; then
echo "Moving existing scratch files from $link_path to $target_path"
find "$link_path" -xdev -mindepth 1 -maxdepth 1 -exec mv -t "$target_path" {} + 2>/dev/null || true
ensure_tree_writable "$target_path"
if ! rmdir "$link_path" 2>/dev/null; then
echo "Could not replace $link_path with symlink, leaving existing directory in place"
ensure_tree_writable "$link_path"
return 0
fi
elif [ -e "$link_path" ]; then
echo "$link_path exists and is not a directory, leaving it in place"
return 0
fi
if [ ! -e "$link_path" ]; then
ln -s "$target_path" "$link_path" || echo "Failed to create symlink ${link_path}, continuing..."
fi
}
fix_misowned /app
fix_misowned /var/log/shelfmark
fix_misowned /tmp/shelfmark
# SeleniumBase (internal bypasser) writes a patched chromedriver binary (uc_driver)
# into its own drivers directory. Some NAS/docker setups can apply restrictive ACLs
# to extracted image layers that block non-root writes; ensure the runtime UID owns it.
# Keep SeleniumBase on its default /app-based paths, but redirect the scratch
# directories into /tmp so bypasser startup doesn't depend on image-layer writes.
if [ "${USING_EXTERNAL_BYPASSER}" != "true" ]; then
ensure_symlinked_dir /app/downloaded_files /tmp/shelfmark/seleniumbase/downloaded_files
ensure_symlinked_dir /app/archived_files /tmp/shelfmark/seleniumbase/archived_files
# Keep SeleniumBase's bundled drivers directory writable as well for
# compatibility with legacy UC code paths that still probe bundled assets.
set +e
SELENIUMBASE_DRIVERS_DIR=$(python3 -c "import pathlib, seleniumbase; print(pathlib.Path(seleniumbase.__file__).resolve().parent / 'drivers')" 2>/dev/null)
set -e
@@ -217,7 +265,7 @@ if [ "${USING_EXTERNAL_BYPASSER}" != "true" ]; then
if [ -n "$SELENIUMBASE_DRIVERS_DIR" ] && [ -d "$SELENIUMBASE_DRIVERS_DIR" ]; then
change_ownership "$SELENIUMBASE_DRIVERS_DIR"
# If the driver already exists, ensure it's executable for the runtime user.
# If the legacy driver already exists, ensure it's executable for the runtime user.
if [ -f "${SELENIUMBASE_DRIVERS_DIR}/uc_driver" ]; then
chmod +x "${SELENIUMBASE_DRIVERS_DIR}/uc_driver" || echo "Failed to chmod uc_driver, continuing..."
fi

View File

@@ -0,0 +1,246 @@
#!/usr/bin/env bash
set -euo pipefail
LATEST_IMAGE="${LATEST_IMAGE:-ghcr.io/calibrain/shelfmark:latest}"
LEGACY_IMAGE="${LEGACY_IMAGE:-ghcr.io/calibrain/shelfmark:v1.0.2}"
WAIT_SECONDS="${WAIT_SECONDS:-5}"
STARTUP_TIMEOUT_SECONDS="${STARTUP_TIMEOUT_SECONDS:-120}"
require_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "Missing required command: $1" >&2
exit 1
}
}
cleanup() {
local name="$1"
docker rm -f "$name" >/dev/null 2>&1 || true
}
wait_for_startup() {
local name="$1"
local elapsed=0
while [ "$elapsed" -lt "$STARTUP_TIMEOUT_SECONDS" ]; do
if ! docker inspect "$name" >/dev/null 2>&1; then
echo "Container $name no longer exists" >&2
return 1
fi
if [ "$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null)" != "running" ]; then
echo "Container $name exited before startup completed" >&2
docker logs --tail 120 "$name" 2>&1 || true
return 1
fi
if docker exec "$name" sh -lc "id appuser >/dev/null 2>&1 && ps -eo comm,args | awk '\$1 == \"gunicorn\" && index(\$0, \"shelfmark.main:app\") { found=1 } END { exit(found ? 0 : 1) }'" >/dev/null 2>&1; then
return 0
fi
sleep 1
elapsed=$((elapsed + 1))
done
echo "Timed out waiting for $name to finish startup" >&2
docker logs --tail 120 "$name" 2>&1 || true
return 1
}
start_container() {
local name="$1"
local image="$2"
local pre_entrypoint_script="${3:-}"
cleanup "$name"
if [ -n "$pre_entrypoint_script" ]; then
docker run -d \
--name "$name" \
--entrypoint sh \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=UTC \
"$image" \
-lc "$pre_entrypoint_script
exec /app/entrypoint.sh" >/dev/null
else
docker run -d \
--name "$name" \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=UTC \
"$image" >/dev/null
sleep "$WAIT_SECONDS"
fi
wait_for_startup "$name"
}
run_probe() {
local name="$1"
local mode="${2:-default}"
docker exec -u appuser -e PROBE_MODE="$mode" "$name" sh -lc 'python3 - <<'"'"'PY'"'"'
import asyncio
import os
import shelfmark.bypass.internal_bypasser as ib
async def run_probe():
driver = None
probe_mode = os.environ.get("PROBE_MODE", "default")
if probe_mode == "proxy_auth" and hasattr(ib, "_get_proxy_string"):
ib._get_proxy_string = lambda _url: "user:pass@127.0.0.1:8888"
if hasattr(ib, "_create_cdp_browser"):
try:
driver = await ib._create_cdp_browser("https://example.com")
profile = getattr(getattr(driver, "config", None), "user_data_dir", "")
print(f"PROBE=OK mode={probe_mode} fn=_create_cdp_browser profile={profile}")
except Exception as e:
print(f"PROBE=ERR mode={probe_mode} fn=_create_cdp_browser type={type(e).__name__} msg={e}")
finally:
if driver and hasattr(ib, "_close_cdp_driver"):
await ib._close_cdp_driver(driver)
return
if hasattr(ib, "_create_driver"):
try:
driver = await ib._create_driver()
print(f"PROBE=OK mode={probe_mode} fn=_create_driver driver_type={type(driver).__name__}")
except Exception as e:
print(f"PROBE=ERR mode={probe_mode} fn=_create_driver type={type(e).__name__} msg={e}")
finally:
if driver and hasattr(ib, "_quit_driver"):
await ib._quit_driver(driver)
return
print(f"PROBE=ERR mode={probe_mode} fn=unknown type=RuntimeError msg=no supported startup function found")
asyncio.run(run_probe())
PY'
}
show_logs() {
local name="$1"
docker logs --tail 80 "$name" 2>&1 | tail -n 20
}
scenario_latest_baseline() {
local name="sb-lab-latest-baseline"
echo
echo "== latest baseline =="
start_container "$name" "$LATEST_IMAGE"
run_probe "$name"
cleanup "$name"
}
scenario_latest_drivers_readonly() {
local name="sb-lab-latest-drivers"
echo
echo "== latest drivers readonly =="
start_container "$name" "$LATEST_IMAGE" '
chown -R root:root /usr/local/lib/python3.10/site-packages/seleniumbase/drivers &&
chmod -R a-w /usr/local/lib/python3.10/site-packages/seleniumbase/drivers &&
ls -ld /usr/local/lib/python3.10/site-packages/seleniumbase/drivers
'
run_probe "$name"
cleanup "$name"
}
scenario_latest_proxy_auth_baseline() {
local name="sb-lab-latest-proxy-baseline"
echo
echo "== latest proxy auth baseline =="
start_container "$name" "$LATEST_IMAGE"
run_probe "$name" "proxy_auth"
cleanup "$name"
}
scenario_latest_downloads_readonly() {
local name="sb-lab-latest-downloads"
echo
echo "== latest downloaded_files readonly =="
start_container "$name" "$LATEST_IMAGE" '
mkdir -p /app/downloaded_files &&
touch /app/downloaded_files/pipfinding.lock /app/downloaded_files/proxy_dir.lock &&
chown -R root:root /app/downloaded_files &&
chmod -R a-w /app/downloaded_files &&
find /app/downloaded_files -maxdepth 2 -printf "%M %u:%g %p\n"
'
run_probe "$name"
show_logs "$name"
cleanup "$name"
}
scenario_latest_proxy_auth_downloads_readonly() {
local name="sb-lab-latest-proxy-downloads"
echo
echo "== latest proxy auth with readonly downloaded_files =="
start_container "$name" "$LATEST_IMAGE" '
mkdir -p /app/downloaded_files &&
touch /app/downloaded_files/pipfinding.lock /app/downloaded_files/proxy_dir.lock &&
chown appuser:appuser /app/downloaded_files/pipfinding.lock /app/downloaded_files/proxy_dir.lock &&
chmod 0666 /app/downloaded_files/pipfinding.lock /app/downloaded_files/proxy_dir.lock &&
chown root:root /app/downloaded_files &&
chmod 0555 /app/downloaded_files &&
ls -ld /app/downloaded_files &&
ls -la /app/downloaded_files
'
run_probe "$name" "proxy_auth"
show_logs "$name"
cleanup "$name"
}
scenario_latest_bind_mount_readonly() {
local name="sb-lab-latest-bind-ro"
local bind_dir
bind_dir="$(mktemp -d /tmp/sb-lab-bind.XXXXXX)"
echo
echo "== latest readonly bind mount for downloaded_files =="
chmod 0555 "$bind_dir"
cleanup "$name"
docker run -d \
--name "$name" \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=UTC \
--mount "type=bind,src=${bind_dir},target=/app/downloaded_files,readonly" \
"$LATEST_IMAGE" >/dev/null
wait_for_startup "$name"
run_probe "$name"
show_logs "$name"
cleanup "$name"
rm -rf "$bind_dir"
}
scenario_legacy_drivers_readonly() {
local name="sb-lab-legacy-drivers"
echo
echo "== legacy drivers readonly =="
start_container "$name" "$LEGACY_IMAGE" '
chown -R root:root /usr/local/lib/python3.10/site-packages/seleniumbase/drivers &&
chmod -R a-w /usr/local/lib/python3.10/site-packages/seleniumbase/drivers &&
ls -ld /usr/local/lib/python3.10/site-packages/seleniumbase/drivers
'
run_probe "$name"
show_logs "$name"
cleanup "$name"
}
main() {
require_cmd docker
scenario_latest_baseline
scenario_latest_drivers_readonly
scenario_latest_proxy_auth_baseline
scenario_latest_downloads_readonly
scenario_latest_proxy_auth_downloads_readonly
scenario_latest_bind_mount_readonly
scenario_legacy_drivers_readonly
}
main "$@"

View File

@@ -3,6 +3,7 @@ import os
import random
import signal
import socket
import stat
import subprocess
import threading
import time
@@ -27,6 +28,9 @@ from shelfmark.download.network import get_proxies, get_ssl_verify
logger = setup_logger(__name__)
SELENIUMBASE_RUNTIME_ROOT = "/tmp/shelfmark/seleniumbase"
SELENIUMBASE_DOWNLOADS_DIR = os.path.join(SELENIUMBASE_RUNTIME_ROOT, "downloaded_files")
# Challenge detection indicators
CLOUDFLARE_INDICATORS = [
"just a moment",
@@ -50,6 +54,21 @@ DISPLAY = {
LOCKED = threading.Lock()
def _describe_runtime_path(path: str) -> str:
"""Return compact ownership/mode info for a runtime path."""
try:
link_target = ""
if os.path.islink(path):
link_target = f" -> {os.readlink(path)}"
st = os.stat(path)
mode = stat.S_IMODE(st.st_mode)
return f"{path}{link_target} exists uid={st.st_uid} gid={st.st_gid} mode={oct(mode)}"
except FileNotFoundError:
return f"{path} missing"
except Exception as e:
return f"{path} error={type(e).__name__}: {e}"
class _CdpWorker:
def __init__(self) -> None:
self._thread: Optional[threading.Thread] = None
@@ -771,18 +790,30 @@ async def _create_cdp_browser(url: str) -> Any:
logger.debug(f"Creating Pure CDP browser with args: {browser_args}")
logger.debug(f"Browser screen size: {screen_width}x{screen_height}")
driver = await cdp_driver.start_async(
headless=False,
headed=False,
xvfb=True,
xvfb_metrics=f"{display_width},{display_height}",
sandbox=False,
lang="en",
incognito=True,
ad_block=True,
proxy=proxy,
browser_args=browser_args,
)
try:
driver = await cdp_driver.start_async(
headless=False,
headed=False,
xvfb=True,
xvfb_metrics=f"{display_width},{display_height}",
sandbox=False,
lang="en",
incognito=True,
ad_block=True,
proxy=proxy,
browser_args=browser_args,
)
except Exception as e:
logger.warning(f"Pure CDP browser startup failed: {type(e).__name__}: {e}")
logger.warning(
"SeleniumBase runtime paths: "
f"cwd={os.getcwd()}; "
f"{_describe_runtime_path(SELENIUMBASE_DOWNLOADS_DIR)}; "
f"{_describe_runtime_path('/app/downloaded_files')}; "
f"{_describe_runtime_path('downloaded_files')}; "
f"{_describe_runtime_path('/tmp')}"
)
raise
try:
await driver.page.set_window_rect(0, 0, screen_width, screen_height)

View File

@@ -27,12 +27,79 @@ from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
def _require_authenticated(resolve_auth_mode: Callable[[], str]):
def _normalize_log_field(value: Any) -> str:
if value is None:
return "-"
text = str(value).strip()
return text or "-"
def _log_activity_rejection(
action: str,
*,
status_code: int,
reason: str,
item_type: Any = None,
item_key: Any = None,
item_count: int | None = None,
missing_item_keys: list[str] | None = None,
) -> None:
parts = [
f"Activity {action} rejected",
f"status={status_code}",
f"reason={_normalize_log_field(reason)}",
f"method={request.method}",
f"path={request.path}",
f"user={_normalize_log_field(session.get('user_id'))}",
f"db_user_id={_normalize_log_field(session.get('db_user_id'))}",
f"is_admin={bool(session.get('is_admin', False))}",
]
if item_type is not None:
parts.append(f"item_type={_normalize_log_field(item_type)}")
if item_key is not None:
parts.append(f"item_key={_normalize_log_field(item_key)}")
if item_count is not None:
parts.append(f"item_count={item_count}")
if missing_item_keys:
parts.append(f"missing_item_keys={','.join(missing_item_keys)}")
logger.warning(" ".join(parts))
def _activity_error_response(
action: str,
*,
status_code: int,
error: str,
code: str | None = None,
item_type: Any = None,
item_key: Any = None,
item_count: int | None = None,
missing_item_keys: list[str] | None = None,
):
_log_activity_rejection(
action,
status_code=status_code,
reason=error,
item_type=item_type,
item_key=item_key,
item_count=item_count,
missing_item_keys=missing_item_keys,
)
payload: dict[str, Any] = {"error": error}
if code:
payload["code"] = code
if missing_item_keys:
payload["missing_item_keys"] = missing_item_keys
return jsonify(payload), status_code
def _require_authenticated(resolve_auth_mode: Callable[[], str], *, action: str):
auth_mode = resolve_auth_mode()
if auth_mode == "none":
return None
if "user_id" not in session:
return jsonify({"error": "Unauthorized"}), 401
return _activity_error_response(action, status_code=401, error="Unauthorized")
return None
@@ -40,46 +107,38 @@ def _resolve_db_user_id(
require_in_auth_mode: bool = True,
*,
user_db: UserDB | None = None,
action: str | None = None,
):
raw_db_user_id = session.get("db_user_id")
if raw_db_user_id is None:
if not require_in_auth_mode:
return None, None
return None, (
jsonify(
{
"error": "User identity unavailable for activity workflow",
"code": "user_identity_unavailable",
}
),
403,
return None, _activity_error_response(
action or "request",
status_code=403,
error="User identity unavailable for activity workflow",
code="user_identity_unavailable",
)
try:
parsed_db_user_id = int(raw_db_user_id)
except (TypeError, ValueError):
if not require_in_auth_mode:
return None, None
return None, (
jsonify(
{
"error": "User identity unavailable for activity workflow",
"code": "user_identity_unavailable",
}
),
403,
return None, _activity_error_response(
action or "request",
status_code=403,
error="User identity unavailable for activity workflow",
code="user_identity_unavailable",
)
if parsed_db_user_id < 1:
if not require_in_auth_mode:
return None, None
return None, (
jsonify(
{
"error": "User identity unavailable for activity workflow",
"code": "user_identity_unavailable",
}
),
403,
return None, _activity_error_response(
action or "request",
status_code=403,
error="User identity unavailable for activity workflow",
code="user_identity_unavailable",
)
if user_db is not None:
@@ -91,14 +150,11 @@ def _resolve_db_user_id(
if db_user is None:
if not require_in_auth_mode:
return None, None
return None, (
jsonify(
{
"error": "User identity unavailable for activity workflow",
"code": "user_identity_unavailable",
}
),
403,
return None, _activity_error_response(
action or "request",
status_code=403,
error="User identity unavailable for activity workflow",
code="user_identity_unavailable",
)
return parsed_db_user_id, None
@@ -116,6 +172,7 @@ def _resolve_activity_actor(
*,
user_db: UserDB,
resolve_auth_mode: Callable[[], str],
action: str,
) -> tuple[_ActorContext | None, Any | None]:
"""Resolve acting user identity for activity mutations.
@@ -130,7 +187,7 @@ def _resolve_activity_actor(
viewer_scope=NOAUTH_VIEWER_SCOPE,
), None
db_user_id, db_gate = _resolve_db_user_id(user_db=user_db)
db_user_id, db_gate = _resolve_db_user_id(user_db=user_db, action=action)
if db_user_id is None:
return None, db_gate
@@ -155,25 +212,25 @@ def _activity_ws_room(actor: _ActorContext) -> str:
def _check_item_ownership(actor: _ActorContext, row: dict[str, Any]) -> Any | None:
"""Return a 403 response if the actor doesn't own the item, else None."""
"""Return an error string if the actor doesn't own the item, else None."""
if actor.is_admin:
return None
owner_user_id = normalize_positive_int(row.get("user_id"))
if owner_user_id != actor.db_user_id:
return jsonify({"error": "Forbidden"}), 403
return "Forbidden"
return None
def _check_terminal_download(row: dict[str, Any]) -> Any | None:
final_status = str(row.get("final_status") or "").strip().lower()
if final_status not in VALID_TERMINAL_STATUSES:
return jsonify({"error": "Only terminal downloads can be dismissed"}), 409
return "Only terminal downloads can be dismissed"
return None
def _check_terminal_request(row: dict[str, Any]) -> Any | None:
if _request_terminal_status(row) is None:
return jsonify({"error": "Only terminal requests can be dismissed"}), 409
return "Only terminal requests can be dismissed"
return None
@@ -336,13 +393,14 @@ def register_activity_routes(
@app.route("/api/activity/snapshot", methods=["GET"])
def api_activity_snapshot():
auth_gate = _require_authenticated(resolve_auth_mode)
auth_gate = _require_authenticated(resolve_auth_mode, action="snapshot")
if auth_gate is not None:
return auth_gate
actor, actor_error = _resolve_activity_actor(
user_db=user_db,
resolve_auth_mode=resolve_auth_mode,
action="snapshot",
)
if actor_error is not None:
return actor_error
@@ -404,20 +462,21 @@ def register_activity_routes(
@app.route("/api/activity/dismiss", methods=["POST"])
def api_activity_dismiss():
auth_gate = _require_authenticated(resolve_auth_mode)
auth_gate = _require_authenticated(resolve_auth_mode, action="dismiss")
if auth_gate is not None:
return auth_gate
actor, actor_error = _resolve_activity_actor(
user_db=user_db,
resolve_auth_mode=resolve_auth_mode,
action="dismiss",
)
if actor_error is not None:
return actor_error
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"error": "Invalid payload"}), 400
return _activity_error_response("dismiss", status_code=400, error="Invalid payload")
item_type = str(data.get("item_type") or "").strip().lower()
item_key = data.get("item_key")
@@ -427,18 +486,42 @@ def register_activity_routes(
if item_type == "download":
task_id = _parse_item_key(item_key, "download")
if task_id is None:
return jsonify({"error": "item_key must be in the format download:<task_id>"}), 400
return _activity_error_response(
"dismiss",
status_code=400,
error="item_key must be in the format download:<task_id>",
item_type="download",
item_key=item_key,
)
existing = download_history_service.get_by_task_id(task_id)
if existing is None:
return jsonify({"error": "Download not found"}), 404
return _activity_error_response(
"dismiss",
status_code=404,
error="Download not found",
item_type="download",
item_key=f"download:{task_id}",
)
ownership_gate = _check_item_ownership(actor, existing)
if ownership_gate is not None:
return ownership_gate
terminal_gate = _check_terminal_download(existing)
if terminal_gate is not None:
return terminal_gate
ownership_error = _check_item_ownership(actor, existing)
if ownership_error is not None:
return _activity_error_response(
"dismiss",
status_code=403,
error=ownership_error,
item_type="download",
item_key=f"download:{task_id}",
)
terminal_error = _check_terminal_download(existing)
if terminal_error is not None:
return _activity_error_response(
"dismiss",
status_code=409,
error=terminal_error,
item_type="download",
item_key=f"download:{task_id}",
)
activity_view_state_service.dismiss(
viewer_scope=actor.viewer_scope,
@@ -450,18 +533,42 @@ def register_activity_routes(
elif item_type == "request":
request_id = normalize_positive_int(_parse_item_key(item_key, "request"))
if request_id is None:
return jsonify({"error": "item_key must be in the format request:<id>"}), 400
return _activity_error_response(
"dismiss",
status_code=400,
error="item_key must be in the format request:<id>",
item_type="request",
item_key=item_key,
)
request_row = user_db.get_request(request_id)
if request_row is None:
return jsonify({"error": "Request not found"}), 404
return _activity_error_response(
"dismiss",
status_code=404,
error="Request not found",
item_type="request",
item_key=f"request:{request_id}",
)
ownership_gate = _check_item_ownership(actor, request_row)
if ownership_gate is not None:
return ownership_gate
terminal_gate = _check_terminal_request(request_row)
if terminal_gate is not None:
return terminal_gate
ownership_error = _check_item_ownership(actor, request_row)
if ownership_error is not None:
return _activity_error_response(
"dismiss",
status_code=403,
error=ownership_error,
item_type="request",
item_key=f"request:{request_id}",
)
terminal_error = _check_terminal_request(request_row)
if terminal_error is not None:
return _activity_error_response(
"dismiss",
status_code=409,
error=terminal_error,
item_type="request",
item_key=f"request:{request_id}",
)
activity_view_state_service.dismiss(
viewer_scope=actor.viewer_scope,
@@ -470,7 +577,13 @@ def register_activity_routes(
)
dismissal_item = {"item_type": "request", "item_key": f"request:{request_id}"}
else:
return jsonify({"error": "item_type must be one of: download, request"}), 400
return _activity_error_response(
"dismiss",
status_code=400,
error="item_type must be one of: download, request",
item_type=item_type,
item_key=item_key,
)
room = _activity_ws_room(actor)
emit_ws_event(
@@ -488,30 +601,36 @@ def register_activity_routes(
@app.route("/api/activity/dismiss-many", methods=["POST"])
def api_activity_dismiss_many():
auth_gate = _require_authenticated(resolve_auth_mode)
auth_gate = _require_authenticated(resolve_auth_mode, action="dismiss_many")
if auth_gate is not None:
return auth_gate
actor, actor_error = _resolve_activity_actor(
user_db=user_db,
resolve_auth_mode=resolve_auth_mode,
action="dismiss_many",
)
if actor_error is not None:
return actor_error
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"error": "Invalid payload"}), 400
return _activity_error_response("dismiss_many", status_code=400, error="Invalid payload")
items = data.get("items")
if not isinstance(items, list):
return jsonify({"error": "items must be an array"}), 400
return _activity_error_response("dismiss_many", status_code=400, error="items must be an array")
dismissal_items: list[dict[str, str]] = []
missing_item_keys: list[str] = []
for item in items:
if not isinstance(item, dict):
return jsonify({"error": "items must contain objects"}), 400
return _activity_error_response(
"dismiss_many",
status_code=400,
error="items must contain objects",
item_count=len(items),
)
item_type = str(item.get("item_type") or "").strip().lower()
item_key = item.get("item_key")
@@ -519,48 +638,95 @@ def register_activity_routes(
if item_type == "download":
task_id = _parse_item_key(item_key, "download")
if task_id is None:
return jsonify({"error": "download item_key must be in the format download:<task_id>"}), 400
return _activity_error_response(
"dismiss_many",
status_code=400,
error="download item_key must be in the format download:<task_id>",
item_type="download",
item_key=item_key,
item_count=len(items),
)
existing = download_history_service.get_by_task_id(task_id)
if existing is None:
missing_item_keys.append(f"download:{task_id}")
continue
ownership_gate = _check_item_ownership(actor, existing)
if ownership_gate is not None:
return ownership_gate
terminal_gate = _check_terminal_download(existing)
if terminal_gate is not None:
return terminal_gate
ownership_error = _check_item_ownership(actor, existing)
if ownership_error is not None:
return _activity_error_response(
"dismiss_many",
status_code=403,
error=ownership_error,
item_type="download",
item_key=f"download:{task_id}",
item_count=len(items),
)
terminal_error = _check_terminal_download(existing)
if terminal_error is not None:
return _activity_error_response(
"dismiss_many",
status_code=409,
error=terminal_error,
item_type="download",
item_key=f"download:{task_id}",
item_count=len(items),
)
dismissal_items.append({"item_type": "download", "item_key": f"download:{task_id}"})
continue
if item_type == "request":
request_id = normalize_positive_int(_parse_item_key(item_key, "request"))
if request_id is None:
return jsonify({"error": "request item_key must be in the format request:<id>"}), 400
return _activity_error_response(
"dismiss_many",
status_code=400,
error="request item_key must be in the format request:<id>",
item_type="request",
item_key=item_key,
item_count=len(items),
)
request_row = user_db.get_request(request_id)
if request_row is None:
missing_item_keys.append(f"request:{request_id}")
continue
ownership_gate = _check_item_ownership(actor, request_row)
if ownership_gate is not None:
return ownership_gate
terminal_gate = _check_terminal_request(request_row)
if terminal_gate is not None:
return terminal_gate
ownership_error = _check_item_ownership(actor, request_row)
if ownership_error is not None:
return _activity_error_response(
"dismiss_many",
status_code=403,
error=ownership_error,
item_type="request",
item_key=f"request:{request_id}",
item_count=len(items),
)
terminal_error = _check_terminal_request(request_row)
if terminal_error is not None:
return _activity_error_response(
"dismiss_many",
status_code=409,
error=terminal_error,
item_type="request",
item_key=f"request:{request_id}",
item_count=len(items),
)
dismissal_items.append({"item_type": "request", "item_key": f"request:{request_id}"})
continue
return jsonify({"error": "item_type must be one of: download, request"}), 400
return _activity_error_response(
"dismiss_many",
status_code=400,
error="item_type must be one of: download, request",
item_type=item_type,
item_key=item_key,
item_count=len(items),
)
if missing_item_keys:
return (
jsonify(
{
"error": "One or more activity items were not found",
"missing_item_keys": missing_item_keys,
}
),
404,
return _activity_error_response(
"dismiss_many",
status_code=404,
error="One or more activity items were not found",
item_count=len(items),
missing_item_keys=missing_item_keys,
)
dismissed_count = activity_view_state_service.dismiss_many(
@@ -583,13 +749,14 @@ def register_activity_routes(
@app.route("/api/activity/history", methods=["GET"])
def api_activity_history():
auth_gate = _require_authenticated(resolve_auth_mode)
auth_gate = _require_authenticated(resolve_auth_mode, action="history")
if auth_gate is not None:
return auth_gate
actor, actor_error = _resolve_activity_actor(
user_db=user_db,
resolve_auth_mode=resolve_auth_mode,
action="history",
)
if actor_error is not None:
return actor_error
@@ -601,9 +768,9 @@ def register_activity_routes(
if offset is None:
offset = 0
if limit < 1:
return jsonify({"error": "limit must be a positive integer"}), 400
return _activity_error_response("history", status_code=400, error="limit must be a positive integer")
if offset < 0:
return jsonify({"error": "offset must be a non-negative integer"}), 400
return _activity_error_response("history", status_code=400, error="offset must be a non-negative integer")
history_rows = activity_view_state_service.list_history(
viewer_scope=actor.viewer_scope,
@@ -672,13 +839,14 @@ def register_activity_routes(
@app.route("/api/activity/history", methods=["DELETE"])
def api_activity_history_clear():
auth_gate = _require_authenticated(resolve_auth_mode)
auth_gate = _require_authenticated(resolve_auth_mode, action="history_clear")
if auth_gate is not None:
return auth_gate
actor, actor_error = _resolve_activity_actor(
user_db=user_db,
resolve_auth_mode=resolve_auth_mode,
action="history_clear",
)
if actor_error is not None:
return actor_error

View File

@@ -75,6 +75,30 @@ def determine_auth_mode(
return "none"
def _load_security_config() -> dict[str, Any]:
"""Load security settings with environment-backed values applied."""
from shelfmark.core.settings_registry import (
get_setting_value,
get_settings_field_map,
load_config_file,
)
try:
import shelfmark.config.security # noqa: F401
except Exception:
return load_config_file("security")
config = load_config_file("security")
field_map = get_settings_field_map(tab_name="security")
if not field_map:
return config
resolved = dict(config)
for key, (field, tab_name) in field_map.items():
resolved[key] = get_setting_value(field, tab_name)
return resolved
def load_active_auth_mode(
cwa_db_path: Any | None,
*,
@@ -82,9 +106,7 @@ def load_active_auth_mode(
) -> str:
"""Resolve active auth mode using current security config and runtime prerequisites."""
try:
from shelfmark.core.settings_registry import load_config_file
security_config = load_config_file("security")
security_config = _load_security_config()
return determine_auth_mode(
security_config,
cwa_db_path,

View File

@@ -86,7 +86,9 @@ class Config:
# This handles cases where config is accessed before settings are registered
try:
import shelfmark.config.settings # noqa: F401 - main app settings
import shelfmark.config.security # noqa: F401 - security/auth settings
import shelfmark.config.notifications_settings # noqa: F401 - notifications settings
import shelfmark.config.users_settings # noqa: F401 - users/request settings
import shelfmark.release_sources # noqa: F401 - plugin settings
import shelfmark.metadata_providers # noqa: F401 - plugin settings
except ImportError:

View File

@@ -5,7 +5,7 @@ Business logic remains in oidc_auth.py.
"""
from typing import Any
from urllib.parse import quote
from urllib.parse import urlencode, urlsplit, urlunsplit
from authlib.jose.errors import InvalidClaimError
from authlib.integrations.flask_client import OAuth
@@ -23,6 +23,7 @@ from shelfmark.download.network import get_ssl_verify
logger = setup_logger(__name__)
oauth = OAuth()
_RETURN_TO_SESSION_KEY = "oidc_return_to"
def _normalize_claims(raw_claims: Any) -> dict[str, Any]:
@@ -52,7 +53,67 @@ def _login_error_url(message: str) -> str:
"""Build a login URL (with script_root) that includes an OIDC error message."""
script_root = request.script_root.rstrip("/")
login_url = f"{script_root}/login" if script_root else "/login"
return f"{login_url}?oidc_error={quote(message)}"
params = {"oidc_error": message}
return_to = _get_pending_return_to()
if return_to and return_to != "/":
params["return_to"] = return_to
return f"{login_url}?{urlencode(params)}"
def _normalize_return_to(raw_return_to: Any) -> str | None:
"""Return a safe app-relative post-login target."""
if not isinstance(raw_return_to, str):
return None
value = raw_return_to.strip()
if not value or not value.startswith("/") or value.startswith("//"):
return None
parsed = urlsplit(value)
if parsed.scheme or parsed.netloc:
return None
script_root = request.script_root.rstrip("/")
path = parsed.path or "/"
if script_root:
if path == script_root:
path = "/"
elif path.startswith(f"{script_root}/"):
path = path[len(script_root):] or "/"
if (
path == "/login"
or path.startswith("/login/")
or path == "/api"
or path.startswith("/api/")
):
return None
return urlunsplit(("", "", path, parsed.query, parsed.fragment))
def _get_pending_return_to(*, clear: bool = False) -> str | None:
"""Read the pending post-login target from the session."""
raw_return_to = (
session.pop(_RETURN_TO_SESSION_KEY, None)
if clear
else session.get(_RETURN_TO_SESSION_KEY)
)
normalized = _normalize_return_to(raw_return_to)
if normalized is None and not clear:
session.pop(_RETURN_TO_SESSION_KEY, None)
return normalized
def _post_login_redirect_target(return_to: str | None) -> str:
"""Build the final redirect target, honoring script_root when present."""
normalized = _normalize_return_to(return_to) or "/"
script_root = request.script_root.rstrip("/")
if not script_root:
return normalized
if normalized == "/":
return f"{script_root}/"
return f"{script_root}{normalized}"
def _get_oidc_client() -> tuple[Any, dict[str, Any]]:
@@ -116,6 +177,11 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
"""Initiate OIDC login flow and redirect to the provider."""
try:
client, _ = _get_oidc_client()
return_to = _normalize_return_to(request.args.get("return_to"))
if return_to and return_to != "/":
session[_RETURN_TO_SESSION_KEY] = return_to
else:
session.pop(_RETURN_TO_SESSION_KEY, None)
redirect_uri = request.url_root.rstrip("/") + "/api/auth/oidc/callback"
return client.authorize_redirect(redirect_uri)
except ValueError:
@@ -213,7 +279,7 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
session.permanent = True
logger.info(f"OIDC login successful: {user['username']} (admin={is_admin})")
return redirect(request.script_root or "/")
return redirect(_post_login_redirect_target(_get_pending_return_to(clear=True)))
except ValueError as e:
logger.error(f"OIDC callback error: {e}")

View File

@@ -82,7 +82,7 @@ if BASE_PATH:
# We run this app under Gunicorn with a gevent websocket worker (even when DEBUG=true),
# so Socket.IO should always use gevent here.
async_mode = 'gevent'
socketio_cors_allowed_origins = "*" if DEBUG else None
socketio_cors_allowed_origins = "*"
# Initialize Flask-SocketIO with reverse proxy support
socketio_path = f"{BASE_PATH}/socket.io" if BASE_PATH else "/socket.io"
@@ -656,10 +656,9 @@ def proxy_auth_middleware():
def set_security_headers(response: Response) -> Response:
"""Add baseline security headers to every response."""
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; frame-ancestors 'none'",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:",
)
response.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
response.headers.setdefault("Cross-Origin-Embedder-Policy", "credentialless")
@@ -744,6 +743,11 @@ def index() -> Response:
"""
return _serve_index_html()
@app.route('/theme-init.js')
def theme_init_js() -> Response:
"""Serve the blocking theme-init script."""
return send_from_directory(FRONTEND_DIST, 'theme-init.js', mimetype='application/javascript')
@app.route('/logo.png')
def logo() -> Response:
"""

View File

@@ -1,5 +1,6 @@
"""Direct download source - Anna's Archive/Libgen with fallback cascade."""
from dataclasses import replace
import itertools
import json
import re
@@ -166,7 +167,7 @@ def search_books(query: str, filters: SearchFilters) -> List[BrowseRecord]:
filters_query = ""
for value in filters.lang if filters.lang else config.BOOK_LANGUAGE or []:
for value in filters.lang or []:
if value and value != "all":
filters_query += f"&lang={quote(value)}"
@@ -1162,6 +1163,23 @@ class DirectDownloadSource(ReleaseSource):
return None
return get_aa_content_type_dir(task.content_type)
def _search_books_with_language_fallback(
self,
query: str,
filters: SearchFilters,
*,
search_label: str,
) -> List[BrowseRecord]:
"""Retry AA queries without a language filter when filtered search returns nothing."""
results = search_books(query, filters)
if results or not filters.lang:
return results
logger.debug(
f"No {search_label} results with langs={filters.lang}, retrying without language filter"
)
return search_books(query, replace(filters, lang=None))
def search(
self,
book: BookMetadata,
@@ -1191,7 +1209,7 @@ class DirectDownloadSource(ReleaseSource):
logger.debug(f"Searching direct_download: source_query='{query}', langs={lang_filter}")
filters = plan.source_filters or SearchFilters()
filters.lang = lang_filter if lang_filter is not None else (filters.lang or [])
results = search_books(query, filters)
results = self._search_books_with_language_fallback(query, filters, search_label="manual")
self._last_search_type = "manual" if query else "title_author"
return [_browse_record_to_release(record) for record in results]
@@ -1242,6 +1260,24 @@ class DirectDownloadSource(ReleaseSource):
except Exception as e:
logger.error(f"Search error: {e}")
if not all_results and any(langs for _, langs in searches):
logger.debug("No title+author results with language filter, retrying without language filter")
for title, _langs in searches:
query = f"{title} {author}".strip()
if not query:
continue
logger.debug(f"Searching direct_download: title_author='{query}', langs=[]")
try:
for bi in search_books(query, SearchFilters()):
if bi.id not in seen_ids:
seen_ids.add(bi.id)
all_results.append(bi)
except SearchUnavailable:
raise
except Exception as e:
logger.error(f"Search error: {e}")
logger.info(f"Found {len(all_results)} releases via title+author")
return [_browse_record_to_release(record) for record in all_results]

View File

@@ -1,6 +1,6 @@
"""IRC release source plugin.
Searches and downloads ebooks from IRC channels via DCC protocol.
Searches and downloads ebook and audiobook releases from IRC channels via DCC protocol.
Available when IRC server, channel, and nickname are configured in settings.
Based on OpenBooks (https://github.com/evan-buss/openbooks).

View File

@@ -4,7 +4,6 @@ Stores search results in CONFIG_DIR to survive container restarts.
IRC searches are slow and resource-intensive, so we cache aggressively.
"""
import hashlib
import json
import time
from dataclasses import asdict
@@ -14,6 +13,7 @@ from typing import Any, Dict, List, Optional
from shelfmark.config import env
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import is_audiobook as check_audiobook
from shelfmark.release_sources import Release, ReleaseProtocol
logger = setup_logger(__name__)
@@ -28,9 +28,10 @@ DEFAULT_CACHE_TTL = 30 * 24 * 60 * 60
_cache_lock = Lock()
def _generate_cache_key(provider: str, provider_id: str) -> str:
"""Generate a cache key from provider and provider_id."""
return f"{provider}:{provider_id}"
def _generate_cache_key(provider: str, provider_id: str, content_type: Optional[str] = None) -> str:
"""Generate a cache key from provider, provider_id, and content type."""
normalized_content_type = "audiobook" if check_audiobook(content_type) else "ebook"
return f"{provider}:{provider_id}:{normalized_content_type}"
def _load_cache() -> Dict[str, Any]:
@@ -74,6 +75,7 @@ def _dict_to_release(data: Dict[str, Any]) -> Release:
def get_cached_results(
provider: str,
provider_id: str,
content_type: Optional[str] = None,
ttl_seconds: Optional[int] = None
) -> Optional[Dict[str, Any]]:
"""
@@ -82,6 +84,7 @@ def get_cached_results(
Args:
provider: Metadata provider name (e.g., "hardcover", "openlibrary")
provider_id: Book ID in the provider's system
content_type: Search content type for cache isolation
ttl_seconds: Cache TTL in seconds (from settings)
Returns:
@@ -99,7 +102,7 @@ def get_cached_results(
if ttl_seconds == 0:
ttl_seconds = float('inf')
cache_key = _generate_cache_key(provider, provider_id)
cache_key = _generate_cache_key(provider, provider_id, content_type)
with _cache_lock:
cache = _load_cache()
@@ -113,6 +116,7 @@ def get_cached_results(
age = time.time() - cached_at
if age > ttl_seconds:
title = entry.get("title", cache_key)
logger.debug(f"IRC cache expired for '{title}' (age: {age:.0f}s > TTL: {ttl_seconds}s)")
# Don't delete here - let cleanup handle it
return None
@@ -136,6 +140,7 @@ def cache_results(
provider_id: str,
title: str,
releases: List[Release],
content_type: Optional[str] = None,
online_servers: Optional[List[str]] = None
) -> None:
"""
@@ -146,9 +151,10 @@ def cache_results(
provider_id: Book ID in the provider's system
title: Book title (for logging/display)
releases: List of Release objects from search
content_type: Search content type for cache isolation
online_servers: List of online server nicks (optional)
"""
cache_key = _generate_cache_key(provider, provider_id)
cache_key = _generate_cache_key(provider, provider_id, content_type)
with _cache_lock:
cache = _load_cache()
@@ -159,6 +165,7 @@ def cache_results(
cache["entries"][cache_key] = {
"provider": provider,
"provider_id": provider_id,
"content_type": "audiobook" if check_audiobook(content_type) else "ebook",
"title": title,
"releases": [_release_to_dict(r) for r in releases],
"online_servers": list(online_servers) if online_servers else [],
@@ -169,18 +176,19 @@ def cache_results(
logger.info(f"Cached {len(releases)} IRC releases for '{title}'")
def invalidate_cache(provider: str, provider_id: str) -> bool:
def invalidate_cache(provider: str, provider_id: str, content_type: Optional[str] = None) -> bool:
"""
Remove a specific entry from the cache.
Args:
provider: Metadata provider name
provider_id: Book ID in the provider's system
content_type: Search content type for cache isolation
Returns:
True if entry was found and removed
"""
cache_key = _generate_cache_key(provider, provider_id)
cache_key = _generate_cache_key(provider, provider_id, content_type)
with _cache_lock:
cache = _load_cache()

View File

@@ -1,6 +1,6 @@
"""IRC client implementation using raw sockets.
Minimal IRC client for ebook searches.
Minimal IRC client for Shelfmark release searches.
"""
import re
@@ -64,7 +64,7 @@ class IRCConnectionError(IRCError):
class IRCClient:
"""Minimal IRC client for per-request ebook searches."""
"""Minimal IRC client for per-request IRC release searches."""
def __init__(
self,

View File

@@ -1,6 +1,6 @@
"""IRC DCC download handler.
Handles downloading books via IRC DCC protocol.
Handles downloading IRC releases via DCC protocol.
"""
from pathlib import Path
@@ -29,7 +29,7 @@ class IRCDownloadHandler(DownloadHandler):
progress_callback: Callable[[float], None],
status_callback: Callable[[str, Optional[str]], None],
) -> Optional[str]:
"""Download a book via IRC DCC. task.task_id contains the IRC request string."""
"""Download a release via IRC DCC. task.task_id contains the IRC request string."""
download_request = task.task_id
logger.info(f"IRC download: {download_request[:60]}...")

View File

@@ -11,14 +11,13 @@ from typing import Optional
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import is_audiobook as check_audiobook
logger = setup_logger(__name__)
# All recognized formats for parsing IRC result lines.
# This comprehensive list is used to identify file extensions in results.
# User's configured formats are used separately for filtering.
# Note: IRC source currently only supports ebooks, but audiobook formats
# are included for future-proofing and format detection consistency.
# User-configured formats are used separately for filtering.
ALL_RECOGNIZED_FORMATS = {
# Ebook formats
'epub', 'mobi', 'azw3', 'azw', 'pdf', 'doc', 'docx',
@@ -29,9 +28,13 @@ ALL_RECOGNIZED_FORMATS = {
}
def _get_supported_formats() -> set[str]:
"""Get user's configured supported formats from settings."""
formats = config.get("SUPPORTED_FORMATS", ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"])
def _get_supported_formats(content_type: Optional[str] = None) -> set[str]:
"""Get the supported formats for the requested content type."""
if check_audiobook(content_type):
formats = config.get("SUPPORTED_AUDIOBOOK_FORMATS", ["m4b", "mp3"])
else:
formats = config.get("SUPPORTED_FORMATS", ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"])
if isinstance(formats, str):
return {fmt.strip().lower() for fmt in formats.split(",") if fmt.strip()}
return {fmt.lower() for fmt in formats}
@@ -140,10 +143,10 @@ def parse_result_line(line: str) -> Optional[SearchResult]:
return None
def parse_results_file(content: str) -> list[SearchResult]:
def parse_results_file(content: str, content_type: Optional[str] = None) -> list[SearchResult]:
"""Parse a search results file into SearchResult objects."""
results = []
supported = _get_supported_formats()
supported = _get_supported_formats(content_type)
for line in content.splitlines():
result = parse_result_line(line)

View File

@@ -39,7 +39,7 @@ def irc_settings():
key="heading",
title="IRC",
description=(
"Search and download books from IRC ebook channels. "
"Search and download ebook and audiobook releases from IRC channels. "
"This source connects via IRC and uses DCC for file transfers. "
"Configure the connection details below to enable IRC search. "
"Note: DCC requires direct TCP connections to arbitrary ports, "

View File

@@ -1,6 +1,6 @@
"""IRC release source plugin.
Searches IRC ebook channels for book releases.
Searches IRC channels for ebook and audiobook releases.
"""
import tempfile
@@ -66,11 +66,11 @@ def _enforce_rate_limit() -> None:
@register_source("irc")
class IRCReleaseSource(ReleaseSource):
"""Search IRC channels for book releases."""
"""Search IRC channels for ebook and audiobook releases."""
name = "irc"
display_name = "IRC"
supported_content_types = ["ebook"] # IRC only supports ebooks
supported_content_types = ["ebook", "audiobook"]
can_be_default = False # Exclude from default source options (requires deliberate selection)
def __init__(self):
@@ -141,7 +141,7 @@ class IRCReleaseSource(ReleaseSource):
# Check cache first (unless expand_search/refresh is requested)
if not expand_search:
cached = get_cached_results(book.provider, book.provider_id)
cached = get_cached_results(book.provider, book.provider_id, content_type=content_type)
if cached:
_emit_status("Using cached results", phase='complete')
self._online_servers = set(cached.get("online_servers", []))
@@ -199,7 +199,8 @@ class IRCReleaseSource(ReleaseSource):
book.provider_id,
book.title,
[],
list(self._online_servers) if self._online_servers else None
content_type=content_type,
online_servers=list(self._online_servers) if self._online_servers else None,
)
return []
@@ -219,8 +220,8 @@ class IRCReleaseSource(ReleaseSource):
connection_manager.release_connection(client)
# Convert to Release objects
results = parse_results_file(content)
releases = self._convert_to_releases(results)
results = parse_results_file(content, content_type=content_type)
releases = self._convert_to_releases(results, content_type=content_type)
# Cache results
cache_results(
@@ -228,7 +229,8 @@ class IRCReleaseSource(ReleaseSource):
book.provider_id,
book.title,
releases,
list(self._online_servers) if self._online_servers else None
content_type=content_type,
online_servers=list(self._online_servers) if self._online_servers else None,
)
return releases
@@ -263,7 +265,7 @@ class IRCReleaseSource(ReleaseSource):
return ' '.join(parts)
# Format priority for sorting (lower = higher priority)
FORMAT_PRIORITY = {
EBOOK_FORMAT_PRIORITY = {
'epub': 0,
'mobi': 1,
'azw3': 2,
@@ -283,10 +285,33 @@ class IRCReleaseSource(ReleaseSource):
'zip': 16,
}
def _convert_to_releases(self, results: List[SearchResult]) -> List[Release]:
AUDIOBOOK_FORMAT_PRIORITY = {
'm4b': 0,
'mp3': 1,
'm4a': 2,
'flac': 3,
'opus': 4,
'ogg': 5,
'aac': 6,
'wav': 7,
'wma': 8,
'rar': 9,
'zip': 10,
}
def _convert_to_releases(
self,
results: List[SearchResult],
content_type: str = "ebook",
) -> List[Release]:
"""Convert parsed results to Release objects, sorted by online/format/server."""
releases = []
online_servers = self._online_servers if self._online_servers else set()
format_priority_map = (
self.AUDIOBOOK_FORMAT_PRIORITY
if content_type == "audiobook"
else self.EBOOK_FORMAT_PRIORITY
)
for result in results:
release = Release(
@@ -298,6 +323,7 @@ class IRCReleaseSource(ReleaseSource):
size_bytes=self._parse_size(result.size) if result.size else None,
protocol=ReleaseProtocol.DCC,
indexer=f"IRC:{result.server}",
content_type=content_type,
extra={
"server": result.server,
"author": result.author,
@@ -311,7 +337,7 @@ class IRCReleaseSource(ReleaseSource):
server = release.extra.get("server", "")
is_online = server in online_servers
fmt = release.format.lower() if release.format else ""
format_priority = self.FORMAT_PRIORITY.get(fmt, 99)
format_priority = format_priority_map.get(fmt, 99)
return (
0 if is_online else 1, # Online first
format_priority, # Then by format

View File

@@ -19,25 +19,7 @@
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="apple-touch-icon" href="logo.png" />
<title>Shelfmark</title>
<script>
// Apply theme immediately before first paint to prevent flash.
// CSS variables aren't available until the stylesheet loads, so set
// the background color directly on <html> to avoid a white flash.
(function() {
const savedTheme = localStorage.getItem('preferred-theme') || 'auto';
let theme = savedTheme;
if (savedTheme === 'auto') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.backgroundColor = theme === 'dark' ? '#121212' : '#f8f8f8';
// Add class to prevent transitions on initial load
document.documentElement.classList.add('preload');
})();
</script>
<script src="theme-init.js"></script>
</head>
<body>
<div id="root"></div>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -12,19 +12,20 @@
"test:unit:build": "rm -rf ../../.local/frontend-test-dist && tsc -p tsconfig.tests.json"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.3",
"socket.io-client": "^4.7.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@types/node": "^24.10.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^5.1.4",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"tailwindcss": "^4.2.1",
"typescript": "^5.5.3",
"vite": "^7.3.1"
}

View File

@@ -1,6 +1,3 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
plugins: {},
}

View File

@@ -0,0 +1,22 @@
// Apply theme immediately before first paint to prevent flash.
// This runs as a blocking script before the app bundle loads.
(function() {
var saved = localStorage.getItem('preferred-theme') || 'auto';
var theme = saved === 'auto'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: saved;
var dark = theme === 'dark';
var bg = dark ? '#121212' : '#f8f8f8';
var fg = dark ? '#fff' : '#333';
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme;
var s = document.createElement('style');
s.id = 'theme-init';
s.textContent = 'html,body,#root{background:' + bg + ';color:' + fg + '}';
document.head.appendChild(s);
document.documentElement.classList.add('preload');
})();

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef, useMemo, CSSProperties } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import {
Book,
Release,
@@ -24,6 +24,7 @@ import {
cancelDownload,
retryDownload,
getConfig,
getStatus,
getMetadataProviders,
getMetadataSearchConfig,
createRequest,
@@ -61,6 +62,7 @@ import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages';
import { buildSearchQuery } from './utils/buildSearchQuery';
import { formatActingAsUserName } from './utils/actingAsUser';
import { withBasePath } from './utils/basePath';
import { buildLoginRedirectPath, getReturnToFromSearch } from './utils/authRedirect';
import { getConfiguredMetadataProviderForContentType } from './utils/metadataProviders';
import { getEffectiveMetadataSort } from './utils/metadataSort';
import {
@@ -79,6 +81,7 @@ import {
import { bookFromRequestData } from './utils/requestFulfil';
import { emitBookTargetChange, onBookTargetChange } from './utils/bookTargetEvents';
import { bookSupportsTargets } from './utils/bookTargetLoader';
import { wasDownloadQueuedAfterResponseError } from './utils/downloadRecovery';
import { getDynamicOptionGroup } from './components/shared/DynamicDropdown';
import { policyTrace } from './utils/policyTrace';
import { SearchModeProvider } from './contexts/SearchModeContext';
@@ -136,6 +139,9 @@ const getErrorMessage = (error: unknown, fallback: string): string => {
return fallback;
};
const CONFIRMED_DOWNLOAD_INTERRUPTED_MESSAGE =
'Download queued, but the proxy interrupted the response. Status will refresh shortly.';
type PendingOnBehalfDownload =
| {
type: 'book';
@@ -151,6 +157,7 @@ type PendingOnBehalfDownload =
};
function App() {
const location = useLocation();
const { toasts, showToast, removeToast } = useToast();
const { socket } = useSocket();
@@ -1130,8 +1137,10 @@ function App() {
async (book: Book, onBehalfOfUserId?: number): Promise<void> => {
const source = getBrowseSource(book);
const directContentType: ContentType = 'ebook';
const payload = buildReleaseDataFromDirectBook(book);
const requestStartedAtSeconds = Date.now() / 1000;
try {
await downloadRelease(buildReleaseDataFromDirectBook(book), onBehalfOfUserId);
await downloadRelease(payload, onBehalfOfUserId);
await fetchStatus();
removeBookFromActiveList(book);
} catch (error) {
@@ -1154,6 +1163,17 @@ function App() {
await refreshRequestPolicy({ force: true });
return;
}
try {
const status = await getStatus();
if (wasDownloadQueuedAfterResponseError(status, payload.source_id, requestStartedAtSeconds)) {
await fetchStatus();
removeBookFromActiveList(book);
showToast(CONFIRMED_DOWNLOAD_INTERRUPTED_MESSAGE, 'info');
return;
}
} catch (verificationError) {
console.warn('Failed to verify download after response error:', verificationError);
}
showToast(getErrorMessage(error, 'Failed to queue download'), 'error');
throw error;
}
@@ -1168,6 +1188,7 @@ function App() {
releaseContentType: ContentType,
onBehalfOfUserId?: number
): Promise<void> => {
const requestStartedAtSeconds = Date.now() / 1000;
try {
trackRelease(book.id, release.source_id);
await downloadRelease(
@@ -1220,6 +1241,17 @@ function App() {
await refreshRequestPolicy({ force: true });
return;
}
try {
const status = await getStatus();
if (wasDownloadQueuedAfterResponseError(status, release.source_id, requestStartedAtSeconds)) {
await fetchStatus();
removeBookFromActiveList(book);
showToast(CONFIRMED_DOWNLOAD_INTERRUPTED_MESSAGE, 'info');
return;
}
} catch (verificationError) {
console.warn('Failed to verify release download after response error:', verificationError);
}
showToast(getErrorMessage(error, 'Failed to queue download'), 'error');
throw error;
}
@@ -2055,6 +2087,7 @@ function App() {
onLogout={handleLogoutWithCleanup}
onSearch={handleSearchDispatch}
onAdvancedToggle={hasAdvancedContent ? () => setShowAdvanced(!showAdvanced) : undefined}
isAdvancedActive={showAdvanced}
isLoading={isSearching}
onShowToast={showToast}
onRemoveToast={removeToast}
@@ -2100,8 +2133,15 @@ function App() {
onMetadataProviderChange={handleMetadataProviderChange}
contentType={contentType}
isAdmin={requestRoleIsAdmin}
onClose={() => setShowAdvanced(false)}
/>
{!isInitialState && activeQueryTarget === 'manual' && (
<p className="text-xs opacity-50 px-4 sm:px-6 lg:px-8 pt-2 lg:ml-16">
Manual search queries release sources directly. Some sources may return limited metadata, which can affect file naming templates.
</p>
)}
<main
className="relative w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-6"
style={
@@ -2359,8 +2399,10 @@ function App() {
}
const shouldRedirectFromLogin = !authRequired || isAuthenticated;
const postLoginPath = getReturnToFromSearch(location.search);
const loginRedirectPath = buildLoginRedirectPath(location);
const appElement = authRequired && !isAuthenticated ? (
<Navigate to="/login" replace />
<Navigate to={loginRedirectPath} replace />
) : (
mainAppContent
);
@@ -2371,7 +2413,7 @@ function App() {
path="/login"
element={
shouldRedirectFromLogin ? (
<Navigate to="/" replace />
<Navigate to={postLoginPath} replace />
) : (
<LoginPage
onLogin={handleLogin}

View File

@@ -28,6 +28,7 @@ interface AdvancedFiltersProps {
onMetadataProviderChange?: (provider: string) => void;
contentType?: ContentType;
isAdmin?: boolean;
onClose?: () => void;
}
const SEARCH_MODE_OPTIONS = [
@@ -50,6 +51,7 @@ export const AdvancedFilters = ({
onMetadataProviderChange,
contentType = 'ebook',
isAdmin = false,
onClose,
}: AdvancedFiltersProps) => {
const { lang, content, formats } = filters;
@@ -91,10 +93,33 @@ export const AdvancedFilters = ({
const wrapperClassName = formClassName
? 'px-2'
: 'px-2 lg:ml-[calc(3rem+1rem)] lg:w-[calc(50vw+4rem)]';
: 'px-2 lg:ml-16 lg:w-[calc(50vw+4rem)]';
const settingsForm = (
<div className={wrapperClassName}>
{onClose && (
<div className="flex justify-end mb-1">
<button
type="button"
onClick={onClose}
className="p-1 rounded-full hover-action transition-colors"
aria-label="Close filters"
title="Close filters"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
style={{ color: 'var(--text-muted)' }}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
{isAdmin && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
@@ -169,7 +194,7 @@ export const AdvancedFilters = ({
return renderWrapper ? (
renderWrapper(settingsForm)
) : (
<div className="w-full border-b pt-6 pb-4 mb-4" style={{ borderColor: 'var(--border-muted)' }}>
<div className="w-full border-b-hairline 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>
);

View File

@@ -102,8 +102,8 @@ export const BookDownloadButton = ({
const baseClasses =
variant === 'icon'
? 'flex items-center justify-center rounded-full transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500'
: 'inline-flex items-center justify-center gap-1.5 rounded text-white transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500';
? 'flex items-center justify-center rounded-full transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500'
: 'inline-flex items-center justify-center gap-1.5 rounded-sm text-white transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500';
const sizeClass = variant === 'icon' ? iconVariantSizeClasses[size] : sizeClasses[size];
const iconSizes = variant === 'icon' ? iconVariantIconSizes[size] : undefined;

View File

@@ -169,7 +169,7 @@ export const BookGetButton = ({
if (isIconVariant) {
return (
<button
className={`flex items-center justify-center rounded-full transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-emerald-500 ${sizeClass} ${getButtonClasses()} ${className}`.trim()}
className={`flex items-center justify-center rounded-full transition-all duration-200 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-emerald-500 ${sizeClass} ${getButtonClasses()} ${className}`.trim()}
onClick={handleClick}
disabled={isDisabled}
style={style}
@@ -182,7 +182,7 @@ export const BookGetButton = ({
return (
<button
className={`inline-flex items-center justify-center gap-1.5 rounded text-white transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-emerald-500 ${sizeClass} ${widthClasses} ${getButtonClasses()} ${className}`.trim()}
className={`inline-flex items-center justify-center gap-1.5 rounded-sm text-white transition-all duration-200 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-emerald-500 ${sizeClass} ${widthClasses} ${getButtonClasses()} ${className}`.trim()}
onClick={handleClick}
disabled={isDisabled}
style={style}

View File

@@ -30,7 +30,7 @@ const BookmarkIcon = ({ className = 'h-4 w-4' }: { className?: string }) => (
strokeWidth="1.5"
stroke="currentColor"
aria-hidden="true"
className={`${className} flex-shrink-0`}
className={`${className} shrink-0`}
>
<path
strokeLinecap="round"
@@ -203,7 +203,7 @@ export const BookTargetDropdown = ({
<button
type="button"
onClick={toggle}
className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full transition-colors text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 hover:bg-emerald-100 dark:hover:bg-emerald-900/40 focus:outline-none`}
className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full transition-colors text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 hover:bg-emerald-100 dark:hover:bg-emerald-900/40 focus:outline-hidden`}
>
<BookmarkIcon className="w-3 h-3" />
Hardcover Lists{count > 0 ? ` (${count})` : ''}
@@ -217,7 +217,7 @@ export const BookTargetDropdown = ({
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggle(); }}
className={`flex items-center justify-center rounded-full transition-colors duration-200 focus:outline-none ${className ?? 'p-1.5 sm:p-2 text-gray-600 dark:text-gray-200 hover-action'}`}
className={`flex items-center justify-center rounded-full transition-colors duration-200 focus:outline-hidden ${className ?? 'p-1.5 sm:p-2 text-gray-600 dark:text-gray-200 hover-action'}`}
aria-label="Hardcover Lists"
title={count > 0 ? `On ${count} Hardcover list${count > 1 ? 's' : ''}` : 'Hardcover Lists'}
>

View File

@@ -65,7 +65,7 @@ export const ConfigSetupBanner = ({
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150
${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
/>
@@ -73,7 +73,7 @@ export const ConfigSetupBanner = ({
{/* Modal */}
<div
className={`relative w-full max-w-lg rounded-xl
border border-[var(--border-muted)] shadow-2xl
border-hairline border-(--border-muted) shadow-2xl
overflow-hidden
${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
style={{ background: 'var(--bg)' }}
@@ -82,13 +82,13 @@ export const ConfigSetupBanner = ({
aria-label="Settings Setup Information"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border-muted)]">
<div className="flex items-center justify-between px-5 py-4 border-b-hairline border-(--border-muted)">
<h2 className="text-lg font-semibold">
{showContinueButton ? 'Config Volume Required' : 'New Feature: Settings Page'}
</h2>
<button
onClick={handleClose}
className="p-1.5 rounded-lg hover:bg-[var(--hover-surface)] transition-colors"
className="p-1.5 rounded-lg hover:bg-(--hover-surface) transition-colors"
aria-label="Close"
>
<svg
@@ -113,8 +113,8 @@ export const ConfigSetupBanner = ({
</p>
{/* Code snippet */}
<div className="rounded-lg overflow-hidden border border-[var(--border-muted)]">
<div className="px-3 py-1.5 text-xs font-medium opacity-60 border-b border-[var(--border-muted)]"
<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)"
style={{ background: 'var(--bg-soft)' }}>
docker-compose.yml
</div>
@@ -139,22 +139,22 @@ export const ConfigSetupBanner = ({
</div>
{/* Footer */}
<div className="px-5 py-4 border-t border-[var(--border-muted)] flex justify-end gap-3">
<div className="px-5 py-4 border-t-hairline border-(--border-muted) flex justify-end gap-3">
{showContinueButton ? (
<>
<button
onClick={handleClose}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
bg-(--bg-soft) border-hairline border-(--border-muted)
hover:bg-(--hover-surface) transition-colors"
>
Close
</button>
<button
onClick={handleContinue}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--primary-color)] text-white
hover:bg-[var(--primary-dark)] transition-colors"
bg-(--primary-color) text-white
hover:bg-(--primary-dark) transition-colors"
>
Continue to Settings
</button>
@@ -163,8 +163,8 @@ export const ConfigSetupBanner = ({
<button
onClick={handleClose}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--primary-color)] text-white
hover:bg-[var(--primary-dark)] transition-colors"
bg-(--primary-color) text-white
hover:bg-(--primary-dark) transition-colors"
>
Got it
</button>

View File

@@ -135,7 +135,7 @@ export const DetailsModal = ({
})
: [];
const extendedInfoEntries = [[publisherInfo.label, publisherInfo.value], ...additionalInfo];
const infoCardClass = 'rounded-2xl border border-[var(--border-muted)] px-4 py-3 text-sm bg-[var(--bg-soft)] sm:bg-[var(--bg)]';
const infoCardClass = 'rounded-2xl border-hairline 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';
@@ -147,13 +147,13 @@ export const DetailsModal = ({
}}
>
<div
className={`details-container w-full max-w-4xl h-full sm:h-auto ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
className={`details-container w-full h-full sm:h-auto ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
role="dialog"
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 border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)] text-[var(--text)] shadow-none sm:shadow-2xl">
<header className="flex items-start gap-4 border-b border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--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-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-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 +198,7 @@ export const DetailsModal = ({
</div>
) : (
<div
className="flex w-full items-center justify-center rounded-xl border border-dashed border-[var(--border-muted)] bg-[var(--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-hairline 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
@@ -299,7 +299,7 @@ export const DetailsModal = ({
onSearchSeries(book.series_name!, book.series_id);
handleClose();
}}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors flex-shrink-0"
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 shrink-0"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
@@ -329,7 +329,7 @@ export const DetailsModal = ({
</div>
<footer
className="border-t border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)] px-5 py-4"
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))' }}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">

View File

@@ -184,9 +184,8 @@ export const Dropdown = ({
type="button"
onClick={toggleOpen}
disabled={disabled}
className={`w-full px-3 py-2 text-sm border flex items-center justify-between gap-2 text-left focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 transition-[border-radius] duration-150 ${buttonClassName}`}
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}`}
style={{
background: triggerChrome === 'minimal' ? 'transparent' : 'var(--bg-soft)',
color: 'var(--text)',
borderColor: triggerChrome === 'minimal' ? 'transparent' : 'var(--border-muted)',
borderWidth: triggerChrome === 'minimal' ? 0 : undefined,
@@ -205,7 +204,7 @@ export const Dropdown = ({
{summary ?? <span className="opacity-60">Select an option</span>}
</span>
<svg
className={`h-4 w-4 flex-shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`}
className={`h-4 w-4 shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -223,7 +222,7 @@ export const Dropdown = ({
panelDirection === 'down'
? renderTrigger ? 'mt-2' : ''
: renderTrigger ? 'bottom-full mb-2' : 'bottom-full'
} border z-20 ${panelDirection === 'down' ? 'shadow-lg' : ''} ${panelClassName || widthClassName}`}
} border-hairline z-20 ${panelDirection === 'down' ? 'shadow-lg' : ''} ${panelClassName || widthClassName}`}
style={{
background: 'var(--bg)',
borderColor: 'var(--border-muted)',

View File

@@ -132,7 +132,7 @@ export const DropdownList = ({
type="checkbox"
checked={selectedValues.includes(option.value)}
readOnly
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500 pointer-events-none"
className="h-4 w-4 rounded-sm border-gray-300 text-sky-600 focus:ring-sky-500 pointer-events-none"
/>
)}
{option.icon}

View File

@@ -39,7 +39,7 @@ export const Footer = ({ buildVersion, releaseVersion, debug }: FooterProps) =>
{truncatedBuild && ` (${truncatedBuild})`}
</span>
{debug && (
<span className="text-xs px-1.5 py-0.5 rounded opacity-60" style={{ background: 'var(--border-muted)' }}>
<span className="text-xs px-1.5 py-0.5 rounded-sm opacity-60" style={{ background: 'var(--border-muted)' }}>
Debug
</span>
)}

View File

@@ -22,6 +22,7 @@ interface HeaderProps {
onSearchChange?: (value: string | number | boolean, label?: string) => void;
onSearch?: () => void;
onAdvancedToggle?: () => void;
isAdvancedActive?: boolean;
isLoading?: boolean;
onDownloadsClick?: () => void;
onSettingsClick?: () => void;
@@ -58,6 +59,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
onSearchChange,
onSearch,
onAdvancedToggle,
isAdvancedActive = false,
isLoading = false,
onDownloadsClick,
onSettingsClick,
@@ -153,9 +155,11 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
const saved = localStorage.getItem('preferred-theme') || 'auto';
applyTheme(saved);
// Remove preload class after initial theme is applied to enable transitions
// Remove preload class and inline theme-init styles now that the
// external CSS is loaded and React has mounted.
requestAnimationFrame(() => {
document.documentElement.classList.remove('preload');
document.getElementById('theme-init')?.remove();
});
}, []);
@@ -163,7 +167,9 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
if (localStorage.getItem('preferred-theme') === 'auto') {
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
const effective = e.matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', effective);
document.documentElement.style.colorScheme = effective;
}
};
mq.addEventListener('change', handler);
@@ -238,12 +244,11 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
}, [isDropdownOpen, isClosing]);
const applyTheme = (pref: string) => {
if (pref === 'auto') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
} else {
document.documentElement.setAttribute('data-theme', pref);
}
const effective = pref === 'auto'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: pref;
document.documentElement.setAttribute('data-theme', effective);
document.documentElement.style.colorScheme = effective;
};
const handleLogout = () => {
@@ -372,7 +377,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
<button
onClick={toggleDropdown}
className={`relative p-2 rounded-full hover-action transition-colors ${
isDropdownOpen ? 'bg-[var(--hover-action)]' : ''
isDropdownOpen ? 'bg-(--hover-action)' : ''
}`}
aria-label="User menu"
aria-expanded={isDropdownOpen}
@@ -394,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 border-[var(--bg)]"
className="absolute top-1 right-1 h-2 w-2 rounded-full bg-sky-500 border-hairline border-(--bg)"
title={`Downloading as ${formatActingAsUserName(actingAsUser)}`}
/>
)}
@@ -403,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 z-50 ${
className={`absolute right-0 mt-2 ${dropdownPanelWidthClass} rounded-lg shadow-lg border-hairline z-50 ${
isClosing ? 'animate-fade-out-up' : shouldAnimateIn ? 'animate-fade-in-down' : ''
}`}
style={{
@@ -545,7 +550,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
{/* User Footer */}
{authRequired && isAuthenticated && username && (
<div
className="border-t"
className="border-t-hairline"
style={{ borderColor: 'var(--border-muted)' }}
>
<div className="px-4 py-3 flex items-center gap-2.5">
@@ -576,7 +581,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
{isAdmin && onActingAsUserChange && (
<div
className="border-t px-4 py-3 space-y-2"
className="border-t-hairline px-4 py-3 space-y-2"
style={{ borderColor: 'var(--border-muted)' }}
>
<div className="text-xs font-medium uppercase tracking-wide opacity-70">
@@ -620,7 +625,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
return (
<header
className="w-full sticky top-0 z-40 backdrop-blur-sm"
className="w-full sticky top-0 z-40 backdrop-blur-xs"
style={{ background: 'var(--bg)', paddingTop: 'env(safe-area-inset-top)' }}
>
<div className="max-w-full mx-auto px-4 sm:px-6 lg:px-8 py-4">
@@ -635,7 +640,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
src={logoUrl}
onClick={onLogoClick}
alt="Logo"
className="h-10 w-10 flex-shrink-0 cursor-pointer lg:hidden"
className="h-10 w-10 shrink-0 cursor-pointer lg:hidden"
/>
)}
@@ -650,7 +655,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
src={logoUrl}
onClick={onLogoClick}
alt="Logo"
className="hidden lg:block h-12 w-12 flex-shrink-0 cursor-pointer"
className="hidden lg:block h-12 w-12 shrink-0 cursor-pointer"
/>
)}
<SearchBar
@@ -661,6 +666,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
onChange={handleSearchChange}
onSubmit={handleHeaderSearch}
onAdvancedToggle={onAdvancedToggle}
isAdvancedActive={isAdvancedActive}
isLoading={isLoading}
contentType={contentType}
onContentTypeChange={onContentTypeChange}

View File

@@ -2,6 +2,7 @@ import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { LoginCredentials } from '../types';
import { withBasePath } from '../utils/basePath';
import { buildOidcLoginUrl } from '../utils/authRedirect';
interface LoginFormProps {
onSubmit: (credentials: LoginCredentials) => void;
@@ -111,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 focus:outline-none 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-hairline 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)',
@@ -140,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 focus:outline-none 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-hairline 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)',
@@ -168,7 +169,7 @@ const PasswordLoginForm = ({
checked={rememberMe}
onChange={(event) => setRememberMe(event.target.checked)}
disabled={isLoading}
className="w-4 h-4 rounded focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed accent-sky-900"
className="w-4 h-4 rounded-sm focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed accent-sky-900"
style={{ borderColor: 'var(--border-color)' }}
/>
<label htmlFor="remember-me" className="ml-2 text-sm">
@@ -229,6 +230,7 @@ export const LoginForm = ({
const [showPasswordLogin, setShowPasswordLogin] = useState(false);
const [searchParams] = useSearchParams();
const oidcError = searchParams.get('oidc_error');
const oidcLoginUrl = buildOidcLoginUrl(searchParams.toString());
// Auto-expand password form if there's an error (likely from a password attempt)
useEffect(() => {
@@ -240,9 +242,9 @@ export const LoginForm = ({
// Auto-redirect to OIDC provider when enabled and no errors present
useEffect(() => {
if (oidcAutoRedirect && isOidc && !error && !oidcError) {
window.location.href = withBasePath('/api/auth/oidc/login');
window.location.href = oidcLoginUrl;
}
}, [oidcAutoRedirect, isOidc, error, oidcError]);
}, [oidcAutoRedirect, isOidc, error, oidcError, oidcLoginUrl]);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -272,7 +274,7 @@ export const LoginForm = ({
{isOidc ? (
<>
<a
href={withBasePath('/api/auth/oidc/login')}
href={oidcLoginUrl}
className="w-full py-2.5 px-4 rounded-lg font-medium text-white text-center transition-colors block bg-sky-700 hover:bg-sky-800"
>
{oidcButtonLabel || 'Sign in with OIDC'}
@@ -281,7 +283,7 @@ export const LoginForm = ({
{!hideLocalAuth && (
<>
<div className="flex items-center mt-5 mb-2">
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
<div className="flex-1 border-t-hairline" style={{ borderColor: 'var(--border-color)' }} />
<button
type="button"
onClick={() => setShowPasswordLogin((prev) => !prev)}
@@ -289,7 +291,7 @@ export const LoginForm = ({
>
{showPasswordLogin ? 'Hide' : 'Use password'}
</button>
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
<div className="flex-1 border-t-hairline" style={{ borderColor: 'var(--border-color)' }} />
</div>
{showPasswordLogin && (

View File

@@ -88,25 +88,25 @@ export const OnBehalfConfirmationModal = ({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
/>
<div
className={`relative w-full max-w-lg rounded-xl border border-[var(--border-muted)] shadow-2xl ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
className={`relative w-full max-w-lg rounded-xl border-hairline 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 border-[var(--border-muted)] px-6 py-4">
<header className="flex items-center justify-between border-b-hairline border-(--border-muted) px-6 py-4">
<h3 id={titleId} className="text-lg font-semibold">
Download as {actingAsName}?
</h3>
<button
type="button"
onClick={handleClose}
className="p-1.5 rounded-lg hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="p-1.5 rounded-lg hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Close download confirmation"
disabled={isSubmitting}
>
@@ -120,18 +120,18 @@ export const OnBehalfConfirmationModal = ({
<p className="text-sm opacity-90">
This download will use {actingAsName}&apos;s output preferences and destination settings.
</p>
<div className="rounded-xl border border-[var(--border-muted)] bg-[var(--bg-soft)] px-4 py-3">
<div className="rounded-xl border-hairline 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 break-words">{itemTitle}</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 border-[var(--border-muted)] px-6 py-4">
<footer className="flex items-center justify-end gap-3 border-t-hairline 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-[var(--bg-soft)] border border-[var(--border-muted)] hover:bg-[var(--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-hairline border-(--border-muted) hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>

View File

@@ -331,7 +331,7 @@ export const OnboardingModal = ({
if (isLoading) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
<div className="absolute inset-0 bg-black/50 backdrop-blur-xs" />
<div
className="relative rounded-xl p-8 shadow-2xl"
style={{ background: 'var(--bg)' }}
@@ -364,7 +364,7 @@ export const OnboardingModal = ({
if (error) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={handleClose} />
<div className="absolute inset-0 bg-black/50 backdrop-blur-xs" onClick={handleClose} />
<div
className="relative rounded-xl p-8 shadow-2xl max-w-md"
style={{ background: 'var(--bg)' }}
@@ -390,8 +390,8 @@ export const OnboardingModal = ({
<button
onClick={handleClose}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
bg-(--bg-soft) border-hairline border-(--border-muted)
hover:bg-(--hover-surface) transition-colors"
>
Close
</button>
@@ -409,14 +409,14 @@ export const OnboardingModal = ({
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150
${isClosing ? 'opacity-0' : 'opacity-100'}`}
/>
{/* Modal */}
<div
className={`relative w-full max-w-xl rounded-xl
border border-[var(--border-muted)] shadow-2xl
border-hairline 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 border-[var(--border-muted)]">
<div className="flex items-center justify-between px-6 py-4 border-b-hairline 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}
@@ -438,7 +438,7 @@ export const OnboardingModal = ({
</div>
<button
onClick={handleClose}
className="p-1.5 rounded-lg hover:bg-[var(--hover-surface)] transition-colors"
className="p-1.5 rounded-lg hover:bg-(--hover-surface) transition-colors"
aria-label="Close"
>
<svg
@@ -455,7 +455,7 @@ export const OnboardingModal = ({
</div>
{/* Progress bar */}
<div className="h-1 bg-[var(--bg-soft)]">
<div className="h-1 bg-(--bg-soft)">
<div
className="h-full bg-sky-500 transition-all duration-300"
style={{ width: `${progress}%` }}
@@ -481,7 +481,7 @@ export const OnboardingModal = ({
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-[var(--border-muted)] flex items-center justify-between h-[68px]">
<div className="px-6 py-4 border-t-hairline border-(--border-muted) flex items-center justify-between h-[68px]">
<div>
<button
onClick={handleSkip}
@@ -499,8 +499,8 @@ export const OnboardingModal = ({
onClick={handleBack}
disabled={isSaving}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors
bg-(--bg-soft) border-hairline border-(--border-muted)
hover:bg-(--hover-surface) transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
>
Back

View File

@@ -245,7 +245,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
<div className="flex flex-col gap-1 max-w-xs">
{rows.map((row) => (
<div key={row.label} className="flex gap-2">
<span className="text-gray-400 dark:text-gray-500 flex-shrink-0">{row.label}:</span>
<span className="text-gray-400 dark:text-gray-500 shrink-0">{row.label}:</span>
<span className="truncate">{row.value}</span>
</div>
))}
@@ -324,7 +324,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
return (
<span className="inline-flex items-center gap-1">
<span
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${dotColor}`}
className={`w-1.5 h-1.5 rounded-full shrink-0 ${dotColor}`}
title={protocolLabel}
/>
{displayValue}
@@ -335,11 +335,11 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
return (
<div className={`flex items-center ${alignClass} text-xs text-gray-600 dark:text-gray-300 truncate gap-1.5`}>
<span
className={`w-2 h-2 rounded-full flex-shrink-0 ${dotColor}`}
className={`w-2 h-2 rounded-full shrink-0 ${dotColor}`}
title={protocolLabel}
/>
<span className="truncate">{displayValue}</span>
{peers && <span className="text-gray-400 dark:text-gray-500 flex-shrink-0">{peers}</span>}
{peers && <span className="text-gray-400 dark:text-gray-500 shrink-0">{peers}</span>}
</div>
);
}
@@ -403,12 +403,12 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
// Icon sized to match visual height of format text badges
const icon = isAudiobook ? (
// Headphones icon for audiobook
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
</svg>
) : (
// Book icon for ebook
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
</svg>
);
@@ -433,7 +433,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
if (!primaryFormat) {
return (
<div className="flex items-center justify-start" title={isAudiobook ? 'Audiobook' : 'Book'}>
<span className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold py-0.5 rounded-lg inline-flex items-center justify-center w-[3.25rem]`}>
<span className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold py-0.5 rounded-lg inline-flex items-center justify-center w-13`}>
{icon}
</span>
</div>
@@ -444,7 +444,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
const formatBadge = (
<span className="inline-flex items-center gap-1">
<span
className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold py-0.5 rounded-lg tracking-wide whitespace-nowrap w-[3.25rem] text-center`}
className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold py-0.5 rounded-lg tracking-wide whitespace-nowrap w-13 text-center`}
>
{column.uppercase ? primaryFormat.toUpperCase() : primaryFormat}
</span>
@@ -492,7 +492,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
return (
<span className="inline-flex items-center gap-1">
<span
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${isOnline ? 'bg-emerald-500' : 'bg-gray-400'}`}
className={`w-1.5 h-1.5 rounded-full shrink-0 ${isOnline ? 'bg-emerald-500' : 'bg-gray-400'}`}
title={isOnline ? 'Online' : 'Offline'}
/>
{displayValue}
@@ -506,7 +506,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
<div className={`flex items-center ${alignClass} text-xs text-gray-600 dark:text-gray-300 truncate`}>
{isServerColumn && (
<span
className={`w-2 h-2 rounded-full mr-1.5 flex-shrink-0 ${isOnline ? 'bg-emerald-500' : 'bg-gray-400'}`}
className={`w-2 h-2 rounded-full mr-1.5 shrink-0 ${isOnline ? 'bg-emerald-500' : 'bg-gray-400'}`}
title={isOnline ? 'Online' : 'Offline'}
/>
)}

View File

@@ -115,7 +115,7 @@ function StarRating({ rating, maxRating = 5 }: { rating: number; maxRating?: num
<div key={index} className="relative w-4 h-4">
{/* Empty star (gray background) */}
<svg
className="absolute inset-0 w-4 h-4 text-gray-300 dark:text-gray-600"
className="absolute inset-0 w-4 h-4 text-zinc-300 dark:text-zinc-600"
fill="currentColor"
viewBox="0 0 20 20"
>
@@ -145,7 +145,7 @@ const ReleaseThumbnail = ({ preview, title }: { preview?: string; title?: string
if (!preview || imageError) {
return (
<div
className="w-7 h-10 sm:w-8 sm:h-12 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[7px] sm:text-[8px] font-medium text-gray-500 dark:text-gray-400 flex-shrink-0"
className="w-7 h-10 sm:w-8 sm:h-12 rounded-sm bg-zinc-200 dark:bg-zinc-700 flex items-center justify-center text-[7px] sm:text-[8px] font-medium text-zinc-500 dark:text-zinc-400 shrink-0"
aria-label="No cover available"
>
No Cover
@@ -154,9 +154,9 @@ const ReleaseThumbnail = ({ preview, title }: { preview?: string; title?: string
}
return (
<div className="relative w-7 h-10 sm:w-8 sm:h-12 rounded overflow-hidden bg-gray-100 dark:bg-gray-800 border border-white/40 dark:border-gray-700/70 flex-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-hairline border-white/40 dark:border-zinc-700/70 shrink-0">
{!imageLoaded && (
<div className="absolute inset-0 bg-gradient-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" />
<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" />
)}
<img
src={preview}
@@ -201,7 +201,7 @@ const LeadingCell = ({
return (
<div
className={`w-7 h-10 sm:w-8 sm:h-12 rounded-lg ${colorStyle.bg} flex items-center justify-center flex-shrink-0`}
className={`w-7 h-10 sm:w-8 sm:h-12 rounded-lg ${colorStyle.bg} flex items-center justify-center shrink-0`}
>
<span className={`text-[8px] sm:text-[9px] font-bold ${colorStyle.text} text-center leading-tight px-0.5`}>
{text}
@@ -286,7 +286,7 @@ const ReleaseRow = ({
)}
</p>
{author && (
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
<p className="text-xs text-zinc-500 dark:text-zinc-400 truncate">
{author}
</p>
)}
@@ -332,12 +332,12 @@ const ReleaseRow = ({
<span className="font-medium">{release.title}</span>
)}
{author && (
<span className="text-gray-500 dark:text-gray-400 font-normal"> {author}</span>
<span className="text-zinc-500 dark:text-zinc-400 font-normal"> {author}</span>
)}
</p>
{/* Plugin-provided info line (format, size, indexer, seeders, etc.) */}
{mobileColumns.length > 0 && (
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-zinc-500 dark:text-zinc-400">
{(() => {
// Pre-filter columns that will render content to avoid orphan dots
const columnsWithContent = mobileColumns.filter((col) => {
@@ -365,7 +365,7 @@ const ReleaseRow = ({
});
return columnsWithContent.map((col, idx) => (
<span key={col.key} className="flex items-center gap-1.5">
{idx > 0 && <span className="text-gray-300 dark:text-gray-600">·</span>}
{idx > 0 && <span className="text-zinc-300 dark:text-zinc-600">·</span>}
<ReleaseCell column={col} release={release} compact onlineServers={onlineServers} />
</span>
));
@@ -390,7 +390,7 @@ const ReleaseRow = ({
function ShimmerBlock({ className }: { className: string }) {
return (
<div
className={`rounded bg-gray-200 dark:bg-gray-800 relative overflow-hidden ${className}`}
className={`rounded-sm bg-zinc-200 dark:bg-zinc-800 relative overflow-hidden ${className}`}
>
<div
className="absolute inset-0 dark:opacity-50"
@@ -411,7 +411,7 @@ function ReleaseSkeleton() {
const rows = 8;
return (
<div
className="divide-y divide-gray-200/60 dark:divide-gray-800/60 overflow-hidden"
className="divide-y divide-zinc-200/60 dark:divide-zinc-800/60 overflow-hidden"
style={{
maskImage: 'linear-gradient(to bottom, black 40%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(to bottom, black 40%, transparent 100%)',
@@ -453,7 +453,7 @@ function ReleaseSkeleton() {
</div>
{/* Action button skeleton */}
<ShimmerBlock className="w-8 h-8 !rounded-full" />
<ShimmerBlock className="w-8 h-8 rounded-full!" />
</div>
</div>
</div>
@@ -466,9 +466,9 @@ function ReleaseSkeleton() {
function EmptyState({ message }: { message: string }) {
return (
<div className="text-center py-12 px-4">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-gray-100 dark:bg-gray-800 mb-4">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-zinc-100 dark:bg-zinc-800 mb-4">
<svg
className="w-7 h-7 text-gray-400"
className="w-7 h-7 text-zinc-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -481,7 +481,7 @@ function EmptyState({ message }: { message: string }) {
/>
</svg>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">{message}</p>
<p className="text-sm text-zinc-500 dark:text-zinc-400">{message}</p>
</div>
);
}
@@ -505,10 +505,10 @@ function ErrorState({ message }: { message: string }) {
/>
</svg>
</div>
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-2">
<h4 className="text-base font-semibold text-zinc-900 dark:text-zinc-100 mb-2">
Error Loading Releases
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-xs mx-auto">{message}</p>
<p className="text-sm text-zinc-500 dark:text-zinc-400 max-w-xs mx-auto">{message}</p>
</div>
);
}
@@ -1292,18 +1292,18 @@ export const ReleaseModal = ({
}}
>
<div
className={`details-container w-full max-w-3xl h-full sm:h-auto ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
className={`details-container w-full h-full sm:h-auto ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
role="dialog"
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 border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)] text-[var(--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-hairline 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 border-[var(--border-muted)] px-5 py-4">
<header className="flex items-start gap-3 border-b-hairline border-(--border-muted) px-5 py-4">
{/* Animated thumbnail that appears when scrolling */}
{!isRequestMode && (
<div
className="flex-shrink-0 overflow-hidden transition-[width,margin] duration-300 ease-out"
className="shrink-0 overflow-hidden transition-[width,margin] duration-300 ease-out"
style={{
width: showHeaderThumb ? 46 : 0,
marginRight: showHeaderThumb ? 0 : -12,
@@ -1319,12 +1319,12 @@ export const ReleaseModal = ({
alt=""
width={46}
height={68}
className="rounded shadow-md object-cover object-top"
className="rounded-sm shadow-md object-cover object-top"
style={{ width: 46, height: 68, minWidth: 46 }}
/>
) : (
<div
className="rounded border border-dashed border-[var(--border-muted)] bg-[var(--bg)]/60 flex items-center justify-center text-[7px] text-gray-500"
className="rounded-sm border-hairline 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
@@ -1334,23 +1334,23 @@ export const ReleaseModal = ({
</div>
)}
<div className="flex-1 space-y-1 min-w-0">
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
<p className="text-xs uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Find Releases
</p>
<h3 id={titleId} className="text-lg font-semibold leading-snug truncate">
{book.provider === 'manual' ? 'Manual Query' : (book.title || 'Untitled')}
</h3>
{!isRequestMode && (
<p className="text-sm text-gray-600 dark:text-gray-300 truncate">
<p className="text-sm text-zinc-600 dark:text-zinc-300 truncate">
{book.author || 'Unknown author'}
</p>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={handleClose}
className="rounded-full p-2 text-gray-500 transition-colors hover-action hover:text-gray-900 dark:hover:text-gray-100"
className="rounded-full p-2 text-zinc-500 transition-colors hover-action hover:text-zinc-900 dark:hover:text-zinc-100"
aria-label="Close"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
@@ -1364,34 +1364,34 @@ 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 border-[var(--border-muted)]">
<div ref={bookSummaryRef} className="flex gap-4 px-5 py-4 border-b-hairline border-(--border-muted)">
{book.preview ? (
<img
src={book.preview}
alt="Book cover"
className={`rounded-lg shadow-md object-cover object-top flex-shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}
className={`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 border-dashed border-[var(--border-muted)] bg-[var(--bg)]/60 flex items-center justify-center text-[10px] text-gray-500 flex-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]'}`}>
No cover
</div>
)}
<div className="flex-1 min-w-0 space-y-2">
{/* Metadata row */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-600 dark:text-gray-400">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-zinc-600 dark:text-zinc-400">
{book.year && <span>{book.year}</span>}
{displayFields?.starField && (
<span className="flex items-center gap-1.5">
<StarRating rating={parseFloat(displayFields.starField.value || '0')} />
<span>{displayFields.starField.value}</span>
{displayFields.ratingsField && (
<span className="text-gray-400 dark:text-gray-500">({displayFields.ratingsField.value})</span>
<span className="text-zinc-400 dark:text-zinc-500">({displayFields.ratingsField.value})</span>
)}
</span>
)}
{displayFields?.usersField && (
<span className="flex items-center gap-1">
<svg className="h-3.5 w-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<svg className="h-3.5 w-3.5 text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
</svg>
{displayFields.usersField.value} readers
@@ -1404,7 +1404,7 @@ export const ReleaseModal = ({
{/* Series info */}
{book.series_name && (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400">
<span>
{book.series_position != null ? (
<>#{Number.isInteger(book.series_position) ? book.series_position : book.series_position}{book.series_count ? ` of ${book.series_count}` : ''} in {book.series_name}</>
@@ -1432,7 +1432,7 @@ export const ReleaseModal = ({
{/* Description */}
{book.description && (
<div className="text-sm text-gray-600 dark:text-gray-400 relative">
<div className="text-sm text-zinc-600 dark:text-zinc-400 relative">
<p ref={descriptionRef} className={descriptionExpanded ? '' : 'line-clamp-3'}>
{book.description}
{descriptionExpanded && descriptionOverflows && (
@@ -1452,7 +1452,7 @@ export const ReleaseModal = ({
<button
type="button"
onClick={() => setDescriptionExpanded(true)}
className="absolute bottom-0 right-0 text-emerald-600 dark:text-emerald-400 hover:underline font-medium pl-8 bg-gradient-to-r from-transparent via-[var(--bg)] to-[var(--bg)] sm:via-[var(--bg-soft)] sm:to-[var(--bg-soft)]"
className="absolute bottom-0 right-0 text-emerald-600 dark:text-emerald-400 hover:underline font-medium pl-8 bg-linear-to-r from-transparent via-(--bg) to-(--bg) sm:via-(--bg-soft) sm:to-(--bg-soft)"
>
more
</button>
@@ -1463,7 +1463,7 @@ export const ReleaseModal = ({
{/* Links row */}
<div className="flex flex-wrap items-center gap-3 text-xs">
{(book.isbn_13 || book.isbn_10) && (
<span className="text-gray-500 dark:text-gray-400">
<span className="text-zinc-500 dark:text-zinc-400">
ISBN: {book.isbn_13 || book.isbn_10}
</span>
)}
@@ -1509,13 +1509,13 @@ export const ReleaseModal = ({
)}
{/* Source tabs + filters - sticky within scroll container */}
<div className="sticky top-0 z-10 border-b border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)]">
<div className="sticky top-0 z-10 border-b-hairline 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-gray-200 dark:bg-gray-700 rounded" />
<div className="h-10 w-32 animate-pulse bg-zinc-200 dark:bg-zinc-700 rounded-sm" />
</div>
) : allTabs.length === 0 ? (
<div className="px-5 py-3 text-sm text-gray-500 dark:text-gray-400">
<div className="px-5 py-3 text-sm text-zinc-500 dark:text-zinc-400">
{sourcesError || 'No release sources are available for this book.'}
</div>
) : (
@@ -1538,7 +1538,7 @@ export const ReleaseModal = ({
onClick={() => setActiveTab(tab.name)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 border-transparent transition-colors whitespace-nowrap ${activeTab === tab.name
? 'text-emerald-600 dark:text-emerald-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
: 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200'
}`}
>
{tab.displayName}
@@ -1563,7 +1563,7 @@ export const ReleaseModal = ({
return next;
});
}}
className={`p-2.5 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${manualQuery.trim() ? 'text-emerald-600 dark:text-emerald-400' : ''
className={`p-2.5 rounded-full transition-colors hover-surface text-zinc-500 dark:text-zinc-400 ${manualQuery.trim() ? 'text-emerald-600 dark:text-emerald-400' : ''
}`}
aria-label="Manual search query"
title="Manual query"
@@ -1577,13 +1577,13 @@ export const ReleaseModal = ({
{(allSortOptions.length > 0 || availableFormats.length > 1) && (
<Dropdown
align="right"
widthClassName="w-auto flex-shrink-0"
widthClassName="w-auto shrink-0"
panelClassName="w-48"
renderTrigger={({ isOpen, toggle }) => (
<button
type="button"
onClick={toggle}
className={`relative p-2.5 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${isOpen ? 'bg-[var(--hover-surface)]' : ''
className={`relative p-2.5 rounded-full transition-colors hover-surface text-zinc-500 dark:text-zinc-400 ${isOpen ? 'bg-(--hover-surface)' : ''
}`}
aria-label="Sort releases"
>
@@ -1608,7 +1608,7 @@ export const ReleaseModal = ({
}}
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${!currentSort
? 'text-emerald-600 dark:text-emerald-400 font-medium'
: 'text-gray-700 dark:text-gray-300'
: 'text-zinc-700 dark:text-zinc-300'
}`}
>
<span>Best Match (Default)</span>
@@ -1633,7 +1633,7 @@ export const ReleaseModal = ({
}}
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${isSelected
? 'text-emerald-600 dark:text-emerald-400 font-medium'
: 'text-gray-700 dark:text-gray-300'
: 'text-zinc-700 dark:text-zinc-300'
}`}
>
<span>{opt.label}</span>
@@ -1654,7 +1654,7 @@ export const ReleaseModal = ({
{availableFormats.length > 1 && (
<>
{allSortOptions.length > 0 && (
<div className="mx-2 my-1 border-t border-gray-200 dark:border-gray-700" />
<div className="mx-2 my-1 border-t-hairline border-zinc-200 dark:border-zinc-700" />
)}
<button
type="button"
@@ -1662,7 +1662,7 @@ export const ReleaseModal = ({
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${
currentSort?.key === FORMAT_SORT_KEY
? 'text-emerald-600 dark:text-emerald-400 font-medium'
: 'text-gray-700 dark:text-gray-300'
: 'text-zinc-700 dark:text-zinc-300'
}`}
>
<span>
@@ -1692,7 +1692,7 @@ export const ReleaseModal = ({
className={`w-full pl-6 pr-3 py-1.5 text-left text-sm flex items-center justify-between hover-surface rounded ${
isSelected
? 'text-emerald-600 dark:text-emerald-400 font-medium'
: 'text-gray-700 dark:text-gray-300'
: 'text-zinc-700 dark:text-zinc-300'
}`}
>
<span>{fmt.toUpperCase()}</span>
@@ -1722,7 +1722,7 @@ export const ReleaseModal = ({
(columnConfig.supported_filters?.includes('indexer') && availableIndexers.length > 1)) && (
<Dropdown
align="right"
widthClassName="w-auto flex-shrink-0"
widthClassName="w-auto shrink-0"
panelClassName="w-56"
noScrollLimit
renderTrigger={({ isOpen, toggle }) => {
@@ -1744,8 +1744,8 @@ export const ReleaseModal = ({
<button
type="button"
onClick={toggle}
className={`relative p-2.5 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${
isOpen ? 'bg-[var(--hover-surface)]' : ''
className={`relative p-2.5 rounded-full transition-colors hover-surface text-zinc-500 dark:text-zinc-400 ${
isOpen ? 'bg-(--hover-surface)' : ''
}`}
aria-label="Filter releases"
>
@@ -1852,7 +1852,7 @@ export const ReleaseModal = ({
{/* Manual query panel (below source tabs) */}
{showManualQuery && (
<div className="px-5 py-3 border-b border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)]">
<div className="px-5 py-3 border-b-hairline border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft)">
<form
className="flex items-center gap-2"
onSubmit={async (e) => {
@@ -1907,7 +1907,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 border-[var(--border-muted)] bg-[var(--bg)] text-[var(--text)]"
className="w-full px-3 py-2 text-sm rounded-lg border-hairline border-(--border-muted) bg-(--bg) text-(--text)"
/>
<button
type="submit"
@@ -1920,7 +1920,7 @@ export const ReleaseModal = ({
{currentTabLoading ? 'Searching…' : 'Search'}
</button>
</form>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
Manual query overrides ISBN/title/author/language expansion.
</p>
</div>
@@ -1959,7 +1959,7 @@ export const ReleaseModal = ({
<button
type="button"
onClick={handleExpandSearch}
className="px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400 rounded-full hover-action transition-all duration-200"
className="px-3 py-1.5 text-sm text-zinc-500 dark:text-zinc-400 rounded-full hover-action transition-all duration-200"
>
{columnConfig.action_button?.label ?? 'Expand search'}
</button>
@@ -1969,7 +1969,7 @@ export const ReleaseModal = ({
) : (
<>
{/* Key includes filter to force remount when filter changes */}
<div key={`releases-${formatFilter}-${languageFilter.join(',')}`} className="divide-y divide-gray-200/60 dark:divide-gray-800/60">
<div key={`releases-${formatFilter}-${languageFilter.join(',')}`} className="divide-y divide-zinc-200/60 dark:divide-zinc-800/60">
{filteredReleases.map((release, index) => (
<ReleaseRow
key={`${release.source}-${release.source_id}`}
@@ -2002,7 +2002,7 @@ export const ReleaseModal = ({
<button
type="button"
onClick={handleExpandSearch}
className="px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400 rounded-full hover-action transition-all duration-200"
className="px-3 py-1.5 text-sm text-zinc-500 dark:text-zinc-400 rounded-full hover-action transition-all duration-200"
>
{columnConfig.action_button?.label ?? 'Expand search'}
</button>
@@ -2019,7 +2019,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-[var(--bg-soft)] border border-[var(--border-muted)] text-gray-500 dark:text-gray-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-hairline 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" />
)}

View File

@@ -134,25 +134,25 @@ export const RequestConfirmationModal = ({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
/>
<div
className={`relative w-full max-w-xl rounded-xl border border-[var(--border-muted)] shadow-2xl ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
className={`relative w-full max-w-xl rounded-xl border-hairline 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 border-[var(--border-muted)] px-6 py-4">
<header className="flex items-center justify-between border-b-hairline border-(--border-muted) px-6 py-4">
<h3 id={titleId} className="text-lg font-semibold">
Request Book
</h3>
<button
type="button"
onClick={handleClose}
className="p-1.5 rounded-lg hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="p-1.5 rounded-lg hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Close request confirmation"
disabled={isSubmitting}
>
@@ -163,9 +163,9 @@ export const RequestConfirmationModal = ({
</header>
<div className="space-y-4 px-6 py-5">
<div className="rounded-xl border border-[var(--border-muted)] bg-[var(--bg-soft)] px-4 py-4">
<div className="rounded-xl border-hairline border-(--border-muted) bg-(--bg-soft) px-4 py-4">
<div className="flex gap-4">
<div className="w-16 h-24 flex-shrink-0 rounded-lg overflow-hidden border border-[var(--border-muted)] bg-[var(--bg)]">
<div className="w-16 h-24 shrink-0 rounded-lg overflow-hidden border-hairline 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 border-[var(--border-muted)] bg-[var(--bg)] text-sm resize-y min-h-[96px] focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500"
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"
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 border-[var(--border-muted)] px-6 py-4">
<footer className="flex items-center justify-end gap-3 border-t-hairline 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-[var(--bg-soft)] border border-[var(--border-muted)] hover:bg-[var(--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-hairline border-(--border-muted) hover:bg-(--hover-surface) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>

View File

@@ -311,7 +311,7 @@ const SortControl = ({ value, onChange, metadataSortOptions }: SortControlProps)
type="button"
onClick={toggle}
className={`relative flex items-center gap-2 px-3 py-2 rounded-full transition-all duration-200 text-gray-900 dark:text-gray-100 hover-action ${
isOpen ? 'bg-[var(--hover-action)]' : ''
isOpen ? 'bg-(--hover-action)' : ''
} animate-pop-up`}
aria-haspopup="listbox"
aria-expanded={isOpen}

View File

@@ -26,6 +26,7 @@ interface SearchBarProps {
onSubmit: () => void;
isLoading?: boolean;
onAdvancedToggle?: () => void;
isAdvancedActive?: boolean;
placeholder?: string;
inputAriaLabel?: string;
className?: string;
@@ -33,8 +34,6 @@ interface SearchBarProps {
controlsClassName?: string;
clearButtonLabel?: string;
clearButtonTitle?: string;
advancedButtonLabel?: string;
advancedButtonTitle?: string;
searchButtonLabel?: string;
searchButtonTitle?: string;
autoComplete?: string;
@@ -100,6 +99,12 @@ const AudiobookIcon = () => (
</svg>
);
const CheckIcon = ({ className = 'w-3.5 h-3.5' }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth="2.5" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
);
const getDefaultPlaceholder = (
contentType: ContentType,
activeQueryTarget: QueryTargetOption | undefined,
@@ -147,6 +152,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
onSubmit,
isLoading = false,
onAdvancedToggle,
isAdvancedActive = false,
placeholder,
inputAriaLabel = 'Search books',
className = '',
@@ -154,8 +160,6 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
controlsClassName = '',
clearButtonLabel = 'Clear search input',
clearButtonTitle = 'Clear search',
advancedButtonLabel = 'Search settings',
advancedButtonTitle = 'Search settings',
searchButtonLabel = 'Search books',
searchButtonTitle = 'Search',
autoComplete = 'off',
@@ -197,7 +201,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
? 'pl-3 rounded-r-full'
: 'pl-4 rounded-full';
const searchInputClass = [
'w-full min-w-0 py-3 border-0 outline-none search-input bg-transparent',
'w-full min-w-0 py-3 border-0 outline-hidden search-input bg-transparent',
inputPaddingClass,
].join(' ');
@@ -218,15 +222,21 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
};
}, []);
// Auto-open select dropdown only when transitioning into a select field
const prevFieldKeyRef = useRef<string | undefined>(undefined);
// Auto-open select dropdown only when transitioning into a select field.
// Initialise the ref to the current key so that mounting with an already-
// active select field (e.g. the Header SearchBar after first search)
// doesn't count as a field change. Using `if (fieldChanged)` instead of
// always calling setIsSelectOpen avoids StrictMode's second effect
// invocation resetting the state set by the first.
const prevFieldKeyRef = useRef<string | undefined>(activeQueryField?.key);
useEffect(() => {
const fieldKey = activeQueryField?.key;
const isSelect = activeQueryField?.type === 'SelectSearchField' || activeQueryField?.type === 'DynamicSelectSearchField';
const fieldChanged = fieldKey !== prevFieldKeyRef.current;
prevFieldKeyRef.current = fieldKey;
const fieldChanged = activeQueryField?.key !== prevFieldKeyRef.current;
prevFieldKeyRef.current = activeQueryField?.key;
setIsSelectOpen(fieldChanged && isSelect);
if (fieldChanged) {
setIsSelectOpen(isSelect);
}
setIsAutocompleteOpen(false);
setAutocompleteOptions([]);
}, [activeQueryField?.key, activeQueryField?.type]);
@@ -404,9 +414,9 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
Boolean(autocompleteEndpoint)
&& isAutocompleteOpen
&& textInputValue.trim().length >= autocompleteMinQueryLength;
const wrapperClasses = ['relative flex items-center rounded-full border', className].filter(Boolean).join(' ').trim();
const wrapperClasses = ['relative flex items-center rounded-full border-hairline', className].filter(Boolean).join(' ').trim();
const controlsClasses = [
'flex items-center gap-1 pr-2 flex-shrink-0',
'flex items-center gap-1 pr-2 shrink-0',
controlsClassName,
]
.filter(Boolean)
@@ -533,7 +543,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
<span className="opacity-50 truncate">{effectivePlaceholder}</span>
)}
<svg
className={`w-3.5 h-3.5 opacity-40 flex-shrink-0 transition-transform duration-200 ${isSelectOpen ? 'rotate-180' : ''}`}
className={`w-3.5 h-3.5 opacity-40 shrink-0 transition-transform duration-200 ${isSelectOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -553,7 +563,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
type="checkbox"
checked={Boolean(value)}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 rounded border-[var(--border-muted)] text-emerald-500 focus:ring-emerald-500/50"
className="h-4 w-4 rounded-sm border-(--border-muted) text-emerald-500 focus:ring-emerald-500/50"
/>
<span className="truncate text-sm" style={{ color: 'var(--text)' }}>
{activeQueryField.label}
@@ -576,9 +586,10 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
>
{showQueryTargetSelector && (
<div
className="relative flex-shrink-0 flex self-stretch"
className="relative shrink-0 flex self-stretch"
ref={selectorRef}
onMouseEnter={() => {
onPointerEnter={(e) => {
if (e.pointerType !== 'mouse') return;
if (selectorHoverTimeout.current) {
clearTimeout(selectorHoverTimeout.current);
selectorHoverTimeout.current = null;
@@ -587,7 +598,8 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
setIsSelectOpen(false);
setIsAutocompleteOpen(false);
}}
onMouseLeave={() => {
onPointerLeave={(e) => {
if (e.pointerType !== 'mouse') return;
selectorHoverTimeout.current = setTimeout(() => {
setIsSelectorOpen(false);
selectorHoverTimeout.current = null;
@@ -628,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 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-hairline shadow-2xl animate-fade-in-down"
style={{
background: 'var(--bg)',
borderColor: 'var(--border-muted)',
@@ -638,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 pb-3" style={{ borderColor: 'var(--border-muted)' }}>
<div className="border-b-hairline pb-3" style={{ borderColor: 'var(--border-muted)' }}>
<div className="px-1 pb-2 text-xs font-medium uppercase tracking-wide opacity-60">
Content
</div>
@@ -646,27 +658,27 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
<button
type="button"
onClick={() => handleContentTypeSelect('ebook')}
className={`flex items-center gap-2 rounded-xl border px-3 py-2.5 text-sm font-medium transition-colors ${
className={`flex items-center gap-2 rounded-xl border-hairline px-3 py-2.5 text-sm font-medium transition-colors ${
contentType === 'ebook' ? 'bg-emerald-600 text-white' : 'hover-surface'
}`}
style={contentType !== 'ebook'
? { color: 'var(--text)', borderColor: 'var(--border-muted)' }
: { borderColor: 'rgb(16 185 129 / 0.7)' }}
>
<BookIcon />
{contentType === 'ebook' ? <CheckIcon /> : <BookIcon />}
<span>Books</span>
</button>
<button
type="button"
onClick={() => handleContentTypeSelect('audiobook')}
className={`flex items-center gap-2 rounded-xl border px-3 py-2.5 text-sm font-medium transition-colors ${
className={`flex items-center gap-2 rounded-xl border-hairline px-3 py-2.5 text-sm font-medium transition-colors ${
contentType === 'audiobook' ? 'bg-emerald-600 text-white' : 'hover-surface'
}`}
style={contentType !== 'audiobook'
? { color: 'var(--text)', borderColor: 'var(--border-muted)' }
: { borderColor: 'rgb(16 185 129 / 0.7)' }}
>
<AudiobookIcon />
{contentType === 'audiobook' ? <CheckIcon /> : <AudiobookIcon />}
<span>Audiobooks</span>
</button>
</div>
@@ -687,19 +699,61 @@ 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 px-3 py-2.5 text-left text-sm font-medium transition-colors ${
className={`min-w-0 rounded-xl border-hairline 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
? { borderColor: searchMode === 'direct' ? 'rgb(3 105 161 / 0.7)' : 'rgb(16 185 129 / 0.7)' }
: { color: 'var(--text)', borderColor: 'var(--border-muted)' }}
>
{isActive && <CheckIcon />}
<span className="block truncate">{target.label}</span>
</button>
);
})}
</div>
</div>
{onAdvancedToggle && (
<div className="border-t-hairline pt-3 mt-3" style={{ borderColor: 'var(--border-muted)' }}>
<button
type="button"
onClick={() => {
setIsSelectorOpen(false);
onAdvancedToggle();
}}
className={`w-full px-3 py-2.5 text-sm font-medium rounded-xl transition-colors flex items-center justify-center gap-2 ${
isAdvancedActive
? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white`
: 'hover-surface'
}`}
style={isAdvancedActive
? { borderColor: searchMode === 'direct' ? 'rgb(3 105 161 / 0.7)' : 'rgb(16 185 129 / 0.7)' }
: { color: 'var(--text-muted)' }}
>
{isAdvancedActive ? (
<CheckIcon />
) : (
<svg
className="w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
/>
</svg>
)}
Options & Filters
</button>
</div>
)}
</div>
</div>
)}
@@ -733,32 +787,6 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
</svg>
</button>
)}
{onAdvancedToggle && (
<button
type="button"
onClick={onAdvancedToggle}
className="p-2 rounded-full hover-action flex items-center justify-center transition-colors"
aria-label={advancedButtonLabel}
title={advancedButtonTitle}
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
style={{ color: 'var(--text)' }}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
/>
</svg>
</button>
)}
<button
ref={buttonRef}
type="button"
@@ -790,7 +818,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
</svg>
)}
{isLoading && (
<div className="spinner w-3 h-3 border-2 border-white border-t-transparent search-bar-spinner" />
<div className="spinner w-5 h-5 border-2 border-white border-t-transparent search-bar-spinner" />
)}
</button>
</div>
@@ -798,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 shadow-xl overflow-hidden animate-fade-in-down"
className="absolute top-full left-0 right-0 z-50 mt-2 rounded-2xl border-hairline shadow-xl overflow-hidden animate-fade-in-down"
style={{ background: 'var(--bg)', borderColor: 'var(--border-muted)' }}
role="listbox"
aria-label={effectiveInputAriaLabel}
@@ -827,7 +855,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
{option.label}
</span>
{isSelected && (
<svg className="w-4 h-4 text-emerald-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="2.5" stroke="currentColor" aria-hidden="true">
<svg className="w-4 h-4 text-emerald-500 shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="2.5" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
)}
@@ -841,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 shadow-xl overflow-hidden animate-fade-in-down"
className="absolute top-full left-0 right-0 z-50 mt-2 rounded-2xl border-hairline shadow-xl overflow-hidden animate-fade-in-down"
style={{ background: 'var(--bg)', borderColor: 'var(--border-muted)' }}
role="listbox"
aria-label={`${effectiveInputAriaLabel} suggestions`}

View File

@@ -92,6 +92,7 @@ export const SearchSection = ({
onSubmit={onSearch}
isLoading={isLoading}
onAdvancedToggle={onAdvancedToggle}
isAdvancedActive={showAdvanced}
contentType={contentType}
onContentTypeChange={onContentTypeChange}
allowedContentTypes={allowedContentTypes}
@@ -100,6 +101,11 @@ export const SearchSection = ({
onQueryTargetChange={onQueryTargetChange}
activeQueryField={activeQueryField}
/>
{activeQueryTarget === 'manual' && (
<p className="text-xs opacity-50 px-2">
Manual search queries release sources directly. Some sources may return limited metadata, which can affect file naming templates.
</p>
)}
<AdvancedFilters
visible={showAdvanced}
bookLanguages={bookLanguages}
@@ -115,6 +121,7 @@ export const SearchSection = ({
onMetadataProviderChange={onMetadataProviderChange}
contentType={contentType}
isAdmin={isAdmin}
onClose={onAdvancedToggle}
/>
</div>
</section>

View File

@@ -25,7 +25,7 @@ export const ToastContainer = ({ toasts }: ToastContainerProps) => {
};
return (
<div id="toast-container" className="fixed bottom-4 right-4 z-[1100] space-y-2">
<div id="toast-container" className="fixed bottom-4 right-4 z-1100 space-y-2">
{toasts.map(toast => (
<div
key={toast.id}

View File

@@ -43,7 +43,7 @@ interface ActivityCardProps {
}
const BookFallback = () => (
<div className="w-12 h-[4.5rem] rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] font-medium text-gray-500 dark:text-gray-400">
<div className="w-12 h-18 rounded-sm bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] font-medium text-gray-500 dark:text-gray-400">
No Cover
</div>
);
@@ -233,7 +233,7 @@ const hasAttachedReleaseData = (record: RequestRecord): boolean => {
const DetailField = ({ label, value }: { label: string; value: string }) => (
<div className="py-1">
<p className="text-[10px] uppercase tracking-wide opacity-60">{label}</p>
<p className="text-xs font-medium break-words mt-0.5">{value}</p>
<p className="text-xs font-medium wrap-break-word mt-0.5">{value}</p>
</div>
);
@@ -507,7 +507,7 @@ export const ActivityCard = ({
const requestType = reviewRecord?.content_type === 'audiobook' ? 'Audiobook' : 'Book';
const titleAuthorLine = item.author ? `${item.title}${item.author}` : item.title;
const titleLineClassName = isDetailsExpanded
? 'text-sm leading-tight min-w-0 whitespace-normal break-words'
? 'text-sm leading-tight min-w-0 whitespace-normal wrap-break-word'
: 'text-sm truncate leading-tight min-w-0';
const canShowDownloadLink =
@@ -558,7 +558,7 @@ export const ActivityCard = ({
)}
<div className="flex gap-3 items-start">
{/* Artwork */}
<div className="w-12 h-[4.5rem] rounded flex-shrink-0 overflow-hidden bg-gray-200 dark:bg-gray-700">
<div className="w-12 h-18 rounded-sm shrink-0 overflow-hidden bg-gray-200 dark:bg-gray-700">
{item.preview ? (
<img
src={item.preview}
@@ -587,7 +587,7 @@ export const ActivityCard = ({
</p>
</Tooltip>
</div>
<div className="flex-shrink-0 inline-flex items-center gap-1 -my-1">
<div className="shrink-0 inline-flex items-center gap-1 -my-1">
{actions.map((action) => {
const config = actionUiConfig(action);
const icon =
@@ -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 border-[var(--border-muted)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50"
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"
>
{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 border-[var(--border-muted)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50"
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"
>
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 border-[var(--border-muted)] bg-[var(--bg)] text-xs resize-y min-h-[72px] focus:outline-none focus:ring-2 focus:ring-red-500/30 focus:border-red-500"
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"
disabled={isRejectSubmitting}
/>
<div className="flex items-center justify-between">
@@ -786,7 +786,7 @@ export const ActivityCard = ({
type="button"
onClick={onRequestRejectClose}
disabled={isRejectSubmitting}
className="px-2.5 py-1.5 rounded-md text-xs hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50"
className="px-2.5 py-1.5 rounded-md text-xs hover:bg-(--hover-surface) transition-colors disabled:opacity-50"
>
Cancel
</button>

View File

@@ -657,7 +657,7 @@ export const ActivitySidebar = ({
<Dropdown
align="right"
widthClassName="w-auto"
panelClassName="min-w-[11rem]"
panelClassName="min-w-44"
renderTrigger={({ isOpen, toggle }) => (
<button
type="button"
@@ -735,7 +735,7 @@ export const ActivitySidebar = ({
</div>
{activeTab !== 'history' && (
<div className="mt-2 border-b border-[var(--border-muted)] -mx-4 px-4">
<div className="mt-2 border-b-hairline 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 flex items-center justify-center"
className="p-3 border-t-hairline 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 flex items-center justify-center"
className="p-3 border-t-hairline flex items-center justify-center"
style={{
borderColor: 'var(--border-muted)',
paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))',
@@ -1011,7 +1011,7 @@ export const ActivitySidebar = ({
return (
<aside
className="hidden lg:flex fixed right-0 w-96 flex-col bg-[var(--bg-soft)] z-30 rounded-2xl shadow-lg overflow-hidden"
className="hidden lg:flex fixed right-0 w-96 flex-col bg-(--bg-soft) z-30 rounded-2xl shadow-lg overflow-hidden"
style={{
top: `${pinnedTopOffset}px`,
height: `calc(100dvh - ${pinnedTopOffset}px - 0.75rem)`,
@@ -1028,7 +1028,7 @@ export const ActivitySidebar = ({
return (
<>
<div
className={`fixed inset-0 bg-black/50 z-[45] transition-opacity duration-300 ${
className={`fixed inset-0 bg-black/50 z-45 transition-opacity duration-300 ${
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
onClick={onClose}

View File

@@ -7,7 +7,7 @@ import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { DisplayFieldBadges } from '../shared';
const SkeletonLoader = () => (
<div className="w-full h-full bg-gradient-to-r from-gray-300 via-gray-200 to-gray-300 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
<div className="w-full h-full bg-linear-to-r from-gray-300 via-gray-200 to-gray-300 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
);
interface CardViewProps {
@@ -62,12 +62,12 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative w-full sm:w-full max-sm:w-[120px] max-sm:h-full max-sm:flex-shrink-0" style={{ aspectRatio: '2/3' }}>
<div className="relative w-full sm:w-full max-sm:w-[120px] max-sm:h-full max-sm:shrink-0" style={{ aspectRatio: '2/3' }}>
<div className="absolute inset-0 overflow-hidden sm:rounded-t-[.75rem] max-sm:rounded-l-[.75rem]">
{/* Series position badge */}
{showSeriesPosition && book.series_position != null && (
<div
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border border-emerald-700"
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"
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)',
@@ -122,12 +122,12 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
bookId={book.provider_id!}
onShowToast={onShowToast}
variant="icon"
className="w-8 h-8 bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm shadow-lg hover:scale-110"
className="w-8 h-8 bg-white/90 dark:bg-neutral-800/90 backdrop-blur-xs shadow-lg hover:scale-110"
onOpenChange={setDropdownOpen}
/>
)}
<button
className="w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
className="w-8 h-8 rounded-full bg-white/90 dark:bg-neutral-800/90 backdrop-blur-xs flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
onClick={(e) => {
e.stopPropagation();
handleDetails(book.id);
@@ -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 border text-xs flex-1 flex items-center justify-center gap-1"
className="px-2 py-1.5 rounded-sm border-hairline text-xs flex-1 flex items-center justify-center gap-1"
onClick={() => handleDetails(book.id)}
style={{ borderColor: 'var(--border-muted)' }}
disabled={isLoadingDetails}

View File

@@ -7,7 +7,7 @@ import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { DisplayFieldBadges } from '../shared';
const SkeletonLoader = () => (
<div className="w-full h-full bg-gradient-to-r from-gray-300 via-gray-200 to-gray-300 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
<div className="w-full h-full bg-linear-to-r from-gray-300 via-gray-200 to-gray-300 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
);
interface CompactViewProps {
@@ -51,7 +51,7 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
return (
<article
className="book-card !flex !flex-row w-full !h-[180px] transition-shadow duration-300 animate-pop-up will-change-transform relative"
className="book-card flex! flex-row! w-full h-[180px]! transition-shadow duration-300 animate-pop-up will-change-transform relative"
style={{
background: 'var(--bg-soft)',
borderRadius: '.75rem',
@@ -63,12 +63,12 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative w-[120px] h-full flex-shrink-0">
<div className="relative w-[120px] h-full shrink-0">
<div className="absolute inset-0 overflow-hidden rounded-l-[.75rem]">
{/* Series position badge */}
{showSeriesPosition && book.series_position != null && (
<div
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border border-emerald-700"
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"
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)',
@@ -121,12 +121,12 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
bookId={book.provider_id!}
onShowToast={onShowToast}
variant="icon"
className="w-8 h-8 bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm shadow-lg hover:scale-110"
className="w-8 h-8 bg-white/90 dark:bg-neutral-800/90 backdrop-blur-xs shadow-lg hover:scale-110"
onOpenChange={setDropdownOpen}
/>
)}
<button
className="w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
className="w-8 h-8 rounded-full bg-white/90 dark:bg-neutral-800/90 backdrop-blur-xs flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
onClick={(e) => {
e.stopPropagation();
handleDetails(book.id);
@@ -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 border text-xs flex-shrink-0 flex items-center justify-center gap-1"
className="px-2 py-1.5 rounded-sm border-hairline text-xs shrink-0 flex items-center justify-center gap-1"
onClick={() => handleDetails(book.id)}
style={{ borderColor: 'var(--border-muted)' }}
disabled={isLoadingDetails}

View File

@@ -25,7 +25,7 @@ const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: strin
if (!preview || imageError) {
return (
<div
className="w-7 h-10 sm:w-10 sm:h-14 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] sm:text-[9px] font-medium text-gray-500 dark:text-gray-300"
className="w-7 h-10 sm:w-10 sm:h-14 rounded-sm bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] sm:text-[9px] font-medium text-gray-500 dark:text-gray-300"
aria-label="No cover available"
>
No Cover
@@ -34,9 +34,9 @@ const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: strin
}
return (
<div className="relative w-7 h-10 sm:w-10 sm:h-14 rounded overflow-hidden bg-gray-100 dark:bg-gray-800 border 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-hairline border-white/40 dark:border-gray-700/70">
{!imageLoaded && (
<div className="absolute inset-0 bg-gradient-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" />
<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" />
)}
<img
src={preview}
@@ -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 border border-emerald-700 flex-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-hairline 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)',

View File

@@ -247,18 +247,18 @@ export const SelfSettingsModal = ({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
/>
<div
className={`relative w-full max-w-3xl h-[85vh] max-h-[750px] rounded-xl border border-[var(--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-hairline 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 border-[var(--border-muted)] px-6 py-4">
<header className="flex items-center justify-between border-b-hairline 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 border-[var(--border-muted)] bg-[var(--bg-soft)] hover:bg-[var(--hover-surface)] transition-colors"
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"
>
Retry
</button>
@@ -350,7 +350,7 @@ export const SelfSettingsModal = ({
)}
</div>
<footer className="flex items-center justify-end gap-3 border-t border-[var(--border-muted)] px-6 py-4">
<footer className="flex items-center justify-end gap-3 border-t-hairline border-(--border-muted) px-6 py-4">
<UserEditActions
variant="modalFooter"
onSave={() => {

View File

@@ -12,7 +12,7 @@ export const SettingsHeader = ({
onClose,
}: SettingsHeaderProps) => (
<header
className="flex items-center gap-3 px-5 py-4 border-b border-[var(--border-muted)] flex-shrink-0"
className="flex items-center gap-3 px-5 py-4 border-b-hairline border-(--border-muted) shrink-0"
style={{ paddingTop: 'calc(1rem + env(safe-area-inset-top))' }}
>
{showBack && (

View File

@@ -319,7 +319,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
onClick={handleClose}
/>
<div
className="relative bg-[var(--bg)] rounded-xl p-8 shadow-2xl"
className="relative bg-(--bg) rounded-xl p-8 shadow-2xl"
style={{ background: 'var(--bg)' }}
>
<div className="flex items-center gap-3">
@@ -356,7 +356,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
onClick={handleClose}
/>
<div
className="relative bg-[var(--bg)] rounded-xl p-8 shadow-2xl max-w-md"
className="relative bg-(--bg) rounded-xl p-8 shadow-2xl max-w-md"
style={{ background: 'var(--bg)' }}
>
<div className="text-center space-y-4">
@@ -380,8 +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-[var(--bg-soft)] border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
bg-(--bg-soft) border-hairline border-(--border-muted) hover:bg-(--hover-surface) transition-colors"
>
Close
</button>
@@ -441,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 border-[var(--border-muted)] shadow-2xl
border-hairline border-(--border-muted) shadow-2xl
flex flex-col overflow-hidden
${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
style={{ background: 'var(--bg)' }}

View File

@@ -213,13 +213,13 @@ export const SettingsSidebar = ({
<button
onClick={() => onSelectTab(item.tab.name)}
className="w-full flex items-center gap-4 px-5 py-4
active:bg-[var(--hover-surface)] transition-colors text-left"
active:bg-(--hover-surface) transition-colors text-left"
>
<span className="opacity-50">{getIcon(item.tab.icon)}</span>
<span className="flex-1">{item.tab.displayName}</span>
</button>
{itemIndex < sidebarItems.length - 1 && (
<div className="ml-14 mr-5 border-b border-[var(--border-muted)]" />
<div className="ml-14 mr-5 border-b-hairline border-(--border-muted)" />
)}
</div>
);
@@ -232,7 +232,7 @@ export const SettingsSidebar = ({
<button
onClick={() => toggleGroup(item.group.name)}
className="w-full flex items-center gap-4 px-5 py-4
active:bg-[var(--hover-surface)] transition-colors text-left"
active:bg-(--hover-surface) transition-colors text-left"
>
<span className="opacity-50">{getIcon(item.group.icon)}</span>
<span className="flex-1">{item.group.displayName}</span>
@@ -240,18 +240,18 @@ export const SettingsSidebar = ({
</button>
{isExpanded && (
<div className="bg-[var(--bg-soft)]/50">
<div className="bg-(--bg-soft)/50">
{item.tabs.map((tab, index) => (
<div key={tab.name}>
<button
onClick={() => onSelectTab(tab.name)}
className="w-full flex items-center gap-4 pl-14 pr-5 py-3.5
active:bg-[var(--hover-surface)] transition-colors text-left"
active:bg-(--hover-surface) transition-colors text-left"
>
<span className="flex-1 text-[15px]">{tab.displayName}</span>
</button>
{index < item.tabs.length - 1 && (
<div className="ml-14 mr-5 border-b border-[var(--border-muted)]" />
<div className="ml-14 mr-5 border-b-hairline border-(--border-muted)" />
)}
</div>
))}
@@ -259,7 +259,7 @@ export const SettingsSidebar = ({
)}
{itemIndex < sidebarItems.length - 1 && (
<div className="ml-14 mr-5 border-b border-[var(--border-muted)]" />
<div className="ml-14 mr-5 border-b-hairline border-(--border-muted)" />
)}
</div>
);
@@ -270,7 +270,7 @@ export const SettingsSidebar = ({
// Desktop: Sidebar navigation
return (
<nav className="w-60 border-r border-[var(--border-muted)] py-2 flex-shrink-0 overflow-y-auto">
<nav className="w-60 border-r-hairline border-(--border-muted) py-2 shrink-0 overflow-y-auto">
{sidebarItems.map((item) => {
if (item.type === 'section') {
return (
@@ -290,8 +290,8 @@ export const SettingsSidebar = ({
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left
transition-colors ${
selectedTab === item.tab.name
? 'bg-[var(--hover-action)] font-medium'
: 'hover:bg-[var(--hover-surface)]'
? 'bg-(--hover-action) font-medium'
: 'hover:bg-(--hover-surface)'
}`}
>
<span className="opacity-60">{getIcon(item.tab.icon)}</span>
@@ -309,8 +309,8 @@ export const SettingsSidebar = ({
<button
onClick={() => toggleGroup(item.group.name)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left
transition-colors hover:bg-[var(--hover-surface)]
${hasSelectedTab && !isExpanded ? 'bg-[var(--hover-action)]/50' : ''}`}
transition-colors hover:bg-(--hover-surface)
${hasSelectedTab && !isExpanded ? 'bg-(--hover-action)/50' : ''}`}
>
<span className="opacity-60">{getIcon(item.group.icon)}</span>
<span className="flex-1">{item.group.displayName}</span>
@@ -318,16 +318,16 @@ export const SettingsSidebar = ({
</button>
{isExpanded && (
<div className="ml-8 border-l border-[var(--border-muted)]">
<div className="ml-[22px] border-l-hairline border-(--border-muted) flex flex-col gap-0.5 pl-3">
{item.tabs.map((tab) => (
<button
key={tab.name}
onClick={() => onSelectTab(tab.name)}
className={`w-full flex items-center pl-4 pr-4 py-2 text-sm text-left
className={`w-full flex items-center pl-3 pr-4 py-2 text-sm text-left
transition-colors ${
selectedTab === tab.name
? 'bg-[var(--hover-action)] font-medium'
: 'hover:bg-[var(--hover-surface)]'
? 'bg-(--hover-action) font-medium'
: 'hover:bg-(--hover-surface)'
}`}
>
<span>{tab.displayName}</span>

View File

@@ -2,9 +2,9 @@ import { CustomSettingsFieldRendererProps } from './types';
export const OidcEnvInfo = (_props: CustomSettingsFieldRendererProps) => {
return (
<div className="rounded-lg overflow-hidden border border-[var(--border-muted)]">
<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 border-[var(--border-muted)]"
className="px-3 py-1.5 text-xs font-medium opacity-60 border-b-hairline border-(--border-muted)"
style={{ background: 'var(--bg-soft)' }}
>
docker-compose.yml

View File

@@ -31,7 +31,7 @@ export const ActionButton = ({ field, onAction, disabled }: ActionButtonProps) =
const styleClasses = {
default:
'bg-[var(--bg-soft)] border border-[var(--border-muted)] hover:bg-[var(--hover-surface)]',
'bg-(--bg-soft) border-hairline 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',
};

View File

@@ -5,7 +5,7 @@ interface HeadingFieldProps {
}
export const HeadingField = ({ field }: HeadingFieldProps) => (
<div className="pb-1 [&:not(:first-child)]:pt-5 [&:not(:first-child)]:mt-1 [&:not(:first-child)]:border-t [&:not(:first-child)]:border-[var(--border-muted)]">
<div className="pb-1 not-first:pt-5 not-first:mt-1 not-first:border-t-hairline not-first:border-(--border-muted)">
<h3 className="text-base font-semibold mb-1">{field.title}</h3>
{field.description && (
<p className="text-sm opacity-70">

View File

@@ -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 border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm opacity-60 cursor-not-allowed">
<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">
{summaryFormatter()}
</div>
);
@@ -277,12 +277,12 @@ 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
transition-colors border-hairline
disabled:opacity-60 disabled:cursor-not-allowed
${
isSelected
? 'bg-sky-600 text-white border-sky-600'
: 'bg-transparent border-[var(--border-muted)] hover:bg-[var(--hover-surface)]'
: 'bg-transparent border-(--border-muted) hover:bg-(--hover-surface)'
}`}
>
{opt.label}

View File

@@ -20,9 +20,8 @@ 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 border-[var(--border-muted)]
bg-[var(--bg-soft)] text-sm
focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
className="w-full px-3 py-2 rounded-lg border-hairline 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"
/>

View File

@@ -233,13 +233,12 @@ 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 border-[var(--border-muted)]
${isDisabled ? 'opacity-60' : !isPinned ? 'hover:bg-[var(--hover-surface)]' : ''}
border-hairline border-(--border-muted) ${isDisabled ? 'opacity-60' : !isPinned ? 'hover:bg-(--hover-surface)' : ''}
`}
>
{/* Reorder Controls - hidden for pinned items */}
{!isPinned ? (
<div className="flex flex-col flex-shrink-0 -my-1">
<div className="flex flex-col shrink-0 -my-1">
<button
type="button"
onClick={(e) => {
@@ -282,14 +281,14 @@ export const OrderableListField = ({
</button>
</div>
) : (
<div className="w-5 sm:w-4 flex-shrink-0" />
<div className="w-5 sm:w-4 shrink-0" />
)}
{/* Label and Description */}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{item.label}</div>
{item.description && (
<div className="text-xs text-[var(--text-muted)] mt-0.5">
<div className="text-xs text-(--text-muted) mt-0.5">
{item.description}
</div>
)}
@@ -308,7 +307,7 @@ export const OrderableListField = ({
</div>
{/* Toggle Switch */}
<div onClick={(e) => e.stopPropagation()} className="flex-shrink-0">
<div onClick={(e) => e.stopPropagation()} className="shrink-0">
<ToggleSwitch
checked={item.enabled && !item.isLocked}
onChange={() => toggleItem(index)}

View File

@@ -21,9 +21,8 @@ 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 border-[var(--border-muted)]
bg-[var(--bg-soft)] text-sm
focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
className="w-full px-3 py-2 pr-10 rounded-lg border-hairline 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"
/>
@@ -32,7 +31,7 @@ export const PasswordField = ({ field, value, onChange, disabled }: PasswordFiel
onClick={() => setShowPassword(!showPassword)}
disabled={isDisabled}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded
hover:bg-[var(--hover-action)] transition-colors
hover:bg-(--hover-action) transition-colors
disabled:opacity-60 disabled:cursor-not-allowed"
tabIndex={-1}
aria-label={showPassword ? 'Hide password' : 'Show password'}

View File

@@ -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 border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm opacity-60 cursor-not-allowed">
<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">
{selectedOption?.label || 'Select...'}
</div>
);

View File

@@ -88,7 +88,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
// Use minmax(0, ...) so the grid can shrink inside the settings modal.
// Use fixed width for delete button column to ensure header/data alignment.
const gridTemplate = 'sm:[grid-template-columns:var(--table-cols)]';
const gridTemplate = 'sm:grid-cols-(--table-cols)';
const tableCols = useMemo(() => {
if (columns.length === 0) {
@@ -175,8 +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-[var(--bg-soft)] border border-[var(--border-muted)]
hover-action transition-colors
bg-(--bg-soft) border-hairline border-(--border-muted) hover-action transition-colors
disabled:opacity-60 disabled:cursor-not-allowed"
>
{field.addLabel || 'Add'}
@@ -237,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 border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm opacity-60 cursor-not-allowed">
<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">
{options.find((o) => o.value === String(cellValue ?? ''))?.label || 'Select...'}
</div>
) : (
@@ -301,9 +300,8 @@ 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 border-[var(--border-muted)]
bg-[var(--bg-soft)] text-sm
focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
className="w-full px-3 py-2 rounded-lg border-hairline 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"
/>
@@ -333,7 +331,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
</button>
</div>
<div className="col-span-full border-t border-[var(--border-muted)] opacity-60" />
<div className="col-span-full border-t-hairline border-(--border-muted) opacity-60" />
</div>
))}
</div>
@@ -343,8 +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-[var(--bg-soft)] border border-[var(--border-muted)]
hover-action transition-colors
bg-(--bg-soft) border-hairline border-(--border-muted) hover-action transition-colors
disabled:opacity-60 disabled:cursor-not-allowed"
>
{field.addLabel || 'Add'}

View File

@@ -82,9 +82,8 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
return (
<div
className={`w-full px-3 py-2 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg-soft)] text-sm
focus-within:outline-none focus-within:ring-2 focus-within:ring-sky-500/50 focus-within:border-sky-500
className={`w-full px-3 py-2 rounded-lg border-hairline 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'}`}
onClick={() => {
@@ -92,16 +91,16 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
inputRef.current?.focus();
}}
>
<div className="flex flex-wrap gap-1 items-center min-h-[1.25rem]">
<div className="flex flex-wrap gap-1 items-center min-h-5">
{tags.map((tag, idx) => (
<span
key={`${tag}-${idx}`}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md
border border-[var(--border-muted)] bg-[var(--bg)]
border-hairline border-(--border-muted) bg-(--bg)
max-w-full"
title={tag}
>
<span className="truncate max-w-[22rem]">{tag}</span>
<span className="truncate max-w-88">{tag}</span>
{!isDisabled && !isRequired(tag) && (
<button
type="button"
@@ -109,7 +108,7 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
e.stopPropagation();
removeAt(idx);
}}
className="p-0.5 rounded hover:bg-[var(--hover-surface)]"
className="p-0.5 rounded-sm hover:bg-(--hover-surface)"
aria-label={`Remove ${tag}`}
>
<svg
@@ -146,7 +145,7 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
}}
onBlur={() => commitDraft()}
placeholder={tags.length === 0 ? field.placeholder : ''}
className="flex-1 min-w-[4rem] bg-transparent outline-none px-1 py-0"
className="flex-1 min-w-16 bg-transparent outline-hidden px-1 py-0"
/>
)}

View File

@@ -19,9 +19,8 @@ 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 border-[var(--border-muted)]
bg-[var(--bg-soft)] text-sm
focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
className="w-full px-3 py-2 rounded-lg border-hairline 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"
/>

View File

@@ -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 border-zinc-500/30"
bg-zinc-500/20 text-zinc-400 border-hairline 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 border-amber-500/30"
bg-amber-500/20 text-amber-600 dark:text-amber-400 border-hairline 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 border-sky-500/30"
bg-sky-500/15 text-sky-500 dark:text-sky-400 border-hairline border-sky-500/30"
>
User overridden{count > 1 ? ` (${count})` : ''}
</span>

View File

@@ -5,7 +5,7 @@ interface SettingsSaveBarProps {
export const SettingsSaveBar = ({ onSave, isSaving }: SettingsSaveBarProps) => (
<div
className="flex-shrink-0 px-6 py-4 border-t border-[var(--border-muted)] bg-[var(--bg)] animate-slide-up"
className="shrink-0 px-6 py-4 border-t-hairline border-(--border-muted) bg-(--bg) animate-slide-up"
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
>
<button

View File

@@ -142,23 +142,23 @@ export const RequestPolicyGrid = ({
type="button"
onClick={onClearOverrides}
disabled={clearOverridesDisabled}
className="px-3 py-1.5 rounded-lg text-xs font-medium border border-[var(--border-muted)] bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
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"
>
Clear all overrides
</button>
</div>
)}
<div className="rounded-lg border border-[var(--border-muted)]">
<div className="rounded-lg border-hairline 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-[var(--bg-soft)] text-xs font-medium opacity-60 border-b border-[var(--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-hairline border-(--border-muted) rounded-t-lg">
<span>Source</span>
<span>Ebook</span>
<span>Audiobook</span>
</div>
{/* Default row */}
<div 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 bg-[var(--bg-soft)] border-b-2 border-[var(--border-muted)]">
<div 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 bg-(--bg-soft) border-b-2 border-(--border-muted)">
<div className="min-w-0">
<p className="text-sm font-semibold truncate">Default</p>
</div>
@@ -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 border-[var(--border-muted)] bg-[var(--bg)] text-sm opacity-60 cursor-not-allowed">
<div className="w-full px-3 py-2 rounded-lg border-hairline 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 border-[var(--border-muted)]' : ''
index > 0 ? 'border-t-hairline 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 border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm opacity-60 cursor-not-allowed">
<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">
{REQUEST_POLICY_MODE_LABELS[effectiveMode]}
</div>
) : (

View File

@@ -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 border-[var(--border-muted)] bg-[var(--bg)]">
<div className="space-y-5 p-4 rounded-lg border-hairline border-(--border-muted) shadow-sm bg-(--bg)">
<h3 className="text-sm font-medium">{title}</h3>
{children}
</div>
@@ -147,10 +147,10 @@ export const UserRoleControl = ({
onUserChange({ ...user, role: nextRole });
}}
widthClassName="w-28"
buttonClassName={`!py-1 !px-2.5 !text-xs !font-medium ${
buttonClassName={`py-1! px-2.5! text-xs! font-medium! ${
user.role === 'admin'
? '!bg-sky-500/15 !text-sky-600 dark:!text-sky-400 !border-sky-500/30'
: '!bg-zinc-500/10 !opacity-70'
? 'bg-sky-500/15! text-sky-600! dark:text-sky-400! border-sky-500/30!'
: 'bg-zinc-500/10! opacity-70!'
}`}
/>
);
@@ -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-[var(--bg-soft)] border border-[var(--border-muted)] 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-hairline 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 border-[var(--border-muted)] sm:flex-row sm:items-center">
<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-wrap gap-2">
<button
onClick={onSave}
@@ -290,8 +290,7 @@ export const UserEditActions = ({
<button
onClick={onCancel}
disabled={cancelDisabled}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
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"
>
Cancel
</button>
@@ -310,8 +309,8 @@ export const UserEditActions = ({
<button
onClick={onCancelDelete}
disabled={deleting}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
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"
>
Cancel
</button>
@@ -320,7 +319,7 @@ export const UserEditActions = ({
<button
onClick={onDelete}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors
border border-red-500/40 text-red-600 hover:bg-red-500/10"
border-hairline border-red-500/40 text-red-600 hover:bg-red-500/10"
>
Delete User
</button>
@@ -401,8 +400,7 @@ export const UserCreateCard = ({
</button>
<button
onClick={onCancel}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors"
className="px-4 py-2 rounded-lg text-sm font-medium border-hairline border-(--border-muted) bg-(--bg) hover:bg-(--hover-surface) transition-colors"
>
Cancel
</button>
@@ -574,7 +572,7 @@ export const UserAccountCardContent = ({
{preferencesContent && preferencesPlacement === 'before' && (
<>
{preferencesContent}
<div className="border-t border-[var(--border-muted)]" />
<div className="border-t-hairline border-(--border-muted)" />
</>
)}
@@ -598,7 +596,7 @@ export const UserAccountCardContent = ({
{preferencesContent && preferencesPlacement === 'after' && (
<>
<div className="border-t border-[var(--border-muted)]" />
<div className="border-t-hairline border-(--border-muted)" />
{preferencesContent}
</>
)}

View File

@@ -96,8 +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 border-[var(--border-muted)]
bg-[var(--bg-soft)] hover:bg-[var(--hover-surface)] transition-colors"
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"
>
Retry
</button>
@@ -118,7 +117,7 @@ export const UserListView = ({
return (
<div
key={user.id}
className={`rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] transition-colors ${active ? '' : 'opacity-60'}`}
className={`rounded-lg border-hairline border-(--border-muted) shadow-sm bg-(--bg-soft) transition-colors ${active ? '' : 'opacity-60'}`}
>
<div
role="button"
@@ -144,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 border-[var(--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-hairline border-(--border-muted)' : 'rounded-b-lg'}`}
aria-expanded={isEditingRow}
aria-label={isEditingRow ? 'Collapse user editor' : `Expand ${user.username} editor`}
>
@@ -185,7 +184,7 @@ export const UserListView = ({
</div>
{isEditingRow && (
<div className="p-4 space-y-5 bg-[var(--bg)] rounded-b-lg">
<div className="p-4 space-y-5 bg-(--bg) rounded-b-lg">
{hasLoadedEditUser && editingUser ? (
<UserAccountCardContent
user={editingUser}

View File

@@ -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 border-[var(--border-muted)]" />}
{index > 0 && <div className="border-t-hairline border-(--border-muted)" />}
{node}
</Fragment>
))}

View File

@@ -38,8 +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 border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors"
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"
>
<svg
className="w-4 h-4"

View File

@@ -11,9 +11,9 @@ interface SearchFieldRendererProps {
}
const baseInputClass =
'w-full px-3 py-2 rounded-md border border-[var(--border-muted)] ' +
'bg-[var(--bg-soft)] text-sm ' +
'focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 ' +
'w-full px-3 py-2 rounded-md border-hairline 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';
export const SearchFieldRenderer = ({ field, value, onChange, onSubmit }: SearchFieldRendererProps) => {
@@ -93,7 +93,7 @@ export const SearchFieldRenderer = ({ field, value, onChange, onSubmit }: Search
type="checkbox"
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
className="w-4 h-4 rounded border-[var(--border-muted)] text-emerald-500 focus:ring-emerald-500/50"
className="w-4 h-4 rounded-sm border-(--border-muted) text-emerald-500 focus:ring-emerald-500/50"
/>
<span className="text-sm">{field.label}</span>
</label>

View File

@@ -26,13 +26,13 @@ export const ToggleSwitch = ({
onClick={() => !disabled && onChange(!checked)}
disabled={disabled}
className={`relative inline-flex h-6 w-11 items-center rounded-full
transition-colors duration-200 focus:outline-none focus:ring-2
transition-colors duration-200 focus:outline-hidden focus:ring-2
${ring} disabled:opacity-60 disabled:cursor-not-allowed
${checked ? active : 'bg-gray-300 dark:bg-gray-600'}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white
shadow-sm transition-transform duration-200
shadow-xs transition-transform duration-200
${checked ? 'translate-x-6' : 'translate-x-1'}`}
/>
</button>

View File

@@ -194,7 +194,7 @@ export function Tooltip({
role="tooltip"
onMouseEnter={interactive ? handleTooltipMouseEnter : undefined}
onMouseLeave={interactive ? handleTooltipMouseLeave : undefined}
className={`fixed z-[9999] ${interactive ? 'select-text cursor-auto' : 'pointer-events-none'} ${tooltipSizeClass} ${transformClass} ${className}`}
className={`fixed z-9999 ${interactive ? 'select-text cursor-auto' : 'pointer-events-none'} ${tooltipSizeClass} ${transformClass} ${className}`}
style={{
top: coords.top,
left: coords.left,

View File

@@ -0,0 +1,6 @@
export const getActivityErrorMessage = (error: unknown, fallback: string): string => {
if (error instanceof Error && error.message) {
return error.message;
}
return fallback;
};

View File

@@ -17,6 +17,7 @@ import {
requestToActivityItem,
} from '../components/activity';
import { dedupeHistoryItems } from '../components/activity/activityHistory.js';
import { getActivityErrorMessage } from './useActivity.helpers.js';
const HISTORY_PAGE_SIZE = 50;
@@ -320,7 +321,7 @@ export const useActivity = ({
console.error('Activity dismiss failed:', error);
void refreshActivitySnapshot();
refreshHistoryIfLoaded();
showToast(errorMessage, 'error');
showToast(getActivityErrorMessage(error, errorMessage), 'error');
});
}, [refreshActivitySnapshot, refreshHistoryIfLoaded, showToast]);
@@ -340,7 +341,7 @@ export const useActivity = ({
console.error('Request dismiss failed:', error);
void refreshActivitySnapshot();
refreshHistoryIfLoaded();
showToast('Failed to clear request', 'error');
showToast(getActivityErrorMessage(error, 'Failed to clear request'), 'error');
});
}, [refreshActivitySnapshot, refreshHistoryIfLoaded, showToast]);
@@ -385,7 +386,7 @@ export const useActivity = ({
.catch((error) => {
console.error('Clear history failed:', error);
void refreshActivityHistory();
showToast('Failed to clear history', 'error');
showToast(getActivityErrorMessage(error, 'Failed to clear history'), 'error');
});
}, [refreshActivityHistory, refreshActivitySnapshot, resetActivityHistory, showToast]);

View File

@@ -1,8 +1,9 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { LoginCredentials } from '../types';
import { login, logout, checkAuth } from '../services/api';
import { useSocket } from '../contexts/SocketContext';
import { getReturnToFromSearch } from '../utils/authRedirect';
interface UseAuthOptions {
onLogoutSuccess?: () => void;
@@ -30,8 +31,10 @@ interface UseAuthReturn {
export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
const { onLogoutSuccess, showToast } = options;
const location = useLocation();
const navigate = useNavigate();
const { socket } = useSocket();
const postLoginPath = getReturnToFromSearch(location.search);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [authRequired, setAuthRequired] = useState<boolean>(true);
@@ -132,7 +135,7 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
applyAuthResponse(await checkAuth());
refreshSocketSession();
setLoginError(null);
navigate('/', { replace: true });
navigate(postLoginPath, { replace: true });
} else {
setLoginError(response.error || 'Login failed');
}
@@ -145,7 +148,7 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
} finally {
setIsLoggingIn(false);
}
}, [navigate, applyAuthResponse, refreshSocketSession]);
}, [navigate, applyAuthResponse, postLoginPath, refreshSocketSession]);
const handleLogout = useCallback(async () => {
try {

View File

@@ -1,9 +1,9 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { getSettings, updateSettings, executeSettingsAction } from '../services/api';
import {
SettingsResponse,
SettingsTab,
SettingsGroup,
SettingsField,
ActionResult,
UpdateResult,
} from '../types/settings';
@@ -14,67 +14,19 @@ import {
} from '../utils/themePreference';
import {
cloneSettingsValues,
extractSettingsValues,
getRestartRequiredFieldKeys,
getValueBearingFields,
mergeFetchedSettingsWithDirtyValues,
settingsTabMatchesSavedValues,
type SettingsValues,
} from '../utils/settingsValues';
type ValueBearingField = Exclude<
SettingsField,
{ type: 'ActionButton' } | { type: 'HeadingField' } | { type: 'CustomComponentField' }
>;
interface FetchSettingsOptions {
silent?: boolean;
preserveDirtyValues?: boolean;
}
// Extract value from a field based on its type
function getFieldValue(field: SettingsField): unknown {
// These field types have no value property
if (
field.type === 'ActionButton'
|| field.type === 'HeadingField'
|| field.type === 'CustomComponentField'
) {
return undefined;
}
if (field.type === 'TableField') {
return (field as unknown as { value?: unknown }).value ?? [];
}
// All other fields have a value property
return field.value ?? '';
}
function getValueBearingFields(fields: SettingsField[]): ValueBearingField[] {
const seen = new Set<string>();
const valueFields: ValueBearingField[] = [];
const collect = (items: SettingsField[]) => {
items.forEach((field) => {
if (field.type === 'CustomComponentField') {
if (field.boundFields && field.boundFields.length > 0) {
collect(field.boundFields);
}
return;
}
if (field.type === 'ActionButton' || field.type === 'HeadingField') {
return;
}
if (seen.has(field.key)) {
return;
}
seen.add(field.key);
valueFields.push(field);
});
};
collect(fields);
return valueFields;
}
interface UseSettingsReturn {
tabs: SettingsTab[];
@@ -112,16 +64,10 @@ export function useSettings(): UseSettingsReturn {
originalValuesRef.current = originalValues;
}, [originalValues]);
const fetchSettings = useCallback(async (options: FetchSettingsOptions = {}) => {
const { silent = false, preserveDirtyValues = false } = options;
if (!silent) {
setIsLoading(true);
}
setError(null);
try {
const response = await getSettings();
const applySettingsResponse = useCallback(
(response: SettingsResponse, options: { preserveDirtyValues?: boolean } = {}) => {
const { preserveDirtyValues = false } = options;
// Inject theme field into the general tab at the beginning
const tabsWithTheme = response.tabs.map((tab) => {
if (tab.name === 'general') {
return {
@@ -131,22 +77,14 @@ export function useSettings(): UseSettingsReturn {
}
return tab;
});
setTabs(tabsWithTheme);
setGroups(response.groups || []);
// Initialize values from fetched data
const initialValues: SettingsValues = {};
tabsWithTheme.forEach((tab) => {
initialValues[tab.name] = {};
getValueBearingFields(tab.fields).forEach((field) => {
// Special handling for theme field - get from localStorage
if (field.key === '_THEME') {
initialValues[tab.name][field.key] = getStoredThemePreference();
} else {
initialValues[tab.name][field.key] = getFieldValue(field);
}
});
});
const initialValues = extractSettingsValues(tabsWithTheme);
if (initialValues.general && Object.prototype.hasOwnProperty.call(initialValues.general, '_THEME')) {
initialValues.general._THEME = getStoredThemePreference();
}
const nextValues = preserveDirtyValues
? mergeFetchedSettingsWithDirtyValues(initialValues, valuesRef.current, originalValuesRef.current)
@@ -155,19 +93,33 @@ export function useSettings(): UseSettingsReturn {
setValues(nextValues);
setOriginalValues(cloneSettingsValues(initialValues));
// Select first tab by default if none selected
if (tabsWithTheme.length > 0) {
setSelectedTab((current) => current ?? tabsWithTheme[0].name);
}
},
[]
);
const fetchSettings = useCallback(async (options: FetchSettingsOptions = {}) => {
const { silent = false, preserveDirtyValues = false } = options;
if (!silent) {
setIsLoading(true);
setError(null);
}
try {
const response = await getSettings();
applySettingsResponse(response, { preserveDirtyValues });
} catch (err) {
console.error('Failed to fetch settings:', err);
setError(err instanceof Error ? err.message : 'Failed to load settings');
if (!silent) {
setError(err instanceof Error ? err.message : 'Failed to load settings');
}
} finally {
if (!silent) {
setIsLoading(false);
}
}
}, []);
}, [applySettingsResponse]);
useEffect(() => {
fetchSettings();
@@ -223,13 +175,15 @@ export function useSettings(): UseSettingsReturn {
const saveTab = useCallback(
async (tabName: string): Promise<UpdateResult> => {
setIsSaving(true);
let valuesToSave: Record<string, unknown> = {};
let restartRequiredFor: string[] = [];
try {
const tabValues = values[tabName] || {};
const originalTabValues = originalValues[tabName] || {};
// Only send values that actually changed
const tab = tabs.find((t) => t.name === tabName);
const valuesToSave: Record<string, unknown> = {};
valuesToSave = {};
if (tab) {
for (const field of getValueBearingFields(tab.fields)) {
@@ -249,6 +203,8 @@ export function useSettings(): UseSettingsReturn {
valuesToSave[field.key] = value;
}
}
restartRequiredFor = getRestartRequiredFieldKeys(tab.fields, valuesToSave);
}
const result = await updateSettings(tabName, valuesToSave);
@@ -261,6 +217,25 @@ export function useSettings(): UseSettingsReturn {
return result;
} catch (err) {
if (Object.keys(valuesToSave).length > 0) {
try {
const response = await getSettings();
if (settingsTabMatchesSavedValues(tabName, response.tabs, valuesToSave)) {
setError(null);
applySettingsResponse(response);
return {
success: true,
message: 'Settings saved, but the proxy interrupted the response. Latest values were confirmed.',
updated: Object.keys(valuesToSave),
requiresRestart: restartRequiredFor.length > 0,
restartRequiredFor,
};
}
} catch (recoveryError) {
console.warn('Failed to verify settings save after response error:', recoveryError);
}
}
console.error('Failed to save settings tab:', tabName, err);
return {
success: false,
@@ -271,7 +246,7 @@ export function useSettings(): UseSettingsReturn {
setIsSaving(false);
}
},
[values, originalValues, tabs]
[applySettingsResponse, originalValues, tabs, values]
);
const executeAction = useCallback(

View File

@@ -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"
className="rounded-lg shadow-2xl p-6 border-hairline"
style={{
backgroundColor: 'var(--card-background)',
borderColor: 'var(--border-color)',

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
import * as assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { StatusData } from '../types/index.js';
import { wasDownloadQueuedAfterResponseError } from '../utils/downloadRecovery.js';
describe('wasDownloadQueuedAfterResponseError', () => {
it('confirms a queued download immediately from active buckets', () => {
const status: StatusData = {
queued: {
'book-1': {
id: 'book-1',
title: 'Queued Book',
author: 'Author',
},
},
};
assert.equal(wasDownloadQueuedAfterResponseError(status, 'book-1', 1_000), true);
});
it('confirms a recent terminal item for the same request window', () => {
const status: StatusData = {
complete: {
'book-2': {
id: 'book-2',
title: 'Completed Book',
author: 'Author',
added_time: 1_005,
},
},
};
assert.equal(wasDownloadQueuedAfterResponseError(status, 'book-2', 1_006), true);
});
it('ignores stale terminal entries from older attempts', () => {
const status: StatusData = {
complete: {
'book-3': {
id: 'book-3',
title: 'Old Completed Book',
author: 'Author',
added_time: 900,
},
},
};
assert.equal(wasDownloadQueuedAfterResponseError(status, 'book-3', 1_000), false);
});
});

View File

@@ -37,6 +37,7 @@ const tableFieldFixture: TableFieldConfig = {
{ value: 'ebook', label: 'Ebook', childOf: 'prowlarr' },
{ value: 'audiobook', label: 'Audiobook', childOf: 'prowlarr' },
{ value: 'ebook', label: 'Ebook', childOf: 'irc' },
{ value: 'audiobook', label: 'Audiobook', childOf: 'irc' },
],
},
{
@@ -70,7 +71,7 @@ describe('requestPolicyGridUtils', () => {
{
source: 'irc',
displayName: 'IRC',
supportedContentTypes: ['ebook'],
supportedContentTypes: ['ebook', 'audiobook'],
},
]);
});
@@ -97,7 +98,7 @@ describe('requestPolicyGridUtils', () => {
{ source: 'direct_download', content_type: 'ebook', mode: 'request_release' }, // same as inherited default -> kept (explicit intent)
{ source: 'prowlarr', content_type: 'ebook', mode: 'blocked' }, // same as inherited global rule -> kept (explicit intent)
{ source: 'prowlarr', content_type: 'audiobook', mode: 'request_release' }, // meaningful override -> kept
{ source: 'irc', content_type: 'audiobook', mode: 'blocked' }, // unsupported pair -> removed
{ source: 'direct_download', content_type: 'audiobook', mode: 'blocked' }, // unsupported pair -> removed
]);
const persisted = normalizeExplicitRulesForPersistence({

View File

@@ -0,0 +1,18 @@
import * as assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { getActivityErrorMessage } from '../hooks/useActivity.helpers.js';
describe('useActivity helpers', () => {
it('returns the backend error message when present', () => {
const error = new Error('User identity unavailable for activity workflow');
assert.equal(
getActivityErrorMessage(error, 'Failed to clear item'),
'User identity unavailable for activity workflow'
);
});
it('falls back to the provided message for non-error values', () => {
assert.equal(getActivityErrorMessage(null, 'Failed to clear item'), 'Failed to clear item');
});
});

View File

@@ -1,6 +1,11 @@
import * as assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { mergeFetchedSettingsWithDirtyValues } from '../utils/settingsValues.js';
import {
getRestartRequiredFieldKeys,
mergeFetchedSettingsWithDirtyValues,
settingsTabMatchesSavedValues,
} from '../utils/settingsValues.js';
import { SettingsTab } from '../types/settings.js';
describe('mergeFetchedSettingsWithDirtyValues', () => {
it('preserves unsaved dirty values while applying fresh fetched values', () => {
@@ -71,3 +76,63 @@ describe('mergeFetchedSettingsWithDirtyValues', () => {
);
});
});
describe('settings save verification helpers', () => {
const tabs: SettingsTab[] = [
{
name: 'general',
displayName: 'General',
order: 1,
fields: [
{
key: 'API_URL',
label: 'API URL',
type: 'TextField',
value: 'https://saved.example',
},
{
key: 'API_KEY',
label: 'API Key',
type: 'PasswordField',
value: '',
},
{
key: 'USE_SSL',
label: 'Use SSL',
type: 'CheckboxField',
value: true,
requiresRestart: true,
},
],
},
];
it('confirms a saved tab when backend values match the expected non-password changes', () => {
assert.equal(
settingsTabMatchesSavedValues('general', tabs, {
API_URL: 'https://saved.example',
API_KEY: 'secret',
}),
true
);
});
it('does not confirm a saved tab when a non-password field does not match', () => {
assert.equal(
settingsTabMatchesSavedValues('general', tabs, {
API_URL: 'https://different.example',
}),
false
);
});
it('collects restart-required keys for changed values', () => {
assert.deepEqual(
getRestartRequiredFieldKeys(tabs[0].fields, {
API_URL: 'https://saved.example',
USE_SSL: true,
}),
['USE_SSL']
);
});
});

View File

@@ -0,0 +1,82 @@
import { withBasePath } from './basePath';
type LocationLike = {
hash?: string;
pathname: string;
search?: string;
};
const PLACEHOLDER_ORIGIN = 'http://shelfmark.local';
const DEFAULT_RETURN_TO = '/';
const normalizeSearch = (search: string | undefined): string => {
if (!search) {
return '';
}
return search.startsWith('?') ? search : `?${search}`;
};
const normalizeHash = (hash: string | undefined): string => {
if (!hash) {
return '';
}
return hash.startsWith('#') ? hash : `#${hash}`;
};
export const sanitizeReturnTo = (value: string | null | undefined): string | null => {
if (!value) {
return null;
}
const trimmed = value.trim();
if (!trimmed.startsWith('/') || trimmed.startsWith('//')) {
return null;
}
try {
const parsed = new URL(trimmed, PLACEHOLDER_ORIGIN);
if (parsed.origin !== PLACEHOLDER_ORIGIN) {
return null;
}
if (
parsed.pathname === '/login' ||
parsed.pathname.startsWith('/login/') ||
parsed.pathname === '/api' ||
parsed.pathname.startsWith('/api/')
) {
return null;
}
return `${parsed.pathname}${parsed.search}${parsed.hash}` || DEFAULT_RETURN_TO;
} catch {
return null;
}
};
export const buildCurrentReturnTo = ({ pathname, search, hash }: LocationLike): string => {
return sanitizeReturnTo(`${pathname}${normalizeSearch(search)}${normalizeHash(hash)}`) || DEFAULT_RETURN_TO;
};
export const getReturnToFromSearch = (search: string): string => {
const returnTo = new URLSearchParams(search).get('return_to');
return sanitizeReturnTo(returnTo) || DEFAULT_RETURN_TO;
};
export const buildLoginRedirectPath = (location: LocationLike): string => {
const returnTo = buildCurrentReturnTo(location);
if (returnTo === DEFAULT_RETURN_TO) {
return '/login';
}
const params = new URLSearchParams({ return_to: returnTo });
return `/login?${params.toString()}`;
};
export const buildOidcLoginUrl = (search: string): string => {
const returnTo = getReturnToFromSearch(search);
if (returnTo === DEFAULT_RETURN_TO) {
return withBasePath('/api/auth/oidc/login');
}
const params = new URLSearchParams({ return_to: returnTo });
return `${withBasePath('/api/auth/oidc/login')}?${params.toString()}`;
};

View File

@@ -0,0 +1,33 @@
import { StatusData } from '../types';
const ACTIVE_STATUS_BUCKETS = ['queued', 'resolving', 'locating', 'downloading'] as const;
const TERMINAL_STATUS_BUCKETS = ['complete', 'error', 'cancelled'] as const;
const TERMINAL_CONFIRMATION_WINDOW_SECONDS = 2;
export function wasDownloadQueuedAfterResponseError(
status: StatusData,
taskId: string,
requestedAtSeconds: number
): boolean {
for (const bucket of ACTIVE_STATUS_BUCKETS) {
if (status[bucket]?.[taskId]) {
return true;
}
}
for (const bucket of TERMINAL_STATUS_BUCKETS) {
const task = status[bucket]?.[taskId];
if (!task) {
continue;
}
if (
typeof task.added_time === 'number'
&& task.added_time >= requestedAtSeconds - TERMINAL_CONFIRMATION_WINDOW_SECONDS
) {
return true;
}
}
return false;
}

View File

@@ -1,5 +1,111 @@
import { SettingsField, SettingsTab } from '../types/settings';
export type SettingsValues = Record<string, Record<string, unknown>>;
type ValueBearingField = Exclude<
SettingsField,
{ type: 'ActionButton' } | { type: 'HeadingField' } | { type: 'CustomComponentField' }
>;
export function getFieldValue(field: SettingsField): unknown {
if (
field.type === 'ActionButton'
|| field.type === 'HeadingField'
|| field.type === 'CustomComponentField'
) {
return undefined;
}
if (field.type === 'TableField') {
return (field as unknown as { value?: unknown }).value ?? [];
}
return field.value ?? '';
}
export function getValueBearingFields(fields: SettingsField[]): ValueBearingField[] {
const seen = new Set<string>();
const valueFields: ValueBearingField[] = [];
const collect = (items: SettingsField[]) => {
items.forEach((field) => {
if (field.type === 'CustomComponentField') {
if (field.boundFields && field.boundFields.length > 0) {
collect(field.boundFields);
}
return;
}
if (field.type === 'ActionButton' || field.type === 'HeadingField') {
return;
}
if (seen.has(field.key)) {
return;
}
seen.add(field.key);
valueFields.push(field);
});
};
collect(fields);
return valueFields;
}
export function extractSettingsValues(tabs: SettingsTab[]): SettingsValues {
const values: SettingsValues = {};
tabs.forEach((tab) => {
values[tab.name] = {};
getValueBearingFields(tab.fields).forEach((field) => {
values[tab.name][field.key] = getFieldValue(field);
});
});
return values;
}
export function getRestartRequiredFieldKeys(
fields: SettingsField[],
changedValues: Record<string, unknown>
): string[] {
return getValueBearingFields(fields)
.filter((field) => field.requiresRestart && Object.prototype.hasOwnProperty.call(changedValues, field.key))
.map((field) => field.key);
}
export function settingsTabMatchesSavedValues(
tabName: string,
tabs: SettingsTab[],
expectedValues: Record<string, unknown>
): boolean {
const tab = tabs.find((entry) => entry.name === tabName);
if (!tab) {
return false;
}
let verifiedFieldCount = 0;
for (const field of getValueBearingFields(tab.fields)) {
if (!Object.prototype.hasOwnProperty.call(expectedValues, field.key)) {
continue;
}
// Passwords are intentionally not returned by the backend, so they
// cannot be verified from a follow-up fetch.
if (field.type === 'PasswordField') {
continue;
}
verifiedFieldCount += 1;
if (JSON.stringify(getFieldValue(field)) !== JSON.stringify(expectedValues[field.key])) {
return false;
}
}
return verifiedFieldCount > 0;
}
export function cloneSettingsValues(values: SettingsValues): SettingsValues {
return JSON.parse(JSON.stringify(values)) as SettingsValues;
}

View File

@@ -29,6 +29,7 @@ export function applyThemePreference(theme: string): void {
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
document.documentElement.setAttribute('data-theme', effectiveTheme);
document.documentElement.style.colorScheme = effectiveTheme;
}
export function setThemePreference(theme: string): void {

View File

@@ -1,12 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -1,10 +1,11 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
export default defineConfig(({ command }) => ({
base: command === 'build' ? './' : '/',
plugins: [react()],
plugins: [tailwindcss(), react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),

View File

@@ -203,6 +203,12 @@ def test_request_policy_rules_source_options_are_dynamic(monkeypatch):
"enabled": True,
"supported_content_types": ["ebook", "audiobook"],
},
{
"name": "irc",
"display_name": "IRC",
"enabled": True,
"supported_content_types": ["ebook", "audiobook"],
},
],
)
@@ -212,6 +218,7 @@ def test_request_policy_rules_source_options_are_dynamic(monkeypatch):
assert source_options == [
{"value": "direct_download", "label": "Direct Download"},
{"value": "prowlarr", "label": "Prowlarr"},
{"value": "irc", "label": "IRC"},
]
content_type_column = columns[1]
@@ -221,6 +228,8 @@ def test_request_policy_rules_source_options_are_dynamic(monkeypatch):
assert {"value": "ebook", "label": "Ebook", "childOf": "direct_download"} in content_type_options
assert {"value": "ebook", "label": "Ebook", "childOf": "prowlarr"} in content_type_options
assert {"value": "audiobook", "label": "Audiobook", "childOf": "prowlarr"} in content_type_options
assert {"value": "ebook", "label": "Ebook", "childOf": "irc"} in content_type_options
assert {"value": "audiobook", "label": "Audiobook", "childOf": "irc"} in content_type_options
assert {"value": "*", "label": "Any Type (*)", "childOf": "prowlarr"} not in content_type_options
assert {"value": "*", "label": "Any Type (*)", "childOf": "direct_download"} not in content_type_options

View File

@@ -347,13 +347,22 @@ class TestActivityRoutes:
_set_session(client, user_id=user["username"], db_user_id=None, is_admin=False)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
response = client.post(
"/api/activity/dismiss",
json={"item_type": "download", "item_key": "download:test-task"},
)
with patch("shelfmark.core.activity_routes.logger.warning") as mock_warning:
response = client.post(
"/api/activity/dismiss",
json={"item_type": "download", "item_key": "download:test-task"},
)
assert response.status_code == 403
assert response.json["code"] == "user_identity_unavailable"
mock_warning.assert_called_once()
log_message = mock_warning.call_args.args[0]
assert "Activity dismiss rejected" in log_message
assert "status=403" in log_message
assert "reason=User identity unavailable for activity workflow" in log_message
assert "path=/api/activity/dismiss" in log_message
assert f"user={user['username']}" in log_message
assert "db_user_id=-" in log_message
def test_dismiss_returns_404_when_download_history_row_is_missing(self, main_module, client):
user = _create_user(main_module, prefix="reader")
@@ -535,15 +544,16 @@ class TestActivityRoutes:
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
response = client.post(
"/api/activity/dismiss-many",
json={
"items": [
{"item_type": "download", "item_key": f"download:{existing_task_id}"},
{"item_type": "download", "item_key": "download:missing-bulk-task"},
]
},
)
with patch("shelfmark.core.activity_routes.logger.warning") as mock_warning:
response = client.post(
"/api/activity/dismiss-many",
json={
"items": [
{"item_type": "download", "item_key": f"download:{existing_task_id}"},
{"item_type": "download", "item_key": "download:missing-bulk-task"},
]
},
)
assert response.status_code == 404
assert response.json["error"] == "One or more activity items were not found"
@@ -552,6 +562,14 @@ class TestActivityRoutes:
main_module,
viewer_scope=f"user:{user['id']}",
)
mock_warning.assert_called_once()
log_message = mock_warning.call_args.args[0]
assert "Activity dismiss_many rejected" in log_message
assert "status=404" in log_message
assert "reason=One or more activity items were not found" in log_message
assert "path=/api/activity/dismiss-many" in log_message
assert "item_count=2" in log_message
assert "missing_item_keys=download:missing-bulk-task" in log_message
def test_no_auth_dismiss_many_and_history_use_shared_identity(self, main_module):
task_id = f"no-auth-{uuid.uuid4().hex[:10]}"
@@ -664,6 +682,25 @@ class TestActivityRoutes:
assert response.status_code == 403
assert response.json["code"] == "user_identity_unavailable"
def test_clear_history_logs_identity_failure(self, main_module, client):
admin = _create_user(main_module, prefix="admin", role="admin")
_set_session(client, user_id=admin["username"], db_user_id=None, is_admin=True)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch("shelfmark.core.activity_routes.logger.warning") as mock_warning:
response = client.delete("/api/activity/history")
assert response.status_code == 403
assert response.json["code"] == "user_identity_unavailable"
mock_warning.assert_called_once()
log_message = mock_warning.call_args.args[0]
assert "Activity history_clear rejected" in log_message
assert "status=403" in log_message
assert "reason=User identity unavailable for activity workflow" in log_message
assert "path=/api/activity/history" in log_message
assert f"user={admin['username']}" in log_message
assert "is_admin=True" in log_message
def test_snapshot_backfills_undismissed_terminal_download_from_download_history(self, 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)

View File

@@ -1,10 +1,13 @@
"""Tests for auth mode and admin policy helpers used by OIDC integration."""
import sqlite3
from shelfmark.core.auth_modes import (
determine_auth_mode,
get_settings_tab_from_path,
get_auth_check_admin_status,
is_settings_or_onboarding_path,
load_active_auth_mode,
requires_admin_for_settings_access,
should_restrict_settings_to_admin,
)
@@ -60,6 +63,18 @@ class TestDetermineAuthMode:
}
assert determine_auth_mode(config, cwa_db_path=None, has_local_admin=False) == "none"
def test_load_active_auth_mode_reads_env_backed_cwa_setting(self, monkeypatch, tmp_path):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("AUTH_METHOD", "cwa")
cwa_db_path = tmp_path / "app.db"
conn = sqlite3.connect(cwa_db_path)
conn.execute("create table user (name text)")
conn.commit()
conn.close()
assert load_active_auth_mode(cwa_db_path) == "cwa"
class TestSettingsRestrictionPolicy:
def test_settings_path_detection(self):

View File

@@ -134,6 +134,30 @@ class TestOIDCLoginEndpoint:
assert resp.status_code == 500
assert resp.get_json()["error"] == "OIDC not configured"
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_login_stores_valid_return_to_in_session(self, mock_get_client, client):
fake_client = Mock()
fake_client.authorize_redirect.return_value = redirect("https://auth.example.com/authorize")
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
resp = client.get("/api/auth/oidc/login?return_to=%2F%3Fq%3DSanderson")
assert resp.status_code == 302
with client.session_transaction() as sess:
assert sess["oidc_return_to"] == "/?q=Sanderson"
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_login_ignores_unsafe_return_to(self, mock_get_client, client):
fake_client = Mock()
fake_client.authorize_redirect.return_value = redirect("https://auth.example.com/authorize")
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
resp = client.get("/api/auth/oidc/login?return_to=https://evil.example.com/phish")
assert resp.status_code == 302
with client.session_transaction() as sess:
assert "oidc_return_to" not in sess
class TestOIDCCallbackEndpoint:
@patch("shelfmark.core.oidc_routes._get_oidc_client")
@@ -158,6 +182,62 @@ class TestOIDCCallbackEndpoint:
assert sess["user_id"] == "john"
assert sess["db_user_id"] is not None
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_redirects_to_original_url_with_query(self, mock_get_client, client):
fake_client = Mock()
fake_client.authorize_redirect.return_value = redirect("https://auth.example.com/authorize")
fake_client.authorize_access_token.return_value = {
"userinfo": {
"sub": "user-123",
"email": "john@example.com",
"name": "John Doe",
"preferred_username": "john",
"groups": ["users"],
}
}
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
login_resp = client.get("/api/auth/oidc/login?return_to=%2F%3Fq%3DSanderson")
assert login_resp.status_code == 302
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
assert resp.status_code == 302
parsed = urlparse(resp.headers["Location"])
assert parsed.path == "/"
assert parse_qs(parsed.query) == {"q": ["Sanderson"]}
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_redirects_to_original_url_with_script_root(self, mock_get_client, client):
fake_client = Mock()
fake_client.authorize_redirect.return_value = redirect("https://auth.example.com/authorize")
fake_client.authorize_access_token.return_value = {
"userinfo": {
"sub": "user-123",
"email": "john@example.com",
"name": "John Doe",
"preferred_username": "john",
"groups": ["users"],
}
}
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
login_resp = client.get(
"/api/auth/oidc/login?return_to=%2Frequests%3Fq%3DSanderson",
environ_overrides={"SCRIPT_NAME": "/shelfmark"},
)
assert login_resp.status_code == 302
resp = client.get(
"/api/auth/oidc/callback?code=abc123&state=test-state",
environ_overrides={"SCRIPT_NAME": "/shelfmark"},
)
assert resp.status_code == 302
parsed = urlparse(resp.headers["Location"])
assert parsed.path == "/shelfmark/requests"
assert parse_qs(parsed.query) == {"q": ["Sanderson"]}
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_sets_admin_from_groups(self, mock_get_client, client):
fake_client = Mock()

21
tests/irc/test_cache.py Normal file
View File

@@ -0,0 +1,21 @@
from shelfmark.release_sources import Release
from shelfmark.release_sources.irc import cache
def test_cache_results_isolated_by_content_type(monkeypatch):
state = {"entries": {}, "version": 1}
monkeypatch.setattr(cache, "_load_cache", lambda: state)
monkeypatch.setattr(cache, "_save_cache", lambda _cache: None)
ebook_release = Release(source="irc", source_id="ebook", title="Shared Title", format="epub")
audiobook_release = Release(source="irc", source_id="audio", title="Shared Title", format="zip")
cache.cache_results("hardcover", "123", "Shared Title", [ebook_release], content_type="ebook")
cache.cache_results("hardcover", "123", "Shared Title", [audiobook_release], content_type="audiobook")
ebook_cached = cache.get_cached_results("hardcover", "123", content_type="ebook", ttl_seconds=60)
audiobook_cached = cache.get_cached_results("hardcover", "123", content_type="audiobook", ttl_seconds=60)
assert [release.source_id for release in ebook_cached["releases"]] == ["ebook"]
assert [release.source_id for release in audiobook_cached["releases"]] == ["audio"]

42
tests/irc/test_parser.py Normal file
View File

@@ -0,0 +1,42 @@
from shelfmark.release_sources.irc import parser
def test_parse_results_file_uses_audiobook_format_settings(monkeypatch):
values = {
"SUPPORTED_FORMATS": ["epub"],
"SUPPORTED_AUDIOBOOK_FORMATS": ["zip", "mp3"],
}
monkeypatch.setattr(parser.config, "get", lambda key, default=None: values.get(key, default))
content = "\n".join(
[
"!AudioBot Author Name - Great Audio Book.zip ::INFO:: 1.2GB",
"!AudioBot Author Name - Great Audio Book.mp3 ::INFO:: 1.1GB",
"!AudioBot Author Name - Great Audio Book.epub ::INFO:: 5MB",
]
)
results = parser.parse_results_file(content, content_type="audiobook")
assert [result.format for result in results] == ["zip", "mp3"]
def test_parse_results_file_uses_book_format_settings_for_ebooks(monkeypatch):
values = {
"SUPPORTED_FORMATS": ["epub"],
"SUPPORTED_AUDIOBOOK_FORMATS": ["zip", "mp3"],
}
monkeypatch.setattr(parser.config, "get", lambda key, default=None: values.get(key, default))
content = "\n".join(
[
"!BookBot Author Name - Great Book.zip ::INFO:: 50MB",
"!BookBot Author Name - Great Book.epub ::INFO:: 5MB",
]
)
results = parser.parse_results_file(content, content_type="ebook")
assert [result.format for result in results] == ["epub"]

31
tests/irc/test_source.py Normal file
View File

@@ -0,0 +1,31 @@
from shelfmark.release_sources.irc.parser import SearchResult
from shelfmark.release_sources.irc.source import IRCReleaseSource
def test_convert_to_releases_marks_audiobook_results_and_sorts_audio_before_archives():
source = IRCReleaseSource()
source._online_servers = set()
results = [
SearchResult(
server="AudioBot",
author="Author Name",
title="Archive Release",
format="zip",
size="1.2GB",
full_line="!AudioBot Author Name - Archive Release.zip ::INFO:: 1.2GB",
),
SearchResult(
server="AudioBot",
author="Author Name",
title="Direct Release",
format="m4b",
size="900MB",
full_line="!AudioBot Author Name - Direct Release.m4b ::INFO:: 900MB",
),
]
releases = source._convert_to_releases(results, content_type="audiobook")
assert [release.format for release in releases] == ["m4b", "zip"]
assert all(release.content_type == "audiobook" for release in releases)