From c59ea4654068f52f264c6d76cc918b0d690fbf52 Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:16:34 +0000 Subject: [PATCH] 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 --- .dockerignore | 1 + Makefile | 10 +- docker-compose.dev.yml | 1 + docs/reverse-proxy.md | 27 +- entrypoint.sh | 56 +- scripts/bypasser_permission_lab.sh | 246 +++ shelfmark/bypass/internal_bypasser.py | 55 +- shelfmark/core/activity_routes.py | 348 +++- shelfmark/core/auth_modes.py | 28 +- shelfmark/core/config.py | 2 + shelfmark/core/oidc_routes.py | 72 +- shelfmark/main.py | 10 +- shelfmark/release_sources/direct_download.py | 40 +- shelfmark/release_sources/irc/__init__.py | 2 +- shelfmark/release_sources/irc/cache.py | 24 +- shelfmark/release_sources/irc/client.py | 4 +- shelfmark/release_sources/irc/handler.py | 4 +- shelfmark/release_sources/irc/parser.py | 19 +- shelfmark/release_sources/irc/settings.py | 2 +- shelfmark/release_sources/irc/source.py | 48 +- src/frontend/index.html | 20 +- src/frontend/package-lock.json | 1491 +++++++---------- src/frontend/package.json | 5 +- src/frontend/postcss.config.js | 5 +- src/frontend/public/theme-init.js | 22 + src/frontend/src/App.tsx | 50 +- .../src/components/AdvancedFilters.tsx | 29 +- .../src/components/BookDownloadButton.tsx | 4 +- src/frontend/src/components/BookGetButton.tsx | 4 +- .../src/components/BookTargetDropdown.tsx | 6 +- .../src/components/ConfigSetupBanner.tsx | 26 +- src/frontend/src/components/DetailsModal.tsx | 14 +- src/frontend/src/components/Dropdown.tsx | 7 +- src/frontend/src/components/DropdownList.tsx | 2 +- src/frontend/src/components/Footer.tsx | 2 +- src/frontend/src/components/Header.tsx | 38 +- src/frontend/src/components/LoginForm.tsx | 18 +- .../components/OnBehalfConfirmationModal.tsx | 16 +- .../src/components/OnboardingModal.tsx | 24 +- src/frontend/src/components/ReleaseCell.tsx | 20 +- src/frontend/src/components/ReleaseModal.tsx | 118 +- .../components/RequestConfirmationModal.tsx | 18 +- .../src/components/ResultsSection.tsx | 2 +- src/frontend/src/components/SearchBar.tsx | 138 +- src/frontend/src/components/SearchSection.tsx | 7 + .../src/components/ToastContainer.tsx | 2 +- .../src/components/activity/ActivityCard.tsx | 18 +- .../components/activity/ActivitySidebar.tsx | 12 +- .../src/components/resultsViews/CardView.tsx | 12 +- .../components/resultsViews/CompactView.tsx | 14 +- .../src/components/resultsViews/ListView.tsx | 8 +- .../components/settings/SelfSettingsModal.tsx | 10 +- .../components/settings/SettingsHeader.tsx | 2 +- .../src/components/settings/SettingsModal.tsx | 9 +- .../components/settings/SettingsSidebar.tsx | 32 +- .../settings/customFields/OidcEnvInfo.tsx | 4 +- .../settings/fields/ActionButton.tsx | 2 +- .../settings/fields/HeadingField.tsx | 2 +- .../settings/fields/MultiSelectField.tsx | 6 +- .../settings/fields/NumberField.tsx | 5 +- .../settings/fields/OrderableListField.tsx | 11 +- .../settings/fields/PasswordField.tsx | 7 +- .../settings/fields/SelectField.tsx | 2 +- .../components/settings/fields/TableField.tsx | 17 +- .../settings/fields/TagListField.tsx | 15 +- .../components/settings/fields/TextField.tsx | 5 +- .../settings/shared/FieldWrapper.tsx | 6 +- .../settings/shared/SettingsSaveBar.tsx | 2 +- .../settings/users/RequestPolicyGrid.tsx | 14 +- .../components/settings/users/UserCard.tsx | 28 +- .../settings/users/UserListView.tsx | 9 +- .../settings/users/UserOverridesSections.tsx | 2 +- .../settings/users/UserOverridesView.tsx | 3 +- .../components/shared/SearchFieldRenderer.tsx | 8 +- .../src/components/shared/ToggleSwitch.tsx | 4 +- .../src/components/shared/Tooltip.tsx | 2 +- src/frontend/src/hooks/useActivity.helpers.ts | 6 + src/frontend/src/hooks/useActivity.ts | 7 +- src/frontend/src/hooks/useAuth.ts | 9 +- src/frontend/src/hooks/useSettings.ts | 135 +- src/frontend/src/pages/LoginPage.tsx | 2 +- src/frontend/src/styles.css | 1234 +++++++------- .../src/tests/downloadRecovery.node.test.ts | 50 + .../tests/requestPolicyGridUtils.node.test.ts | 5 +- .../src/tests/useActivity.node.test.ts | 18 + .../src/tests/useSettings.node.test.ts | 67 +- src/frontend/src/utils/authRedirect.ts | 82 + src/frontend/src/utils/downloadRecovery.ts | 33 + src/frontend/src/utils/settingsValues.ts | 106 ++ src/frontend/src/utils/themePreference.ts | 1 + src/frontend/tailwind.config.js | 12 - src/frontend/vite.config.ts | 3 +- tests/config/test_users_settings.py | 9 + tests/core/test_activity_routes_api.py | 63 +- tests/core/test_oidc_integration.py | 15 + tests/core/test_oidc_routes.py | 80 + tests/irc/test_cache.py | 21 + tests/irc/test_parser.py | 42 + tests/irc/test_source.py | 31 + 99 files changed, 3222 insertions(+), 2167 deletions(-) create mode 100755 scripts/bypasser_permission_lab.sh create mode 100644 src/frontend/public/theme-init.js create mode 100644 src/frontend/src/hooks/useActivity.helpers.ts create mode 100644 src/frontend/src/tests/downloadRecovery.node.test.ts create mode 100644 src/frontend/src/tests/useActivity.node.test.ts create mode 100644 src/frontend/src/utils/authRedirect.ts create mode 100644 src/frontend/src/utils/downloadRecovery.ts delete mode 100644 src/frontend/tailwind.config.js create mode 100644 tests/irc/test_cache.py create mode 100644 tests/irc/test_parser.py create mode 100644 tests/irc/test_source.py diff --git a/.dockerignore b/.dockerignore index d12fee2..df06f32 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,7 @@ .git .github .vscode +.local .mypy_cache README_images .gitignore diff --git a/Makefile b/Makefile index 18e7dd7..a2fda70 100644 --- a/Makefile +++ b/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..." diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 1f8b64b..5792795 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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 diff --git a/docs/reverse-proxy.md b/docs/reverse-proxy.md index 6ec9285..294fa2b 100644 --- a/docs/reverse-proxy.md +++ b/docs/reverse-proxy.md @@ -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. diff --git a/entrypoint.sh b/entrypoint.sh index 64114ae..c66fe6f 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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 diff --git a/scripts/bypasser_permission_lab.sh b/scripts/bypasser_permission_lab.sh new file mode 100755 index 0000000..ac53ee7 --- /dev/null +++ b/scripts/bypasser_permission_lab.sh @@ -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 "$@" diff --git a/shelfmark/bypass/internal_bypasser.py b/shelfmark/bypass/internal_bypasser.py index 4e94a65..3c785af 100644 --- a/shelfmark/bypass/internal_bypasser.py +++ b/shelfmark/bypass/internal_bypasser.py @@ -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) diff --git a/shelfmark/core/activity_routes.py b/shelfmark/core/activity_routes.py index 28b27cf..05357d2 100644 --- a/shelfmark/core/activity_routes.py +++ b/shelfmark/core/activity_routes.py @@ -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:"}), 400 + return _activity_error_response( + "dismiss", + status_code=400, + error="item_key must be in the format download:", + 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:"}), 400 + return _activity_error_response( + "dismiss", + status_code=400, + error="item_key must be in the format request:", + 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:"}), 400 + return _activity_error_response( + "dismiss_many", + status_code=400, + error="download item_key must be in the format download:", + 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:"}), 400 + return _activity_error_response( + "dismiss_many", + status_code=400, + error="request item_key must be in the format request:", + 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 diff --git a/shelfmark/core/auth_modes.py b/shelfmark/core/auth_modes.py index 226e50b..ca8c9ea 100644 --- a/shelfmark/core/auth_modes.py +++ b/shelfmark/core/auth_modes.py @@ -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, diff --git a/shelfmark/core/config.py b/shelfmark/core/config.py index ab28e29..c462ec4 100644 --- a/shelfmark/core/config.py +++ b/shelfmark/core/config.py @@ -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: diff --git a/shelfmark/core/oidc_routes.py b/shelfmark/core/oidc_routes.py index 2cd65ea..62b8bd6 100644 --- a/shelfmark/core/oidc_routes.py +++ b/shelfmark/core/oidc_routes.py @@ -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}") diff --git a/shelfmark/main.py b/shelfmark/main.py index 1259459..311e777 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -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: """ diff --git a/shelfmark/release_sources/direct_download.py b/shelfmark/release_sources/direct_download.py index 14050f5..b7ca07a 100644 --- a/shelfmark/release_sources/direct_download.py +++ b/shelfmark/release_sources/direct_download.py @@ -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] diff --git a/shelfmark/release_sources/irc/__init__.py b/shelfmark/release_sources/irc/__init__.py index 26e0023..5739890 100644 --- a/shelfmark/release_sources/irc/__init__.py +++ b/shelfmark/release_sources/irc/__init__.py @@ -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). diff --git a/shelfmark/release_sources/irc/cache.py b/shelfmark/release_sources/irc/cache.py index 2b4874e..03060d0 100644 --- a/shelfmark/release_sources/irc/cache.py +++ b/shelfmark/release_sources/irc/cache.py @@ -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() diff --git a/shelfmark/release_sources/irc/client.py b/shelfmark/release_sources/irc/client.py index 51f78b5..39af4f0 100644 --- a/shelfmark/release_sources/irc/client.py +++ b/shelfmark/release_sources/irc/client.py @@ -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, diff --git a/shelfmark/release_sources/irc/handler.py b/shelfmark/release_sources/irc/handler.py index 614b1ef..d32c3c5 100644 --- a/shelfmark/release_sources/irc/handler.py +++ b/shelfmark/release_sources/irc/handler.py @@ -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]}...") diff --git a/shelfmark/release_sources/irc/parser.py b/shelfmark/release_sources/irc/parser.py index ed1f6ff..7493392 100644 --- a/shelfmark/release_sources/irc/parser.py +++ b/shelfmark/release_sources/irc/parser.py @@ -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) diff --git a/shelfmark/release_sources/irc/settings.py b/shelfmark/release_sources/irc/settings.py index 23d098c..27cb3f3 100644 --- a/shelfmark/release_sources/irc/settings.py +++ b/shelfmark/release_sources/irc/settings.py @@ -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, " diff --git a/shelfmark/release_sources/irc/source.py b/shelfmark/release_sources/irc/source.py index 9b04bc5..112b69a 100644 --- a/shelfmark/release_sources/irc/source.py +++ b/shelfmark/release_sources/irc/source.py @@ -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 diff --git a/src/frontend/index.html b/src/frontend/index.html index 56c016c..9e94bb4 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -19,25 +19,7 @@ Shelfmark - +
diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 8e0fe9f..fdfb342 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -8,19 +8,20 @@ "name": "cwad-frontend", "version": "1.0.0", "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" } @@ -328,7 +329,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -345,7 +345,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -362,7 +361,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -379,7 +377,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -396,7 +393,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -413,7 +409,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -430,7 +425,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -447,7 +441,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -464,7 +457,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -481,7 +473,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -498,7 +489,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -515,7 +505,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -532,7 +521,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -549,7 +537,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -566,7 +553,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -583,7 +569,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -600,7 +585,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -617,7 +601,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -634,7 +617,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -651,7 +633,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -668,7 +649,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -685,7 +665,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -702,7 +681,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -719,7 +697,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -736,7 +713,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -753,7 +729,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -767,7 +742,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -778,7 +752,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -789,7 +762,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -799,58 +771,18 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -874,7 +806,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -888,7 +819,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -902,7 +832,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -916,7 +845,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -930,7 +858,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -944,7 +871,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -958,7 +884,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -972,7 +897,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -986,7 +910,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1000,7 +923,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1014,7 +936,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1028,7 +949,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1042,7 +962,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1056,7 +975,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1070,7 +988,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1084,7 +1001,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1098,7 +1014,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1112,7 +1027,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1126,7 +1040,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1140,7 +1053,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1154,7 +1066,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1168,7 +1079,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1182,7 +1092,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1196,7 +1105,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1210,7 +1118,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1223,6 +1130,277 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.1.tgz", + "integrity": "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "postcss": "^8.5.6", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", + "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1272,14 +1450,13 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/node": { "version": "24.11.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz", "integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -1336,71 +1513,6 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, - "license": "MIT" - }, - "node_modules/autoprefixer": { - "version": "10.4.27", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", - "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001774", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -1414,32 +1526,6 @@ "node": ">=6.0.0" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -1475,16 +1561,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001774", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", @@ -1506,54 +1582,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1561,19 +1589,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1598,19 +1613,14 @@ } } }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, - "license": "MIT" + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } }, "node_modules/electron-to-chromium": { "version": "1.5.302", @@ -1641,11 +1651,23 @@ "node": ">=10.0.0" } }, + "node_modules/enhanced-resolve": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -1693,78 +1715,10 @@ "node": ">=6" } }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1775,16 +1729,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -1795,103 +1739,19 @@ "node": ">=6.9.0" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" }, "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "license": "MIT", - "peer": true, "bin": { - "jiti": "bin/jiti.js" + "jiti": "lib/jiti-cli.mjs" } }, "node_modules/js-tokens": { @@ -1926,25 +1786,254 @@ "node": ">=6" } }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, "engines": { - "node": ">=14" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/antonk52" + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, "node_modules/loose-envify": { "version": "1.4.0", @@ -1968,28 +2057,13 @@ "yallist": "^3.0.2" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/ms": { @@ -1998,23 +2072,10 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -2036,88 +2097,16 @@ "dev": true, "license": "MIT" }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -2133,7 +2122,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2143,161 +2131,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -2367,66 +2200,10 @@ "react-dom": ">=16.8" } }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -2467,30 +2244,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -2542,114 +2295,34 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } + "node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "license": "MIT" }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=6" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tailwindcss": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", - "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -2666,7 +2339,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -2684,7 +2356,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "peer": true, "engines": { @@ -2694,26 +2365,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2732,7 +2383,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -2766,18 +2417,10 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -2853,7 +2496,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -2871,7 +2513,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "peer": true, "engines": { diff --git a/src/frontend/package.json b/src/frontend/package.json index 1298d26..106a8b5 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -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" } diff --git a/src/frontend/postcss.config.js b/src/frontend/postcss.config.js index 2e7af2b..2b4c7ed 100644 --- a/src/frontend/postcss.config.js +++ b/src/frontend/postcss.config.js @@ -1,6 +1,3 @@ export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, + plugins: {}, } diff --git a/src/frontend/public/theme-init.js b/src/frontend/public/theme-init.js new file mode 100644 index 0000000..245c699 --- /dev/null +++ b/src/frontend/public/theme-init.js @@ -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'); +})(); diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 52d795b..8d05973 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -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 => { 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 => { + 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' && ( +

+ Manual search queries release sources directly. Some sources may return limited metadata, which can affect file naming templates. +

+ )} +
+ ) : ( mainAppContent ); @@ -2371,7 +2413,7 @@ function App() { path="/login" element={ shouldRedirectFromLogin ? ( - + ) : ( 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 = (
+ {onClose && ( +
+ +
+ )} {isAdmin && ( <>
@@ -169,7 +194,7 @@ export const AdvancedFilters = ({ return renderWrapper ? ( renderWrapper(settingsForm) ) : ( -
+
{settingsForm}
); diff --git a/src/frontend/src/components/BookDownloadButton.tsx b/src/frontend/src/components/BookDownloadButton.tsx index 5e27972..d5c4964 100644 --- a/src/frontend/src/components/BookDownloadButton.tsx +++ b/src/frontend/src/components/BookDownloadButton.tsx @@ -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; diff --git a/src/frontend/src/components/BookGetButton.tsx b/src/frontend/src/components/BookGetButton.tsx index 47b8ec1..bc12098 100644 --- a/src/frontend/src/components/BookGetButton.tsx +++ b/src/frontend/src/components/BookGetButton.tsx @@ -169,7 +169,7 @@ export const BookGetButton = ({ if (isIconVariant) { return ( @@ -163,8 +163,8 @@ export const ConfigSetupBanner = ({ diff --git a/src/frontend/src/components/DetailsModal.tsx b/src/frontend/src/components/DetailsModal.tsx index 4fb7fdb..e61dfba 100644 --- a/src/frontend/src/components/DetailsModal.tsx +++ b/src/frontend/src/components/DetailsModal.tsx @@ -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 = ({ }} >
-
-
+
+

Book

@@ -198,7 +198,7 @@ export const DetailsModal = ({

) : (
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" > @@ -329,7 +329,7 @@ export const DetailsModal = ({