mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-05-24 22:14:57 -04:00
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:
@@ -1,6 +1,7 @@
|
||||
.git
|
||||
.github
|
||||
.vscode
|
||||
.local
|
||||
.mypy_cache
|
||||
README_images
|
||||
.gitignore
|
||||
|
||||
10
Makefile
10
Makefile
@@ -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..."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
246
scripts/bypasser_permission_lab.sh
Executable file
246
scripts/bypasser_permission_lab.sh
Executable 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 "$@"
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]}...")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, "
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
1491
src/frontend/package-lock.json
generated
1491
src/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
plugins: {},
|
||||
}
|
||||
|
||||
22
src/frontend/public/theme-init.js
Normal file
22
src/frontend/public/theme-init.js
Normal 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');
|
||||
})();
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}'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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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)' }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
6
src/frontend/src/hooks/useActivity.helpers.ts
Normal file
6
src/frontend/src/hooks/useActivity.helpers.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const getActivityErrorMessage = (error: unknown, fallback: string): string => {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
50
src/frontend/src/tests/downloadRecovery.node.test.ts
Normal file
50
src/frontend/src/tests/downloadRecovery.node.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
18
src/frontend/src/tests/useActivity.node.test.ts
Normal file
18
src/frontend/src/tests/useActivity.node.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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']
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
82
src/frontend/src/utils/authRedirect.ts
Normal file
82
src/frontend/src/utils/authRedirect.ts
Normal 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()}`;
|
||||
};
|
||||
33
src/frontend/src/utils/downloadRecovery.ts
Normal file
33
src/frontend/src/utils/downloadRecovery.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
21
tests/irc/test_cache.py
Normal 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
42
tests/irc/test_parser.py
Normal 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
31
tests/irc/test_source.py
Normal 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)
|
||||
Reference in New Issue
Block a user