Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b583d5a365 | |||
| 32fa01cc12 | |||
| ac703eecd2 | |||
| d05de0aacd | |||
| 09b9c45665 | |||
| 32516f6e3b | |||
| 30f1b6e6db | |||
| 7808822a55 | |||
| e805c624b2 | |||
| f7b63fead5 | |||
| 2eb649eceb | |||
| 2076ab76fa | |||
| 2e3e7b3850 | |||
| c447dfe68d | |||
| 688a18af22 | |||
| 18ee77a4e4 | |||
| 3d331e4c72 | |||
| eebc86a52b |
+2
-1
@@ -16,7 +16,7 @@ dir: .
|
|||||||
Trust boundary: index routes, map orients, source decides.
|
Trust boundary: index routes, map orients, source decides.
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Root configuration and documentation hub for a containerized media library management application with FastAPI backend, React frontend, and full observability stack.
|
Root project configuration and orchestration package for a media library management application with observability, defining Docker deployment stacks, environment templates, and project documentation.
|
||||||
## parent
|
## parent
|
||||||
-
|
-
|
||||||
## children
|
## children
|
||||||
@@ -66,6 +66,7 @@ Root configuration and documentation hub for a containerized media library manag
|
|||||||
- docker-compose.dev.yml
|
- docker-compose.dev.yml
|
||||||
- docker-compose.observability.yml
|
- docker-compose.observability.yml
|
||||||
- docker-compose.yml
|
- docker-compose.yml
|
||||||
|
- swap-pane
|
||||||
- token-usage-output.txt
|
- token-usage-output.txt
|
||||||
## links
|
## links
|
||||||
index: ./.pi-map.index.md
|
index: ./.pi-map.index.md
|
||||||
|
|||||||
+3
-2
@@ -18,7 +18,7 @@ index: ./.pi-map.index.md
|
|||||||
Trust boundary: index routes, map orients, source decides.
|
Trust boundary: index routes, map orients, source decides.
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Root configuration and documentation hub for a containerized media library management application with FastAPI backend, React frontend, and full observability stack.
|
Root project configuration and orchestration package for a media library management application with observability, defining Docker deployment stacks, environment templates, and project documentation.
|
||||||
## files
|
## files
|
||||||
- .dockerignore | Specifies files and directories to exclude from Docker build context to reduce image size and improve build performance | dep: Docker
|
- .dockerignore | Specifies files and directories to exclude from Docker build context to reduce image size and improve build performance | dep: Docker
|
||||||
- .env.example | Provides a template of environment variables for configuring application hosts, backend settings, OIDC authentication, SMTP, Grafana, and alerting across a Docker Compose deployment.
|
- .env.example | Provides a template of environment variables for configuring application hosts, backend settings, OIDC authentication, SMTP, Grafana, and alerting across a Docker Compose deployment.
|
||||||
@@ -32,9 +32,10 @@ Root configuration and documentation hub for a containerized media library manag
|
|||||||
- docker-compose.dev.yml | Defines a development Docker Compose stack for a backend (FastAPI/Uvicorn) and frontend (Vite) application with hot-reload and disabled authentication. | dep: uvicorn, Docker
|
- docker-compose.dev.yml | Defines a development Docker Compose stack for a backend (FastAPI/Uvicorn) and frontend (Vite) application with hot-reload and disabled authentication. | dep: uvicorn, Docker
|
||||||
- docker-compose.observability.yml | Defines an optional standalone Docker Compose observability stack with Prometheus, Loki, Grafana, Alertmanager, Alloy, and Node Exporter for monitoring hosts without the main Manage application. | dep: prom/prometheus, grafana/loki, grafana/alloy, grafana/grafana, prom/alertmanager, prom/node-exporter, Traefik
|
- docker-compose.observability.yml | Defines an optional standalone Docker Compose observability stack with Prometheus, Loki, Grafana, Alertmanager, Alloy, and Node Exporter for monitoring hosts without the main Manage application. | dep: prom/prometheus, grafana/loki, grafana/alloy, grafana/grafana, prom/alertmanager, prom/node-exporter, Traefik
|
||||||
- docker-compose.yml | Defines a production Docker Compose stack for a backend-frontend application with OIDC authentication, Traefik routing, TLS, and Prometheus metrics exposure. | dep: Traefik, OIDC provider, Docker, Vite, external observability stack
|
- docker-compose.yml | Defines a production Docker Compose stack for a backend-frontend application with OIDC authentication, Traefik routing, TLS, and Prometheus metrics exposure. | dep: Traefik, OIDC provider, Docker, Vite, external observability stack
|
||||||
|
- swap-pane | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||||
- token-usage-output.txt | Displays a detailed token usage and cost analysis report for an AI coding session, including breakdowns by category, tool usage, cache efficiency, subagent costs, and pricing comparisons.
|
- token-usage-output.txt | Displays a detailed token usage and cost analysis report for an AI coding session, including breakdowns by category, tool usage, cache efficiency, subagent costs, and pricing comparisons.
|
||||||
## arch
|
## arch
|
||||||
Docker Compose-based multi-service architecture with Traefik reverse proxy, OIDC authentication, and optional Prometheus/Grafana/Loki/Alertmanager observability stack; supports dev (hot-reload) and prod (TLS) deployments.
|
Containerized full-stack architecture using Docker Compose for orchestration, Traefik for production routing/TLS, dual dev/production environments, and an optional standalone observability stack (Prometheus/Grafana/Loki/Alertmanager).
|
||||||
## tags
|
## tags
|
||||||
docker, grafana, application, fastapi, compose, prometheus, backend, frontend
|
docker, grafana, application, fastapi, compose, prometheus, backend, frontend
|
||||||
## symbols
|
## symbols
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: backend
|
dir: backend
|
||||||
|
|
||||||
## role
|
## role
|
||||||
FastAPI backend service providing JWT-protected REST APIs for Jellyfin media browsing, SSH file inspection, and server monitoring.
|
FastAPI backend service providing Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected API endpoints.
|
||||||
## parent
|
## parent
|
||||||
index: ./.pi-map.index.md
|
index: ./.pi-map.index.md
|
||||||
map: ./.pi-map.md
|
map: ./.pi-map.md
|
||||||
|
|||||||
+2
-2
@@ -4,13 +4,13 @@ dir: backend
|
|||||||
index: backend/.pi-map.index.md
|
index: backend/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
FastAPI backend service providing JWT-protected REST APIs for Jellyfin media browsing, SSH file inspection, and server monitoring.
|
FastAPI backend service providing Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected API endpoints.
|
||||||
## files
|
## files
|
||||||
- Dockerfile | Builds a Docker container for a Python 3.11 backend API service using uvicorn | dep: python:3.11-slim, pip, uvicorn, pyproject.toml-based package
|
- Dockerfile | Builds a Docker container for a Python 3.11 backend API service using uvicorn | dep: python:3.11-slim, pip, uvicorn, pyproject.toml-based package
|
||||||
- README.md | Documentation describing the setup, configuration, Docker deployment, and API endpoints for a FastAPI backend that provides Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected access. | dep: FastAPI, uvicorn, pydantic-settings, Docker Compose
|
- README.md | Documentation describing the setup, configuration, Docker deployment, and API endpoints for a FastAPI backend that provides Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected access. | dep: FastAPI, uvicorn, pydantic-settings, Docker Compose
|
||||||
- pyproject.toml | Project configuration file defining dependencies, build system, linting, and testing settings for a FastAPI media library viewer backend. | dep: FastAPI, uvicorn, pydantic-settings, paramiko, requests, python-dotenv, pandas, PyJWT, prometheus-client, python-json-logger, cryptography, hatchling, ruff, pytest, httpx
|
- pyproject.toml | Project configuration file defining dependencies, build system, linting, and testing settings for a FastAPI media library viewer backend. | dep: FastAPI, uvicorn, pydantic-settings, paramiko, requests, python-dotenv, pandas, PyJWT, prometheus-client, python-json-logger, cryptography, hatchling, ruff, pytest, httpx
|
||||||
## arch
|
## arch
|
||||||
Layered Python 3.11 API using FastAPI with uvicorn ASGI server, containerized via Docker for deployment.
|
Containerized Python 3.11 REST API using FastAPI/uvicorn with JWT authentication, configured via pyproject.toml with linting and testing support.
|
||||||
## tags
|
## tags
|
||||||
uvicorn, fastapi, python, backend, pyproject, settings, docker, api
|
uvicorn, fastapi, python, backend, pyproject, settings, docker, api
|
||||||
## symbols
|
## symbols
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: backend/src
|
dir: backend/src
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Backend application source directory containing the core business logic, API endpoints, data models, and server configuration for the project.
|
Root source directory serving as the main entry point and organizational container for the backend application.
|
||||||
## parent
|
## parent
|
||||||
index: backend/.pi-map.index.md
|
index: backend/.pi-map.index.md
|
||||||
map: backend/.pi-map.md
|
map: backend/.pi-map.md
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ dir: backend/src
|
|||||||
index: backend/src/.pi-map.index.md
|
index: backend/src/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Backend application source directory containing the core business logic, API endpoints, data models, and server configuration for the project.
|
Root source directory serving as the main entry point and organizational container for the backend application.
|
||||||
## files
|
## files
|
||||||
## arch
|
## arch
|
||||||
Likely follows a layered/n-tier architecture (controllers/services/models) with possible modular or hexagonal patterns; structure depends on framework conventions (e.g., Express, NestJS, Django, Spring Boot).
|
Standard layered architecture entry point, typically initializing the application, wiring up configurations, modules, routes, and services (e.g., MVC, modular monolith, or Clean Architecture).
|
||||||
## tags
|
## tags
|
||||||
-
|
-
|
||||||
## symbols
|
## symbols
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: backend/src/media_library_viewer_api
|
dir: backend/src/media_library_viewer_api
|
||||||
|
|
||||||
## role
|
## role
|
||||||
FastAPI backend service that provides authenticated APIs for viewing, analyzing, and managing media libraries across Jellyfin/Jellyseerr with remote SSH job execution.
|
FastAPI backend service that provides authenticated, observable APIs for viewing and managing media library data across Jellyfin, Jellyseerr, and remote SSH/local systems.
|
||||||
## parent
|
## parent
|
||||||
index: backend/src/.pi-map.index.md
|
index: backend/src/.pi-map.index.md
|
||||||
map: backend/src/.pi-map.md
|
map: backend/src/.pi-map.md
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ dir: backend/src/media_library_viewer_api
|
|||||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
FastAPI backend service that provides authenticated APIs for viewing, analyzing, and managing media libraries across Jellyfin/Jellyseerr with remote SSH job execution.
|
FastAPI backend service that provides authenticated, observable APIs for viewing and managing media library data across Jellyfin, Jellyseerr, and remote SSH/local systems.
|
||||||
## files
|
## files
|
||||||
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||||
- auth.py | Implements OIDC/JWT and API key authentication for a FastAPI backend with middleware-based route protection. | exp: func:_normalize_issuer_url(issuer_url: str) → str, call:issuer_url.rstrip, func:get_oidc_metadata(issuer_url: str) → dict[str, Any], call:_normalize_issuer_url, call:urljoin, call:requests.get, call:response.raise_for_status, call:response.json, call:isinstance, raise:RuntimeError, func:get_jwk_client(jwks_url: str) → PyJWKClient, call:PyJWKClient, func:_split_audience(audience: str) → list[str], call:item.strip, call:audience.split, func:validate_auth_settings(settings: Settings) → None, raise:RuntimeError, func:validate_bearer_jwt(authorization: str | None, settings) → dict[str, Any], call:get_settings, call:validate_auth_settings, call:authorization.partition, call:scheme.lower, call:token.strip, call:_normalize_issuer_url, call:get_oidc_metadata, call:settings.oidc_jwks_url.strip, call:str, call:metadata.get, call:get_jwk_client, call:jwk_client.get_signing_key_from_jwt, call:_split_audience, call:jwt.decode, call:list, call:len, call:int, raise:PermissionError, raise:RuntimeError, func:require_jwt_auth(request: Request, call_next), call:get_settings, call:path.startswith, call:call_next, call:validate_bearer_jwt, call:request.headers.get, call:logger.warning, call:JSONResponse, call:str, call:logger.exception, call:claims.get, call:isinstance, func:get_api_key() → str, call:get_settings_store, call:store.get_settings, call:settings.get, call:secrets.token_urlsafe, call:store.update_setting, func:require_api_key(authorization) → str, call:get_api_key, call:secrets.compare_digest, raise:HTTPException | dep: logging, secrets, functools, typing, urllib.parse, jwt, requests, fastapi, fastapi.responses, jwt.exceptions, media_library_viewer_api.config, media_library_viewer_api.dependencies
|
- auth.py | Implements OIDC/JWT and API key authentication for a FastAPI backend with middleware-based route protection. | exp: func:_normalize_issuer_url(issuer_url: str) → str, call:issuer_url.rstrip, func:get_oidc_metadata(issuer_url: str) → dict[str, Any], call:_normalize_issuer_url, call:urljoin, call:requests.get, call:response.raise_for_status, call:response.json, call:isinstance, raise:RuntimeError, func:get_jwk_client(jwks_url: str) → PyJWKClient, call:PyJWKClient, func:_split_audience(audience: str) → list[str], call:item.strip, call:audience.split, func:validate_auth_settings(settings: Settings) → None, raise:RuntimeError, func:validate_bearer_jwt(authorization: str | None, settings) → dict[str, Any], call:get_settings, call:validate_auth_settings, call:authorization.partition, call:scheme.lower, call:token.strip, call:_normalize_issuer_url, call:get_oidc_metadata, call:settings.oidc_jwks_url.strip, call:str, call:metadata.get, call:get_jwk_client, call:jwk_client.get_signing_key_from_jwt, call:_split_audience, call:jwt.decode, call:list, call:len, call:int, raise:PermissionError, raise:RuntimeError, func:require_jwt_auth(request: Request, call_next), call:get_settings, call:path.startswith, call:call_next, call:validate_bearer_jwt, call:request.headers.get, call:logger.warning, call:JSONResponse, call:str, call:logger.exception, call:claims.get, call:isinstance, func:get_api_key() → str, call:get_settings_store, call:store.get_settings, call:settings.get, call:secrets.token_urlsafe, call:store.update_setting, func:require_api_key(authorization) → str, call:get_api_key, call:secrets.compare_digest, raise:HTTPException | dep: logging, secrets, functools, typing, urllib.parse, jwt, requests, fastapi, fastapi.responses, jwt.exceptions, media_library_viewer_api.config, media_library_viewer_api.dependencies
|
||||||
@@ -18,7 +18,7 @@ FastAPI backend service that provides authenticated APIs for viewing, analyzing,
|
|||||||
- utils.py | Provides UI-framework-independent formatting helpers and ffprobe output summarizers for video, audio, and subtitle streams. | exp: func:ticks_to_minutes(ticks: int | None) → int | None, call:round, func:human_size(num: int | float | None) → str, call:float, call:int, func:timestamp_to_local(ts: float | None) → str, call:datetime.fromtimestamp(ts).strftime, func:is_known_video_file(path: str | None) → bool, call:PurePosixPath(path).suffix.lower, func:format_duration(seconds: str | int | float | None) → str, call:float, call:str, call:int, func:format_bitrate(bit_rate: str | int | float | None) → str, call:float, call:str, func:_tags(stream: dict[str, Any]) → dict[str, Any], call:stream.get, func:_disposition(stream: dict[str, Any], key: str) → str, call:(stream.get("disposition") or {}).get, call:stream.get, func:_side_data_types(stream: dict[str, Any]) → str, call:stream.get, call:item.get, call:values.append, call:", ".join, func:ffprobe_format_summary(ffprobe: dict[str, Any]) → dict[str, str], call:ffprobe.get, call:fmt.get, call:format_duration, call:human_size, call:float, call:format_bitrate, call:str, func:summarize_video_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:_side_data_types, call:tags.get, call:_disposition, func:summarize_audio_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:tags.get, call:_disposition, func:summarize_subtitle_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:tags.get, call:_disposition, func:summarize_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:rows.append, call:format_bitrate, call:stream.get("tags", {}).get | dep: datetime, pathlib, typing
|
- utils.py | Provides UI-framework-independent formatting helpers and ffprobe output summarizers for video, audio, and subtitle streams. | exp: func:ticks_to_minutes(ticks: int | None) → int | None, call:round, func:human_size(num: int | float | None) → str, call:float, call:int, func:timestamp_to_local(ts: float | None) → str, call:datetime.fromtimestamp(ts).strftime, func:is_known_video_file(path: str | None) → bool, call:PurePosixPath(path).suffix.lower, func:format_duration(seconds: str | int | float | None) → str, call:float, call:str, call:int, func:format_bitrate(bit_rate: str | int | float | None) → str, call:float, call:str, func:_tags(stream: dict[str, Any]) → dict[str, Any], call:stream.get, func:_disposition(stream: dict[str, Any], key: str) → str, call:(stream.get("disposition") or {}).get, call:stream.get, func:_side_data_types(stream: dict[str, Any]) → str, call:stream.get, call:item.get, call:values.append, call:", ".join, func:ffprobe_format_summary(ffprobe: dict[str, Any]) → dict[str, str], call:ffprobe.get, call:fmt.get, call:format_duration, call:human_size, call:float, call:format_bitrate, call:str, func:summarize_video_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:_side_data_types, call:tags.get, call:_disposition, func:summarize_audio_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:tags.get, call:_disposition, func:summarize_subtitle_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:tags.get, call:_disposition, func:summarize_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:rows.append, call:format_bitrate, call:stream.get("tags", {}).get | dep: datetime, pathlib, typing
|
||||||
- version.py | Provides version retrieval and formatting utilities for a backend service, falling back through environment variables, package metadata, and default values. | exp: func:get_backend_version() → str, call:os.getenv("APP_VERSION", "").strip, call:package_version, func:get_backend_build_info() → str, call:os.getenv("APP_BUILD_INFO", "").strip, call:os.getenv("GIT_COMMIT", "").strip, call:os.getenv("BUILD_COMMIT", "").strip, func:format_version_label(version: str, build_info: str) → str, call:version.strip, call:build_info.strip, func:get_version_info() → dict[str, str], call:get_backend_version, call:get_backend_build_info, call:format_version_label | dep: os, importlib.metadata
|
- version.py | Provides version retrieval and formatting utilities for a backend service, falling back through environment variables, package metadata, and default values. | exp: func:get_backend_version() → str, call:os.getenv("APP_VERSION", "").strip, call:package_version, func:get_backend_build_info() → str, call:os.getenv("APP_BUILD_INFO", "").strip, call:os.getenv("GIT_COMMIT", "").strip, call:os.getenv("BUILD_COMMIT", "").strip, func:format_version_label(version: str, build_info: str) → str, call:version.strip, call:build_info.strip, func:get_version_info() → dict[str, str], call:get_backend_version, call:get_backend_build_info, call:format_version_label | dep: os, importlib.metadata
|
||||||
## arch
|
## arch
|
||||||
Layered FastAPI architecture using dependency injection, pydantic-settings configuration, middleware-based OIDC/JWT/API-key authentication, template-based remote job execution, and Prometheus-based observability.
|
Layered FastAPI architecture using dependency injection for cached service clients, Pydantic settings configuration, middleware-based OIDC/JWT/API-key authentication, Prometheus observability with structured logging, and template-based remote job execution.
|
||||||
## tags
|
## tags
|
||||||
call:, settings, call:get, request, get, client, call:str, id
|
call:, settings, call:get, request, get, client, call:str, id
|
||||||
## symbols
|
## symbols
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: backend/src/media_library_viewer_api/integrations
|
dir: backend/src/media_library_viewer_api/integrations
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Defines and registers third-party service integrations (e.g., Alertmanager, Grafana, Jellyfin, Prometheus) with typed configs, secrets, and widget definitions for the media library viewer API.
|
Provides a plugin-style integration framework for declaring and registering external service connections (e.g., Grafana, Jellyfin, Prometheus) with config schemas, secrets, and widget definitions for the media library viewer API.
|
||||||
## parent
|
## parent
|
||||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||||
|
|||||||
@@ -4,22 +4,22 @@ dir: backend/src/media_library_viewer_api/integrations
|
|||||||
index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md
|
index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Defines and registers third-party service integrations (e.g., Alertmanager, Grafana, Jellyfin, Prometheus) with typed configs, secrets, and widget definitions for the media library viewer API.
|
Provides a plugin-style integration framework for declaring and registering external service connections (e.g., Grafana, Jellyfin, Prometheus) with config schemas, secrets, and widget definitions for the media library viewer API.
|
||||||
## files
|
## files
|
||||||
- __init__.py | Defines a closed registry module for service integrations.
|
- __init__.py | Defines a closed registry module for service integrations.
|
||||||
- alertmanager.py | Defines the Alertmanager service integration, including connection config, widget definitions, and alert summarization logic. | exp: class:AlertmanagerConfig, class:AlertmanagerAlertsWidgetConfig, func:summarize_alerts(alerts: list[dict[str, Any]], severity_filter) → dict[str, Any], call:alert.get, call:labels.get, call:by_severity.get, call:open_alerts.append, call:annotations.get, call:open_alerts.sort, call:len | dep: typing, media_library_viewer_api.integrations.base
|
- alertmanager.py | Defines the Alertmanager service integration configuration, widget definitions, and alert summarization logic for a media library viewer API. | exp: class:AlertmanagerConfig, class:AlertmanagerAlertsWidgetConfig, func:summarize_alerts(alerts: list[dict[str, Any]], severity_filter) → dict[str, Any], call:alert.get, call:labels.get, call:by_severity.get, call:open_alerts.append, call:annotations.get, call:open_alerts.sort, call:len | dep: typing, media_library_viewer_api.integrations.base
|
||||||
- base.py | Defines base classes and utilities for creating compile-time service integration definitions with Pydantic-based config schemas, secret fields, and widget kinds. | exp: class:ServiceConfigBase, class:WidgetConfigBase, class:SecretField, class:WidgetKind, class:ServiceDefinition, method:widget_kind(self, kind: str) → WidgetKind | None, func:widget_kind(kind: str, name: str, description: str, model_cls: type[WidgetConfigBase], default_config, refresh_interval_ms) → WidgetKind, call:model_cls.model_json_schema, call:schema.pop, call:WidgetKind, call:dict, func:validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) → dict[str, Any], call:model_cls.model_validate, call:instance.model_dump | dep: dataclasses, typing, pydantic
|
- base.py | Provides abstract base classes and dataclass definitions for declaring external service integrations with config schemas, secret fields, and widget kinds. | exp: class:ServiceConfigBase, class:WidgetConfigBase, class:SecretField, class:WidgetKind, class:ServiceDefinition, method:widget_kind(self, kind: str) → WidgetKind | None, func:_validate_service_base_url(value: Any) → str, call:isinstance, call:value.strip, call:text.lower, call:lowered.startswith, raise:ValueError, func:widget_kind(kind: str, name: str, description: str, model_cls: type[WidgetConfigBase], default_config, refresh_interval_ms) → WidgetKind, call:model_cls.model_json_schema, call:schema.pop, call:WidgetKind, call:dict, func:validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) → dict[str, Any], call:model_cls.model_validate, call:instance.model_dump | dep: dataclasses, typing, pydantic
|
||||||
- grafana.py | Defines the Grafana service configuration, secret fields, and widget types for integration with the media library viewer API. | exp: class:GrafanaConfig, class:GrafanaLinkWidgetConfig | dep: media_library_viewer_api.integrations.base
|
- grafana.py | Defines the Grafana service integration configuration, including connection settings, API key secrets, and dashboard link widget support. | exp: class:GrafanaConfig, class:GrafanaLinkWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||||
- jellyfin.py | Defines the Jellyfin media server service configuration and activity widget definition for a media library viewer API. | exp: class:JellyfinConfig, class:JellyfinActivityWidgetConfig | dep: media_library_viewer_api.integrations.base
|
- jellyfin.py | Defines the Jellyfin service configuration and activity widget for a media library viewer API integration. | exp: class:JellyfinConfig, class:JellyfinActivityWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||||
- jellyseerr.py | Defines a service configuration and metadata for Jellyseerr, a request management companion to Jellyfin. | exp: class:JellyseerrConfig | dep: media_library_viewer_api.integrations.base
|
- jellyseerr.py | Defines the Jellyseerr service configuration and its service definition schema for integration as a request management companion to Jellyfin. | exp: class:JellyseerrConfig | dep: media_library_viewer_api.integrations.base
|
||||||
- nextcloud.py | Defines the Nextcloud service configuration model and service definition for integration into the media library viewer API. | exp: class:NextcloudConfig | dep: media_library_viewer_api.integrations.base
|
- nextcloud.py | Defines the Nextcloud service configuration model and service definition for integration into the media library viewer API. | exp: class:NextcloudConfig | dep: media_library_viewer_api.integrations.base
|
||||||
- prometheus.py | Defines the service configuration, widget types, and service definition for integrating Prometheus as a metrics data source. | exp: class:PrometheusConfig, class:PrometheusMetricWidgetConfig | dep: media_library_viewer_api.integrations.base
|
- prometheus.py | Defines the service definition and configuration models for integrating Prometheus as a metrics data source with PromQL query widgets. | exp: class:PrometheusConfig, class:PrometheusMetricWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||||
- registry.py | Provides a closed registry of service definitions with lookup and enumeration functions. | exp: func:list_service_types() → list[str], call:sorted, func:get_service_definition(service_type: str) → ServiceDefinition | None, call:SERVICE_DEFINITIONS.get, func:get_widget_kind(service_type: str, widget_kind: str) → WidgetKind | None, call:get_service_definition, call:definition.widget_kind, func:require_service_definition(service_type: str) → ServiceDefinition, call:get_service_definition, raise:ValueError | dep: media_library_viewer_api.integrations.alertmanager, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.grafana, media_library_viewer_api.integrations.jellyfin, media_library_viewer_api.integrations.jellyseerr, media_library_viewer_api.integrations.nextcloud, media_library_viewer_api.integrations.prometheus, media_library_viewer_api.integrations.ssh_tasks
|
- registry.py | Provides a closed registry of service definitions with lookup and enumeration functions. | exp: func:list_service_types() → list[str], call:sorted, func:get_service_definition(service_type: str) → ServiceDefinition | None, call:SERVICE_DEFINITIONS.get, func:get_widget_kind(service_type: str, widget_kind: str) → WidgetKind | None, call:get_service_definition, call:definition.widget_kind, func:require_service_definition(service_type: str) → ServiceDefinition, call:get_service_definition, raise:ValueError | dep: media_library_viewer_api.integrations.alertmanager, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.grafana, media_library_viewer_api.integrations.jellyfin, media_library_viewer_api.integrations.jellyseerr, media_library_viewer_api.integrations.nextcloud, media_library_viewer_api.integrations.prometheus, media_library_viewer_api.integrations.ssh_tasks
|
||||||
- ssh_tasks.py | Defines a service configuration for an SSH task runner that executes reusable saved tasks over SSH and records run history. | exp: class:SshTasksConfig, class:SshTaskOutputWidgetConfig | dep: media_library_viewer_api.integrations.base
|
- ssh_tasks.py | Defines a service configuration for an SSH task runner that executes reusable saved tasks over SSH and records run history. | exp: class:SshTasksConfig, class:SshTaskOutputWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||||
## arch
|
## arch
|
||||||
Plugin/registry pattern using Pydantic-based config schemas with a closed registry for compile-time service definition lookups, base class extension for per-service widgets and secrets, and a modular file-per-integration structure.
|
Registry pattern with abstract base classes and dataclass-driven configuration models; each integration is a self-contained module registered in a closed registry that supports lookup, enumeration, and declarative widget/kind definitions.
|
||||||
## tags
|
## tags
|
||||||
config, service, widget, integrations, media_library_viewer_api, base, definition, kind
|
config, service, widget, integrations, base, media_library_viewer_api, definition, kind
|
||||||
## symbols
|
## symbols
|
||||||
- AlertmanagerConfig
|
- AlertmanagerConfig
|
||||||
- AlertmanagerAlertsWidgetConfig
|
- AlertmanagerAlertsWidgetConfig
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Any
|
|||||||
|
|
||||||
from media_library_viewer_api.integrations.base import (
|
from media_library_viewer_api.integrations.base import (
|
||||||
SecretField,
|
SecretField,
|
||||||
|
ServiceBaseUrl,
|
||||||
ServiceConfigBase,
|
ServiceConfigBase,
|
||||||
ServiceDefinition,
|
ServiceDefinition,
|
||||||
WidgetConfigBase,
|
WidgetConfigBase,
|
||||||
@@ -16,7 +17,7 @@ from media_library_viewer_api.integrations.base import (
|
|||||||
class AlertmanagerConfig(ServiceConfigBase):
|
class AlertmanagerConfig(ServiceConfigBase):
|
||||||
"""Non-secret Alertmanager connection config."""
|
"""Non-secret Alertmanager connection config."""
|
||||||
|
|
||||||
base_url: str
|
base_url: ServiceBaseUrl
|
||||||
timeout_seconds: int = 5
|
timeout_seconds: int = 5
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,37 @@ map. There is no runtime plugin loading.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, BeforeValidator, Field
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_service_base_url(value: Any) -> str:
|
||||||
|
"""Require an absolute http(s) URL for service ``base_url`` fields.
|
||||||
|
|
||||||
|
Relative hosts (e.g. ``grafana.example.com``) break downstream HTTP clients
|
||||||
|
because ``requests`` treats them as relative paths, so we fail fast with a
|
||||||
|
clear error instead of letting the call silently malfunction.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError("base_url must be a string starting with http:// or https://")
|
||||||
|
text = value.strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("base_url must not be empty")
|
||||||
|
lowered = text.lower()
|
||||||
|
if not (lowered.startswith("http://") or lowered.startswith("https://")):
|
||||||
|
raise ValueError("base_url must start with http:// or https:// (include the schema)")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
#: Shared annotated type for service ``base_url`` fields. applying the validator
|
||||||
|
#: uniformly across every integration so missing schemas are rejected at the
|
||||||
|
#: config boundary with a helpful message.
|
||||||
|
ServiceBaseUrl = Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="Absolute URL including the http:// or https:// schema."),
|
||||||
|
BeforeValidator(_validate_service_base_url),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ServiceConfigBase(BaseModel):
|
class ServiceConfigBase(BaseModel):
|
||||||
@@ -26,6 +54,9 @@ class ServiceConfigBase(BaseModel):
|
|||||||
|
|
||||||
Subclass this in each integration module and declare the connection fields.
|
Subclass this in each integration module and declare the connection fields.
|
||||||
The JSON schema is derived via ``model_json_schema()`` and exposed to the UI.
|
The JSON schema is derived via ``model_json_schema()`` and exposed to the UI.
|
||||||
|
|
||||||
|
Connection URLs should use the :data:`ServiceBaseUrl` type so the
|
||||||
|
``http(s)://`` schema is enforced consistently across integrations.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from media_library_viewer_api.integrations.base import (
|
from media_library_viewer_api.integrations.base import (
|
||||||
SecretField,
|
SecretField,
|
||||||
|
ServiceBaseUrl,
|
||||||
ServiceConfigBase,
|
ServiceConfigBase,
|
||||||
ServiceDefinition,
|
ServiceDefinition,
|
||||||
WidgetConfigBase,
|
WidgetConfigBase,
|
||||||
@@ -14,7 +15,7 @@ from media_library_viewer_api.integrations.base import (
|
|||||||
class GrafanaConfig(ServiceConfigBase):
|
class GrafanaConfig(ServiceConfigBase):
|
||||||
"""Non-secret Grafana connection config."""
|
"""Non-secret Grafana connection config."""
|
||||||
|
|
||||||
base_url: str
|
base_url: ServiceBaseUrl
|
||||||
timeout_seconds: int = 5
|
timeout_seconds: int = 5
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from media_library_viewer_api.integrations.base import (
|
from media_library_viewer_api.integrations.base import (
|
||||||
SecretField,
|
SecretField,
|
||||||
|
ServiceBaseUrl,
|
||||||
ServiceConfigBase,
|
ServiceConfigBase,
|
||||||
ServiceDefinition,
|
ServiceDefinition,
|
||||||
WidgetConfigBase,
|
WidgetConfigBase,
|
||||||
@@ -14,7 +15,7 @@ from media_library_viewer_api.integrations.base import (
|
|||||||
class JellyfinConfig(ServiceConfigBase):
|
class JellyfinConfig(ServiceConfigBase):
|
||||||
"""Non-secret Jellyfin connection config."""
|
"""Non-secret Jellyfin connection config."""
|
||||||
|
|
||||||
base_url: str
|
base_url: ServiceBaseUrl
|
||||||
user_id: str = ""
|
user_id: str = ""
|
||||||
timeout_seconds: int = 10
|
timeout_seconds: int = 10
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from media_library_viewer_api.integrations.base import (
|
from media_library_viewer_api.integrations.base import (
|
||||||
SecretField,
|
SecretField,
|
||||||
|
ServiceBaseUrl,
|
||||||
ServiceConfigBase,
|
ServiceConfigBase,
|
||||||
ServiceDefinition,
|
ServiceDefinition,
|
||||||
)
|
)
|
||||||
@@ -17,7 +18,7 @@ from media_library_viewer_api.integrations.base import (
|
|||||||
class JellyseerrConfig(ServiceConfigBase):
|
class JellyseerrConfig(ServiceConfigBase):
|
||||||
"""Non-secret Jellyseerr connection config."""
|
"""Non-secret Jellyseerr connection config."""
|
||||||
|
|
||||||
base_url: str
|
base_url: ServiceBaseUrl
|
||||||
|
|
||||||
|
|
||||||
DEFINITION = ServiceDefinition(
|
DEFINITION = ServiceDefinition(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from media_library_viewer_api.integrations.base import (
|
from media_library_viewer_api.integrations.base import (
|
||||||
SecretField,
|
SecretField,
|
||||||
|
ServiceBaseUrl,
|
||||||
ServiceConfigBase,
|
ServiceConfigBase,
|
||||||
ServiceDefinition,
|
ServiceDefinition,
|
||||||
)
|
)
|
||||||
@@ -16,7 +17,7 @@ from media_library_viewer_api.integrations.base import (
|
|||||||
class NextcloudConfig(ServiceConfigBase):
|
class NextcloudConfig(ServiceConfigBase):
|
||||||
"""Non-secret Nextcloud connection config."""
|
"""Non-secret Nextcloud connection config."""
|
||||||
|
|
||||||
base_url: str
|
base_url: ServiceBaseUrl
|
||||||
username: str = ""
|
username: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from media_library_viewer_api.integrations.base import (
|
from media_library_viewer_api.integrations.base import (
|
||||||
SecretField,
|
SecretField,
|
||||||
|
ServiceBaseUrl,
|
||||||
ServiceConfigBase,
|
ServiceConfigBase,
|
||||||
ServiceDefinition,
|
ServiceDefinition,
|
||||||
WidgetConfigBase,
|
WidgetConfigBase,
|
||||||
@@ -14,7 +15,7 @@ from media_library_viewer_api.integrations.base import (
|
|||||||
class PrometheusConfig(ServiceConfigBase):
|
class PrometheusConfig(ServiceConfigBase):
|
||||||
"""Non-secret Prometheus connection config."""
|
"""Non-secret Prometheus connection config."""
|
||||||
|
|
||||||
base_url: str
|
base_url: ServiceBaseUrl
|
||||||
timeout_seconds: int = 10
|
timeout_seconds: int = 10
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: backend/tests
|
dir: backend/tests
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Test suite providing unit and integration coverage for the backend's API endpoints, clients, configuration, utilities, and services.
|
Test suite providing unit and integration tests that validate API endpoints, configuration, external clients, utilities, and service logic across the backend.
|
||||||
## parent
|
## parent
|
||||||
index: backend/.pi-map.index.md
|
index: backend/.pi-map.index.md
|
||||||
map: backend/.pi-map.md
|
map: backend/.pi-map.md
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -8,6 +8,7 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from media_library_viewer_api.dependencies import get_settings_store
|
from media_library_viewer_api.dependencies import get_settings_store
|
||||||
from media_library_viewer_api.integrations.registry import (
|
from media_library_viewer_api.integrations.registry import (
|
||||||
@@ -244,7 +245,8 @@ def test_invalid_config_rejected(client):
|
|||||||
"/api/services/instances",
|
"/api/services/instances",
|
||||||
json={"service_type": "grafana", "name": "x", "config": {"base_url": ""}},
|
json={"service_type": "grafana", "name": "x", "config": {"base_url": ""}},
|
||||||
)
|
)
|
||||||
# Pydantic accepts empty string; force a real validation error via bad type.
|
assert response.status_code == 422
|
||||||
|
# Force a real validation error via bad type.
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/services/instances",
|
"/api/services/instances",
|
||||||
json={"service_type": "grafana", "name": "x", "config": {"timeout_seconds": "fast"}},
|
json={"service_type": "grafana", "name": "x", "config": {"timeout_seconds": "fast"}},
|
||||||
@@ -252,6 +254,25 @@ def test_invalid_config_rejected(client):
|
|||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"bad_url", ["grafana.example.com", "localhost:3000", "//grafana.example.com", "ftp://grafana.example.com"]
|
||||||
|
)
|
||||||
|
def test_service_base_url_requires_http_schema(bad_url):
|
||||||
|
"""Every service base_url must include an http:// or https:// schema."""
|
||||||
|
model = get_service_definition("grafana").config_model
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
model.model_validate({"base_url": bad_url, "timeout_seconds": 5})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"service_type", ["grafana", "prometheus", "alertmanager", "jellyfin", "jellyseerr", "nextcloud"]
|
||||||
|
)
|
||||||
|
def test_service_base_url_accepts_absolute_urls(service_type):
|
||||||
|
model = get_service_definition(service_type).config_model
|
||||||
|
instance = model.model_validate({"base_url": "https://example.com"})
|
||||||
|
assert instance.base_url == "https://example.com"
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_secret_field_rejected(client):
|
def test_unknown_secret_field_rejected(client):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/services/instances",
|
"/api/services/instances",
|
||||||
|
|||||||
@@ -271,6 +271,10 @@ Service definitions live as Pydantic modules in the backend
|
|||||||
(`integrations/`); they declare the service config schema, secret fields, and
|
(`integrations/`); they declare the service config schema, secret fields, and
|
||||||
the widget kinds the service provides. There is no runtime plugin loading.
|
the widget kinds the service provides. There is no runtime plugin loading.
|
||||||
|
|
||||||
|
Every service `base_url` uses the shared `ServiceBaseUrl` type, which rejects
|
||||||
|
values missing an `http://` or `https://` schema with a clear validation error
|
||||||
|
(relative hosts break downstream HTTP clients).
|
||||||
|
|
||||||
### Services
|
### Services
|
||||||
|
|
||||||
- **Grafana** — base URL + optional API key; provides a dashboard-link widget.
|
- **Grafana** — base URL + optional API key; provides a dashboard-link widget.
|
||||||
@@ -464,3 +468,57 @@ The system receives backup execution reports from an external backup tool via HT
|
|||||||
|
|
||||||
- Backup tool uses auto-generated Bearer API key
|
- Backup tool uses auto-generated Bearer API key
|
||||||
- Frontend uses existing OIDC/JWT auth
|
- Frontend uses existing OIDC/JWT auth
|
||||||
|
|
||||||
|
## Mobile Responsive Design
|
||||||
|
|
||||||
|
The frontend is fully operable in phone portrait (≥360px) at a single `md:`
|
||||||
|
(768px) breakpoint. Tablets and wider viewports use the desktop layout
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
### Breakpoint policy
|
||||||
|
|
||||||
|
- Single responsive cut: `md:` (768px). Below is "mobile"; at-or-above is
|
||||||
|
"desktop" (existing layout, unchanged).
|
||||||
|
- `useIsMobile()` hook (`frontend/src/hooks/useIsMobile.ts`) is the single
|
||||||
|
source of truth; it wraps `matchMedia("(max-width: 768px)")` and is SSR-safe.
|
||||||
|
- No `sm:` intermediate cut. No PWA, manifest, or service worker.
|
||||||
|
|
||||||
|
### Data tables (hybrid)
|
||||||
|
|
||||||
|
- The four wide tables (Media, FileBrowser, Users, Backups) render stacked
|
||||||
|
**cards per row** below `md` via `MobileCardRow`, each showing a primary
|
||||||
|
title plus 3–5 key fields. Narrow tables (SessionActivity) keep horizontal
|
||||||
|
scroll. The TanStack column-visibility toggle is hidden below `md`.
|
||||||
|
- At `md:` and above, all tables render as the existing `<DataTable>` unchanged.
|
||||||
|
|
||||||
|
### Edit forms (Sheet)
|
||||||
|
|
||||||
|
- Below `md`, ServicePage, Settings (machine editor), message compose, and
|
||||||
|
WidgetConfigDialog open inside a full-height `SheetForm` (side=bottom,
|
||||||
|
`h-[100dvh]`) with sticky header + sticky save bar instead of a centered
|
||||||
|
Dialog.
|
||||||
|
- At `md:` and above, the existing Dialog-based forms are unchanged.
|
||||||
|
|
||||||
|
### Touch targets
|
||||||
|
|
||||||
|
- All interactive elements below `md` have a minimum 44×44px hit area via the
|
||||||
|
`.mobile-touch-target` CSS utility (applied only below 768px). This covers
|
||||||
|
icon buttons, checkboxes, switches, and small text buttons. The class is a
|
||||||
|
no-op at `md:` and above.
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
- Below `md`, the widget grid collapses to a single column with a section
|
||||||
|
anchor bar (Observability / Media / Backups / Custom) for quick navigation.
|
||||||
|
- At `md:` and above, the existing multi-widget grid is unchanged.
|
||||||
|
|
||||||
|
### Polling
|
||||||
|
|
||||||
|
- Widget refresh intervals and the message-queue poll interval are identical
|
||||||
|
on mobile and desktop. A follow-up to pause refetch when the tab is hidden
|
||||||
|
(`document.visibilityState`) is tracked as a future battery optimization.
|
||||||
|
|
||||||
|
### `HoverEditButton`
|
||||||
|
|
||||||
|
- Below `md`, edit affordances are always visible (not hover-gated). At `md:`
|
||||||
|
and above, the desktop hover-reveal aesthetic is preserved.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: frontend
|
dir: frontend
|
||||||
|
|
||||||
## role
|
## role
|
||||||
React-based single-page application frontend for the "Manage" project, built with Vite, TypeScript, Tailwind CSS, and shadcn/ui.
|
Frontend SPA for the "Manage" application, built with React, Vite, and TypeScript, providing the user interface with OIDC authentication and API integration.
|
||||||
## parent
|
## parent
|
||||||
index: ./.pi-map.index.md
|
index: ./.pi-map.index.md
|
||||||
map: ./.pi-map.md
|
map: ./.pi-map.md
|
||||||
|
|||||||
+3
-3
@@ -4,11 +4,11 @@ dir: frontend
|
|||||||
index: frontend/.pi-map.index.md
|
index: frontend/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
React-based single-page application frontend for the "Manage" project, built with Vite, TypeScript, Tailwind CSS, and shadcn/ui.
|
Frontend SPA for the "Manage" application, built with React, Vite, and TypeScript, providing the user interface with OIDC authentication and API integration.
|
||||||
## files
|
## files
|
||||||
- .gitignore | Specifies files and directories for Git to ignore in a Node.js/frontend project
|
- .gitignore | Specifies files and directories for Git to ignore in a Node.js/frontend project
|
||||||
- Dockerfile | Multi-stage Dockerfile for building and serving a Vite-based frontend application with separate production (nginx) and development (node dev server) targets | dep: node:22-alpine, nginx:1.27-alpine, npm, vite
|
- Dockerfile | Multi-stage Dockerfile for building and serving a Vite-based frontend application with separate production (nginx) and development (node dev server) targets | dep: node:22-alpine, nginx:1.27-alpine, npm, vite
|
||||||
- README.md | Documentation for the Manage Frontend React SPA, covering setup, development, build, pages, environment variables, and configuration workflows.
|
- README.md | Documentation for the Manage frontend SPA, describing its tech stack, setup, pages, configuration, and deployment workflow. | dep: React, TypeScript, Vite, @tanstack/react-query, @tanstack/react-table, react-router-dom, Tailwind CSS, shadcn/ui
|
||||||
- components.json | Configuration file for shadcn/ui component library setup with Tailwind CSS and path aliases | dep: shadcn/ui, tailwindcss, lucide-react, radix-ui
|
- components.json | Configuration file for shadcn/ui component library setup with Tailwind CSS and path aliases | dep: shadcn/ui, tailwindcss, lucide-react, radix-ui
|
||||||
- eslint.config.js | Configures ESLint for a TypeScript React project using Vite with recommended rules for JS, TS, React Hooks, and React Refresh. | dep: @eslint/js, globals, eslint-plugin-react-hooks, eslint-plugin-react-refresh, typescript-eslint, eslint/config
|
- eslint.config.js | Configures ESLint for a TypeScript React project using Vite with recommended rules for JS, TS, React Hooks, and React Refresh. | dep: @eslint/js, globals, eslint-plugin-react-hooks, eslint-plugin-react-refresh, typescript-eslint, eslint/config
|
||||||
- index.html | Standard HTML entry point for a React/Vite single-page application named "Manage" | dep: React (implied by root div and TSX entry), Vite (implied by module script and /src path)
|
- index.html | Standard HTML entry point for a React/Vite single-page application named "Manage" | dep: React (implied by root div and TSX entry), Vite (implied by module script and /src path)
|
||||||
@@ -23,7 +23,7 @@ React-based single-page application frontend for the "Manage" project, built wit
|
|||||||
- vite.config.ts | Configures Vite build tool with React, Tailwind CSS, environment-based API proxying, and path aliasing for a frontend application. | dep: path, vite, @vitejs/plugin-react, @tailwindcss/vite
|
- vite.config.ts | Configures Vite build tool with React, Tailwind CSS, environment-based API proxying, and path aliasing for a frontend application. | dep: path, vite, @vitejs/plugin-react, @tailwindcss/vite
|
||||||
- vitest.config.ts | Configures Vitest test runner for a React project with jsdom environment, path aliasing, and scoped test file inclusion. | dep: path, vitest/config, @vitejs/plugin-react, vitest, jsdom
|
- vitest.config.ts | Configures Vitest test runner for a React project with jsdom environment, path aliasing, and scoped test file inclusion. | dep: path, vitest/config, @vitejs/plugin-react, vitest, jsdom
|
||||||
## arch
|
## arch
|
||||||
Component-driven SPA architecture using Vite for build tooling, PostCSS/Tailwind for styling, OIDC for authentication, Vitest for testing, with multi-stage Docker deployment supporting both nginx (production) and Node dev server (development) targets.
|
Component-based React architecture styled with Tailwind CSS/shadcn/ui, bundled via Vite, containerized through multi-stage Docker builds (Nginx for production, Node dev server for development), with Vitest for testing and ESLint for code quality.
|
||||||
## tags
|
## tags
|
||||||
react, @babel, vite, helper, typescript, project, css, eslint
|
react, @babel, vite, helper, typescript, project, css, eslint
|
||||||
## symbols
|
## symbols
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: frontend/src
|
dir: frontend/src
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Frontend application entry point and core infrastructure providing routing, OIDC authentication, global state, and styling for a React-based admin dashboard.
|
Frontend React application providing an admin dashboard UI with OIDC authentication, dark mode, and user activity management for a Jellyfin media server.
|
||||||
## parent
|
## parent
|
||||||
index: frontend/.pi-map.index.md
|
index: frontend/.pi-map.index.md
|
||||||
map: frontend/.pi-map.md
|
map: frontend/.pi-map.md
|
||||||
|
|||||||
@@ -4,21 +4,21 @@ dir: frontend/src
|
|||||||
index: frontend/src/.pi-map.index.md
|
index: frontend/src/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Frontend application entry point and core infrastructure providing routing, OIDC authentication, global state, and styling for a React-based admin dashboard.
|
Frontend React application providing an admin dashboard UI with OIDC authentication, dark mode, and user activity management for a Jellyfin media server.
|
||||||
## files
|
## files
|
||||||
- App.tsx | Main application component that renders a responsive admin dashboard with OIDC authentication, dark mode, collapsible sidebar navigation, and route-based page rendering. | exp: func:App() | dep: react-router-dom, @tanstack/react-query, react, react-oidc-context, ./pages/Dashboard, ./pages/Applications, ./pages/Settings, ./pages/Users, ./pages/FileBrowser, ./pages/Actions, ./components/BackupsPage, ./components/ObservabilityPage, ./pages/ServicePage, ./pages/ServicesPage, ./auth, ./api/client, ./version, ./hooks/usePersistentState, @/components/ui/button, @/components/ui/tooltip, @/components/ui/sheet, lucide-react, ./pages/*, ./components/*
|
- App.tsx | Main application component that renders a responsive admin dashboard with OIDC authentication, dark mode, collapsible sidebar navigation, and route-based page rendering. | exp: func:App() | dep: react-router-dom, @tanstack/react-query, react, react-oidc-context, ./pages/Dashboard, ./pages/Applications, ./pages/Settings, ./pages/Users, ./pages/FileBrowser, ./pages/Actions, ./components/BackupsPage, ./components/ObservabilityPage, ./pages/ServicePage, ./pages/ServicesPage, ./auth, ./api/client, ./version, ./hooks/usePersistentState, @/components/ui/button, @/components/ui/tooltip, @/components/ui/sheet, lucide-react, ./pages/*, ./components/*
|
||||||
- auth.ts | Manages OIDC authentication configuration and access token retrieval for a browser-based application. | exp: func:isOidcConfigured() → boolean, call:(import.meta.env.VITE_OIDC_ENABLED ?? "true").toLowerCase, call:Boolean, func:getOidcConfig(), call:window.history.replaceState, func:setAccessToken(token: string | null | undefined), func:getAccessToken() → string | null, call:getStoredAccessToken | dep: oidc-client-ts
|
- auth.ts | Manages OIDC authentication configuration and access token retrieval for a browser-based application. | exp: func:isOidcConfigured() → boolean, call:(import.meta.env.VITE_OIDC_ENABLED ?? "true").toLowerCase, call:Boolean, func:getOidcConfig(), call:window.history.replaceState, func:setAccessToken(token: string | null | undefined), func:getAccessToken() → string | null, call:getStoredAccessToken | dep: oidc-client-ts
|
||||||
- index.css | Defines a comprehensive Tailwind CSS v4 theme with custom design tokens, dark mode support, and base styles for a React application. | dep: tailwindcss, Google Fonts (Inter)
|
- index.css | Defines a comprehensive Tailwind CSS v4 theme with custom design tokens, dark mode support, and base styles for a React application. | dep: tailwindcss, Google Fonts (Inter)
|
||||||
- main.tsx | Entry point that renders the React application into the DOM root element with StrictMode enabled. | dep: react, react-dom/client, ./App, ./index.css
|
- main.tsx | Entry point that renders the React application into the DOM root element with StrictMode enabled. | dep: react, react-dom/client, ./App, ./index.css
|
||||||
- userState.d.ts | Defines TypeScript type declarations for user activity state management, including interfaces for user activity summaries and state items, plus function declarations for merging users with activity data and resolving user selections. | exp: UserActivitySummary, UserStateItem, mergeUsersWithActivity, resolveUserSelection | dep: ./types, types (NowPlayingSession, UserDirectoryItem)
|
- userState.d.ts | Defines TypeScript type declarations for user activity state management, including interfaces for user activity summaries and state items, plus function declarations for merging users with activity data and resolving user selections. | exp: UserActivitySummary, UserStateItem, mergeUsersWithActivity, resolveUserSelection | dep: ./types, types (NowPlayingSession, UserDirectoryItem)
|
||||||
- userState.js | Merges Jellyfin users with their session activity data and provides user lookup by identifier. | exp: func:mergeUsersWithActivity(users, sessions), call:users.map, call:sessions.filter, call:sessionMatchesUser, call:buildActivitySummary, func:resolveUserSelection(users, identifier), call:normalize, call:users.find, call:userKeys(user).some
|
- userState.js | Merges Jellyfin users with their session activity data and provides user lookup by identifier.
|
||||||
- users.d.ts | Defines TypeScript interfaces and a function declaration for building a user drawer model from directory data. | exp: UserDetailField, UserContactAction, UserContactState, UserDrawerModel, buildUserDrawerModel | dep: ./types, types
|
- users.d.ts | Defines TypeScript interfaces and a function declaration for building a user drawer model from directory data. | exp: UserDetailField, UserContactAction, UserContactState, UserDrawerModel, buildUserDrawerModel | dep: ./types, types
|
||||||
- users.js | Transforms raw user data into a structured UI model for a user details drawer component. | exp: func:buildUserDrawerModel(user), call:displayName, call:splitValues, call:String, call:user.role.charAt(0).toUpperCase, call:user.role.slice, call:permissions.join, call:syncStatus
|
- users.js | Transforms raw user data into a structured UI model for a user details drawer component. | exp: func:buildUserDrawerModel(user), call:displayName, call:splitValues, call:String, call:user.role.charAt(0).toUpperCase, call:user.role.slice, call:permissions.join, call:syncStatus
|
||||||
- version.ts | Exports frontend version and build info constants with a formatter for semantic version labels | exp: FRONTEND_VERSION, FRONTEND_BUILD_INFO, FRONTEND_VERSION_LABEL, func:formatVersionLabel(version: string, buildInfo: string) → string, call:version.trim, call:buildInfo.trim
|
- version.ts | Exports frontend version and build info constants with a formatter for semantic version labels | exp: FRONTEND_VERSION, FRONTEND_BUILD_INFO, FRONTEND_VERSION_LABEL, func:formatVersionLabel(version: string, buildInfo: string) → string, call:version.trim, call:buildInfo.trim
|
||||||
## arch
|
## arch
|
||||||
React SPA architecture using component-based UI with StrictMode bootstrapping, TypeScript type definitions paired with JavaScript logic modules, and Tailwind CSS v4 theming with dark mode support.
|
Component-based React SPA architecture using Tailwind CSS v4 theming, TypeScript type definitions, and utility modules for authentication and user state transformation.
|
||||||
## tags
|
## tags
|
||||||
user, activity, version, state, react, pages, users, oidc
|
user, version, state, react, pages, oidc, activity, drawer
|
||||||
## symbols
|
## symbols
|
||||||
- App
|
- App
|
||||||
- isOidcConfigured
|
- isOidcConfigured
|
||||||
|
|||||||
+15
-14
@@ -28,6 +28,7 @@ import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
|||||||
import { fetchAppVersion } from "./api/client";
|
import { fetchAppVersion } from "./api/client";
|
||||||
import { FRONTEND_VERSION_LABEL } from "./version";
|
import { FRONTEND_VERSION_LABEL } from "./version";
|
||||||
import { usePersistentState } from "./hooks/usePersistentState";
|
import { usePersistentState } from "./hooks/usePersistentState";
|
||||||
|
import { useIsMobile } from "./hooks/useIsMobile";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -61,7 +62,16 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
// Pause interval-based refetches (widgets ~30s, queue status 5s,
|
||||||
|
// media build progress 1s) when the tab is hidden. Saves battery on
|
||||||
|
// mobile (D8 follow-up). Build progress polls resume on return.
|
||||||
|
refetchIntervalInBackground: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function useDarkMode() {
|
function useDarkMode() {
|
||||||
@@ -193,7 +203,7 @@ function MobileDrawer() {
|
|||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={setOpen}>
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="md:hidden">
|
<Button variant="ghost" size="icon" className="mobile-touch-target md:hidden">
|
||||||
<Menu className="h-5 w-5" />
|
<Menu className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
@@ -280,7 +290,7 @@ function TopBar({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onToggleDarkMode}
|
onClick={onToggleDarkMode}
|
||||||
className="h-8 w-8"
|
className="mobile-touch-target h-8 w-8"
|
||||||
>
|
>
|
||||||
{darkMode ? (
|
{darkMode ? (
|
||||||
<Sun className="h-4 w-4" />
|
<Sun className="h-4 w-4" />
|
||||||
@@ -293,7 +303,7 @@ function TopBar({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onSignOut}
|
onClick={onSignOut}
|
||||||
className="gap-2"
|
className="mobile-touch-target gap-2"
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Logout</span>
|
<span className="hidden sm:inline">Logout</span>
|
||||||
@@ -316,16 +326,7 @@ function ShellLayout({
|
|||||||
onToggleDarkMode: () => void;
|
onToggleDarkMode: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const [isMobile, setIsMobile] = useState(
|
const isMobile = useIsMobile();
|
||||||
() => window.matchMedia("(max-width: 768px)").matches,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const mql = window.matchMedia("(max-width: 768px)");
|
|
||||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
|
||||||
mql.addEventListener("change", handler);
|
|
||||||
return () => mql.removeEventListener("change", handler);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
MobileCardRow,
|
||||||
|
type MobileCardField,
|
||||||
|
} from "@/components/ui/mobile-card";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
import type { BackupAlert } from "../types/backups";
|
import type { BackupAlert } from "../types/backups";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -29,7 +34,51 @@ function severityVariant(severity: string): SeverityVariant {
|
|||||||
return severity === "critical" ? "destructive" : "warning";
|
return severity === "critical" ? "destructive" : "warning";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile card fields (spec R3.2): message is the primary identifier;
|
||||||
|
// severity/type/created give the at-a-glance info. See OpenSpec change
|
||||||
|
// `mobile-responsive-parity`, tasks slice 5.2.
|
||||||
|
const alertCardFields: MobileCardField<BackupAlert>[] = [
|
||||||
|
{ key: "message", label: "Message", render: (a) => a.message, primary: true },
|
||||||
|
{
|
||||||
|
key: "severity",
|
||||||
|
label: "Severity",
|
||||||
|
render: (a) => (
|
||||||
|
<Badge variant={severityVariant(a.severity)}>{a.severity}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "type", label: "Type", render: (a) => a.alert_type },
|
||||||
|
{
|
||||||
|
key: "created",
|
||||||
|
label: "Created",
|
||||||
|
render: (a) => formatTimestamp(a.created_at),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function BackupAlertsTable({ alerts, onAcknowledge }: Props) {
|
export default function BackupAlertsTable({ alerts, onAcknowledge }: Props) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<MobileCardRow
|
||||||
|
rows={alerts}
|
||||||
|
fields={alertCardFields}
|
||||||
|
getRowId={(a) => a.id}
|
||||||
|
actions={(a) =>
|
||||||
|
!a.acknowledged ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={() => onAcknowledge(a.id)}
|
||||||
|
>
|
||||||
|
Ack
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg border border-border">
|
<div className="overflow-hidden rounded-lg border border-border">
|
||||||
<Table aria-label="Backup alerts">
|
<Table aria-label="Backup alerts">
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
MobileCardRow,
|
||||||
|
type MobileCardField,
|
||||||
|
} from "@/components/ui/mobile-card";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
import type { BackupJob, BackupRun } from "../types/backups";
|
import type { BackupJob, BackupRun } from "../types/backups";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -41,7 +46,54 @@ function statusVariant(status: string): StatusVariant {
|
|||||||
return "secondary";
|
return "secondary";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile card fields (spec R3.2): job name is primary; source/schedule/status
|
||||||
|
// give at-a-glance context. See OpenSpec change `mobile-responsive-parity`.
|
||||||
|
interface JobCardRow {
|
||||||
|
job: BackupJob;
|
||||||
|
status: string;
|
||||||
|
run_started: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobCardFields: MobileCardField<JobCardRow>[] = [
|
||||||
|
{ key: "name", label: "Name", render: (r) => r.job.name, primary: true },
|
||||||
|
{
|
||||||
|
key: "source",
|
||||||
|
label: "Source",
|
||||||
|
render: (r) => r.job.source ?? "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "schedule",
|
||||||
|
label: "Schedule",
|
||||||
|
render: (r) => formatInterval(r.job.schedule_interval_seconds),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "Last status",
|
||||||
|
render: (r) => <Badge variant={statusVariant(r.status)}>{r.status}</Badge>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function BackupJobsTable({ jobs, latestRuns }: Props) {
|
export default function BackupJobsTable({ jobs, latestRuns }: Props) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
const cardRows: JobCardRow[] = jobs.map((job) => {
|
||||||
|
const run = latestRuns.get(job.id);
|
||||||
|
return {
|
||||||
|
job,
|
||||||
|
status: run?.status ?? "unknown",
|
||||||
|
run_started: run?.started_at ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<MobileCardRow
|
||||||
|
rows={cardRows}
|
||||||
|
fields={jobCardFields}
|
||||||
|
getRowId={(r) => r.job.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg border border-border">
|
<div className="overflow-hidden rounded-lg border border-border">
|
||||||
<Table aria-label="Backup jobs">
|
<Table aria-label="Backup jobs">
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
MobileCardRow,
|
||||||
|
type MobileCardField,
|
||||||
|
} from "@/components/ui/mobile-card";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
import type { BackupRun } from "../types/backups";
|
import type { BackupRun } from "../types/backups";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -55,8 +60,35 @@ function statusVariant(status: string): StatusVariant {
|
|||||||
return "warning";
|
return "warning";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile card fields (spec R3.2): job_id is primary; status/duration/size/
|
||||||
|
// started give the at-a-glance info. See OpenSpec change `mobile-responsive-parity`.
|
||||||
|
const runCardFields: MobileCardField<BackupRun>[] = [
|
||||||
|
{ key: "job", label: "Job", render: (r) => r.job_id, primary: true },
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "Status",
|
||||||
|
render: (r) => <Badge variant={statusVariant(r.status)}>{r.status}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "duration",
|
||||||
|
label: "Duration",
|
||||||
|
render: (r) => formatDuration(r.duration_ms),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "size",
|
||||||
|
label: "Size",
|
||||||
|
render: (r) => formatBytes(r.bytes_transferred),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "started",
|
||||||
|
label: "Started",
|
||||||
|
render: (r) => formatTimestamp(r.started_at),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function BackupRunsTable({ runs }: Props) {
|
export default function BackupRunsTable({ runs }: Props) {
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const filteredRuns =
|
const filteredRuns =
|
||||||
statusFilter === "all"
|
statusFilter === "all"
|
||||||
@@ -77,34 +109,42 @@ export default function BackupRunsTable({ runs }: Props) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border border-border">
|
{isMobile ? (
|
||||||
<Table aria-label="Backup runs">
|
<MobileCardRow
|
||||||
<TableHeader>
|
rows={filteredRuns}
|
||||||
<TableRow className="bg-card hover:bg-card">
|
fields={runCardFields}
|
||||||
<TableHead>Job</TableHead>
|
getRowId={(r) => r.id}
|
||||||
<TableHead>Status</TableHead>
|
/>
|
||||||
<TableHead>Duration</TableHead>
|
) : (
|
||||||
<TableHead>Size</TableHead>
|
<div className="overflow-hidden rounded-lg border border-border">
|
||||||
<TableHead>Started</TableHead>
|
<Table aria-label="Backup runs">
|
||||||
</TableRow>
|
<TableHeader>
|
||||||
</TableHeader>
|
<TableRow className="bg-card hover:bg-card">
|
||||||
<TableBody>
|
<TableHead>Job</TableHead>
|
||||||
{filteredRuns.map((run) => (
|
<TableHead>Status</TableHead>
|
||||||
<TableRow key={run.id}>
|
<TableHead>Duration</TableHead>
|
||||||
<TableCell>{run.job_id}</TableCell>
|
<TableHead>Size</TableHead>
|
||||||
<TableCell>
|
<TableHead>Started</TableHead>
|
||||||
<Badge variant={statusVariant(run.status)}>
|
|
||||||
{run.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{formatDuration(run.duration_ms)}</TableCell>
|
|
||||||
<TableCell>{formatBytes(run.bytes_transferred)}</TableCell>
|
|
||||||
<TableCell>{formatTimestamp(run.started_at)}</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{filteredRuns.map((run) => (
|
||||||
</div>
|
<TableRow key={run.id}>
|
||||||
|
<TableCell>{run.job_id}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusVariant(run.status)}>
|
||||||
|
{run.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDuration(run.duration_ms)}</TableCell>
|
||||||
|
<TableCell>{formatBytes(run.bytes_transferred)}</TableCell>
|
||||||
|
<TableCell>{formatTimestamp(run.started_at)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,11 @@ export function DialogFooter({
|
|||||||
}: DialogFooterProps) {
|
}: DialogFooterProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row flex-wrap items-center justify-end gap-2">
|
<div className="flex flex-row flex-wrap items-center justify-end gap-2">
|
||||||
<Button variant="ghost" onClick={onCancel}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
{cancelLabel}
|
{cancelLabel}
|
||||||
</Button>
|
</Button>
|
||||||
{secondaryAction ? (
|
{secondaryAction ? (
|
||||||
@@ -59,6 +63,7 @@ export function DialogFooter({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
variant={resolveConfirmVariant(confirmColor, confirmVariant)}
|
variant={resolveConfirmVariant(confirmColor, confirmVariant)}
|
||||||
disabled={confirmDisabled}
|
disabled={confirmDisabled}
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
|
|||||||
@@ -4,26 +4,48 @@ import { Button } from "@/components/ui/button";
|
|||||||
interface HoverEditButtonProps {
|
interface HoverEditButtonProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
/** Controls visibility below the `md:` (768px) breakpoint.
|
||||||
|
*
|
||||||
|
* - `always` (default): the button is always visible on mobile/touch.
|
||||||
|
* - `hover`: keep the legacy opacity-0-everywhere behavior.
|
||||||
|
*
|
||||||
|
* At `md:` and above the hover-reveal aesthetic is always preserved
|
||||||
|
* (`md:opacity-0 md:group-hover:opacity-100`), so desktop is not regressed.
|
||||||
|
* See OpenSpec change `mobile-responsive-parity`, spec R5. */
|
||||||
|
mobile?: "always" | "hover";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hover-to-reveal edit affordance.
|
* Hover-to-reveal edit affordance (desktop) / always-visible (mobile).
|
||||||
*
|
*
|
||||||
* Keeps the `rail-edit` class plus the opacity-0 base + transition so the
|
* Keeps the `rail-edit` class plus the opacity base + transition so the
|
||||||
* existing hover-reveal rules in consuming pages (Actions, Settings) still
|
* existing hover-reveal rules in consuming pages (Actions, Settings) still
|
||||||
* target it (`&:hover .rail-edit { opacity: 1 }`) until those pages migrate.
|
* target it (`&:hover .rail-edit { opacity: 1 }`) until those pages migrate.
|
||||||
* MUI IconButton + EditOutlined → shadcn `Button variant="ghost" size="icon-sm"`
|
*
|
||||||
* + lucide `Pencil`. Same exported props/display name.
|
* Mobile behavior (`mobile="always"`, the default): the button is visible by
|
||||||
|
* default below `md` because hover does not fire on touch. The hover-reveal
|
||||||
|
* aesthetic is layered back on at `md:` and above via `md:opacity-0
|
||||||
|
* md:group-hover:opacity-100`. MUI IconButton + EditOutlined → shadcn `Button
|
||||||
|
* variant="ghost" size="icon-sm"` + lucide `Pencil`. Same exported props/display
|
||||||
|
* name. See OpenSpec change `mobile-responsive-parity`, spec R5.
|
||||||
*/
|
*/
|
||||||
export function HoverEditButton({
|
export function HoverEditButton({
|
||||||
onClick,
|
onClick,
|
||||||
label = "Edit",
|
label = "Edit",
|
||||||
|
mobile = "always",
|
||||||
}: HoverEditButtonProps) {
|
}: HoverEditButtonProps) {
|
||||||
|
// Legacy mode: opacity-0 everywhere, revealed by group hover (the consuming
|
||||||
|
// row supplies `group`).
|
||||||
|
const hoverClasses =
|
||||||
|
mobile === "hover"
|
||||||
|
? "opacity-0 transition-opacity duration-100 ease-out group-hover:opacity-100"
|
||||||
|
: "md:opacity-0 md:transition-opacity md:duration-100 md:ease-out md:group-hover:opacity-100";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="rail-edit text-muted-foreground opacity-0 transition-opacity duration-100 ease-out"
|
className={`rail-edit text-muted-foreground mobile-touch-target ${hoverClasses}`}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ function QueryError({
|
|||||||
<AlertTitle>{label} failed</AlertTitle>
|
<AlertTitle>{label} failed</AlertTitle>
|
||||||
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<span className="break-words">{error.message}</span>
|
<span className="break-words">{error.message}</span>
|
||||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
<Button variant="outline" size="sm" className="mobile-touch-target" onClick={() => refetch()}>
|
||||||
<RefreshCw className="mr-1 h-3 w-3" />
|
<RefreshCw className="mr-1 h-3 w-3" />
|
||||||
Retry
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
@@ -255,7 +255,7 @@ function GrafanaLinkCard({
|
|||||||
<div className="font-medium">{title}</div>
|
<div className="font-medium">{title}</div>
|
||||||
<div className="text-sm text-muted-foreground">{description}</div>
|
<div className="text-sm text-muted-foreground">{description}</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" asChild>
|
<Button variant="outline" size="sm" className="mobile-touch-target" asChild>
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -585,7 +585,7 @@ export function ObservabilityPage() {
|
|||||||
title="No Node Exporter targets"
|
title="No Node Exporter targets"
|
||||||
description="Enable Node Exporter on an SSH machine in Settings to populate Prometheus scrape targets."
|
description="Enable Node Exporter on an SSH machine in Settings to populate Prometheus scrape targets."
|
||||||
action={
|
action={
|
||||||
<Button variant="outline" size="sm" asChild>
|
<Button variant="outline" size="sm" className="mobile-touch-target" asChild>
|
||||||
<Link to="/settings">Open Settings</Link>
|
<Link to="/settings">Open Settings</Link>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -641,7 +641,7 @@ export function ObservabilityPage() {
|
|||||||
title="No Grafana service configured"
|
title="No Grafana service configured"
|
||||||
description="Add a Grafana service instance to enable deep-links to dashboards and logs."
|
description="Add a Grafana service instance to enable deep-links to dashboards and logs."
|
||||||
action={
|
action={
|
||||||
<Button variant="outline" size="sm" asChild>
|
<Button variant="outline" size="sm" className="mobile-touch-target" asChild>
|
||||||
<Link to="/services">Open Services</Link>
|
<Link to="/services">Open Services</Link>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -653,7 +653,7 @@ export function ObservabilityPage() {
|
|||||||
title="No machine selected"
|
title="No machine selected"
|
||||||
description="Add monitoring machines in Settings to see Grafana drill-down links."
|
description="Add monitoring machines in Settings to see Grafana drill-down links."
|
||||||
action={
|
action={
|
||||||
<Button variant="outline" size="sm" asChild>
|
<Button variant="outline" size="sm" className="mobile-touch-target" asChild>
|
||||||
<Link to="/settings">Open Settings</Link>
|
<Link to="/settings">Open Settings</Link>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ export function SessionActivityPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="mobile-touch-target"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onSelectSession(session);
|
onSelectSession(session);
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
} from "../hooks/useWidgets";
|
} from "../hooks/useWidgets";
|
||||||
import { useServiceInstances } from "../hooks/useServices";
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
import { useTasks } from "../hooks/useSettings";
|
import { useTasks } from "../hooks/useSettings";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
|
import { SheetForm } from "@/components/ui/sheet-form";
|
||||||
import type { WidgetInstance, WidgetInstanceInput } from "../types";
|
import type { WidgetInstance, WidgetInstanceInput } from "../types";
|
||||||
import {
|
import {
|
||||||
BUILTIN_WIDGETS,
|
BUILTIN_WIDGETS,
|
||||||
@@ -277,201 +279,220 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
|||||||
]?.widgets.find((w) => w.kind === draft.widgetKind)
|
]?.widgets.find((w) => w.kind === draft.widgetKind)
|
||||||
: BUILTIN_WIDGETS[draft.widgetKind]
|
: BUILTIN_WIDGETS[draft.widgetKind]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const isTaskOutput =
|
const isTaskOutput =
|
||||||
draft?.serviceId !== null &&
|
draft?.serviceId !== null &&
|
||||||
services.find((s) => s.id === draft?.serviceId)?.service_type ===
|
services.find((s) => s.id === draft?.serviceId)?.service_type ===
|
||||||
"ssh_tasks";
|
"ssh_tasks";
|
||||||
|
|
||||||
|
// The draft body (Title/SortOrder/Enabled/config editor) is shared between
|
||||||
|
// the Dialog (desktop) and SheetForm (mobile). On mobile the inline
|
||||||
|
// Back/Save buttons are omitted because the SheetForm footer provides them.
|
||||||
|
const draftBody = draft ? (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Title" htmlFor="widget-title">
|
||||||
|
<Input
|
||||||
|
id="widget-title"
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Sort order" htmlFor="widget-sort-order">
|
||||||
|
<Input
|
||||||
|
id="widget-sort-order"
|
||||||
|
type="number"
|
||||||
|
value={String(draft.sortOrder)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
sortOrder: e.target.value === "" ? 0 : Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="widget-enabled"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
checked={draft.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setDraft({ ...draft, enabled: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="widget-enabled">Enabled</Label>
|
||||||
|
</div>
|
||||||
|
<WidgetConfigEditor
|
||||||
|
binding={draftBinding}
|
||||||
|
isTaskOutput={!!isTaskOutput}
|
||||||
|
config={draft.config}
|
||||||
|
onChange={(config) => setDraft({ ...draft, config })}
|
||||||
|
tasks={tasks}
|
||||||
|
/>
|
||||||
|
{!isMobile ? (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={reset} className="mobile-touch-target">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={saveDraft} disabled={saveWidget.isPending} className="mobile-touch-target">
|
||||||
|
Save widget
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{sortedInstances.length === 0 ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>No widgets yet. Add one below.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{sortedInstances.map((instance, index) => {
|
||||||
|
const serviceName = instance.service_id
|
||||||
|
? services.find((s) => s.id === instance.service_id)?.name
|
||||||
|
: "Built-in";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={instance.id}
|
||||||
|
className="flex items-center gap-2 rounded border p-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{instance.title}</span>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{bindingLabel(instance.service_id, instance.widget_kind)}
|
||||||
|
</Badge>
|
||||||
|
{serviceName ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{serviceName}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{!instance.enabled ? (
|
||||||
|
<Badge variant="secondary">disabled</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mobile-touch-target h-8 w-8"
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => moveInstance(index, -1)}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mobile-touch-target h-8 w-8"
|
||||||
|
disabled={index === sortedInstances.length - 1}
|
||||||
|
onClick={() => moveInstance(index, 1)}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Switch
|
||||||
|
className="mobile-touch-target"
|
||||||
|
checked={instance.enabled}
|
||||||
|
onCheckedChange={() => toggleEnabled(instance)}
|
||||||
|
aria-label={`Toggle ${instance.title}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mobile-touch-target h-8 w-8"
|
||||||
|
onClick={() => startEdit(instance)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mobile-touch-target h-8 w-8 text-destructive"
|
||||||
|
onClick={() => removeInstance(instance)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-sm font-medium">Add widget</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.values(BUILTIN_WIDGETS).map((b) => (
|
||||||
|
<Button
|
||||||
|
key={b.kind}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={() => startAddBuiltIn(b.kind)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
{b.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
{services
|
||||||
|
.filter((s) => s.enabled)
|
||||||
|
.flatMap((s) =>
|
||||||
|
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map((w) => (
|
||||||
|
<Button
|
||||||
|
key={`${s.id}:${w.kind}`}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={() => startAddService(s.id, w.kind)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
{w.name} · {s.name}
|
||||||
|
</Button>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Configure services on their service pages to unlock more widgets.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialogTitle = draft
|
||||||
|
? draft.id
|
||||||
|
? "Edit widget"
|
||||||
|
: "Add widget"
|
||||||
|
: "Dashboard widgets";
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<SheetForm
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) handleClose(next);
|
||||||
|
}}
|
||||||
|
title={dialogTitle}
|
||||||
|
onSave={draft ? saveDraft : () => handleClose(false)}
|
||||||
|
onCancel={draft ? reset : () => handleClose(false)}
|
||||||
|
saveLabel={draft ? "Save widget" : "Done"}
|
||||||
|
isPending={draft ? saveWidget.isPending : false}
|
||||||
|
isDirty={draft !== null}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">{draftBody}</div>
|
||||||
|
</SheetForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-2xl">
|
<DialogContent className="sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||||
{draft
|
|
||||||
? draft.id
|
|
||||||
? "Edit widget"
|
|
||||||
: "Add widget"
|
|
||||||
: "Dashboard widgets"}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
{draftBody}
|
||||||
{draft ? (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<Field label="Title" htmlFor="widget-title">
|
|
||||||
<Input
|
|
||||||
id="widget-title"
|
|
||||||
value={draft.title}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft({ ...draft, title: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Sort order" htmlFor="widget-sort-order">
|
|
||||||
<Input
|
|
||||||
id="widget-sort-order"
|
|
||||||
type="number"
|
|
||||||
value={String(draft.sortOrder)}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft({
|
|
||||||
...draft,
|
|
||||||
sortOrder:
|
|
||||||
e.target.value === "" ? 0 : Number(e.target.value),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
id="widget-enabled"
|
|
||||||
checked={draft.enabled}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setDraft({ ...draft, enabled: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="widget-enabled">Enabled</Label>
|
|
||||||
</div>
|
|
||||||
<WidgetConfigEditor
|
|
||||||
binding={draftBinding}
|
|
||||||
isTaskOutput={!!isTaskOutput}
|
|
||||||
config={draft.config}
|
|
||||||
onChange={(config) => setDraft({ ...draft, config })}
|
|
||||||
tasks={tasks}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="outline" onClick={reset}>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button onClick={saveDraft} disabled={saveWidget.isPending}>
|
|
||||||
Save widget
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{sortedInstances.length === 0 ? (
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription>
|
|
||||||
No widgets yet. Add one below.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{sortedInstances.map((instance, index) => {
|
|
||||||
const serviceName = instance.service_id
|
|
||||||
? services.find((s) => s.id === instance.service_id)?.name
|
|
||||||
: "Built-in";
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={instance.id}
|
|
||||||
className="flex items-center gap-2 rounded border p-2"
|
|
||||||
>
|
|
||||||
<div className="flex flex-1 flex-col gap-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium">{instance.title}</span>
|
|
||||||
<Badge variant="outline">
|
|
||||||
{bindingLabel(
|
|
||||||
instance.service_id,
|
|
||||||
instance.widget_kind,
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
{serviceName ? (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{serviceName}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{!instance.enabled ? (
|
|
||||||
<Badge variant="secondary">disabled</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
disabled={index === 0}
|
|
||||||
onClick={() => moveInstance(index, -1)}
|
|
||||||
>
|
|
||||||
<ChevronUp className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
disabled={index === sortedInstances.length - 1}
|
|
||||||
onClick={() => moveInstance(index, 1)}
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Switch
|
|
||||||
checked={instance.enabled}
|
|
||||||
onCheckedChange={() => toggleEnabled(instance)}
|
|
||||||
aria-label={`Toggle ${instance.title}`}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={() => startEdit(instance)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-destructive"
|
|
||||||
onClick={() => removeInstance(instance)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<p className="text-sm font-medium">Add widget</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{Object.values(BUILTIN_WIDGETS).map((b) => (
|
|
||||||
<Button
|
|
||||||
key={b.kind}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => startAddBuiltIn(b.kind)}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
|
||||||
{b.name}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
{services
|
|
||||||
.filter((s) => s.enabled)
|
|
||||||
.flatMap((s) =>
|
|
||||||
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map(
|
|
||||||
(w) => (
|
|
||||||
<Button
|
|
||||||
key={`${s.id}:${w.kind}`}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => startAddService(s.id, w.kind)}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
|
||||||
{w.name} · {s.name}
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Configure services on their service pages to unlock more
|
|
||||||
widgets.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import BackupAlertsTable from "../BackupAlertsTable";
|
import BackupAlertsTable from "../BackupAlertsTable";
|
||||||
@@ -61,3 +61,46 @@ describe("BackupAlertsTable", () => {
|
|||||||
expect(screen.queryByRole("button", { name: "Acknowledge" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "Acknowledge" })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// jsdom lacks matchMedia; default to desktop so existing tests are unaffected.
|
||||||
|
function setMatchMedia(matches: boolean) {
|
||||||
|
window.matchMedia = ((query: string) => ({
|
||||||
|
matches: query.includes("768") ? matches : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => setMatchMedia(false));
|
||||||
|
|
||||||
|
describe("BackupAlertsTable (mobile card layout — slice 5)", () => {
|
||||||
|
it("renders cards with message as primary below md", () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(
|
||||||
|
<BackupAlertsTable
|
||||||
|
alerts={[alert({ id: "m1", message: "Disk full" })]}
|
||||||
|
onAcknowledge={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Disk full")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Severity")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders acknowledge action on card below md", async () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
const onAck = vi.fn();
|
||||||
|
render(
|
||||||
|
<BackupAlertsTable
|
||||||
|
alerts={[alert({ id: "a1", acknowledged: false })]}
|
||||||
|
onAcknowledge={onAck}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Ack" }));
|
||||||
|
expect(onAck).toHaveBeenCalledWith("a1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import BackupJobsTable from "../BackupJobsTable";
|
||||||
|
import type { BackupJob, BackupRun } from "../../types/backups";
|
||||||
|
|
||||||
|
function job(overrides: Partial<BackupJob> = {}): BackupJob {
|
||||||
|
return {
|
||||||
|
id: "j1",
|
||||||
|
name: "nightly",
|
||||||
|
source: "/data",
|
||||||
|
target: "s3://bucket",
|
||||||
|
schedule_interval_seconds: 86400,
|
||||||
|
created_at: 1_700_000_000,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(overrides: Partial<BackupRun> = {}): BackupRun {
|
||||||
|
return {
|
||||||
|
id: "r1",
|
||||||
|
job_id: "j1",
|
||||||
|
started_at: 1_700_000_000,
|
||||||
|
ended_at: null,
|
||||||
|
status: "success",
|
||||||
|
bytes_transferred: 2048,
|
||||||
|
duration_ms: 1500,
|
||||||
|
error_message: null,
|
||||||
|
details_json: null,
|
||||||
|
created_at: 1_700_000_000,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsdom lacks matchMedia; default to desktop so the table renders.
|
||||||
|
function setMatchMedia(matches: boolean) {
|
||||||
|
window.matchMedia = ((query: string) => ({
|
||||||
|
matches: query.includes("768") ? matches : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => setMatchMedia(false));
|
||||||
|
|
||||||
|
describe("BackupJobsTable (desktop)", () => {
|
||||||
|
it("renders job name and schedule interval", () => {
|
||||||
|
render(
|
||||||
|
<BackupJobsTable
|
||||||
|
jobs={[job({ name: "nightly", schedule_interval_seconds: 86400 })]}
|
||||||
|
latestRuns={new Map()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("nightly")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1d")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BackupJobsTable (mobile card layout — slice 5)", () => {
|
||||||
|
it("renders cards with job name as primary below md", () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(
|
||||||
|
<BackupJobsTable
|
||||||
|
jobs={[job({ id: "j1", name: "nightly", source: "/data" })]}
|
||||||
|
latestRuns={
|
||||||
|
new Map([["j1", run({ status: "success" })]]) as Map<string, BackupRun>
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("nightly")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Source")).toHaveLength(1);
|
||||||
|
expect(screen.getAllByText("Schedule")).toHaveLength(1);
|
||||||
|
expect(screen.getAllByText("Last status")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import BackupRunsTable from "../BackupRunsTable";
|
import BackupRunsTable from "../BackupRunsTable";
|
||||||
import type { BackupRun } from "../../types/backups";
|
import type { BackupRun } from "../../types/backups";
|
||||||
@@ -57,3 +57,29 @@ describe("BackupRunsTable", () => {
|
|||||||
expect(screen.getByText("2.0 KB")).toBeInTheDocument();
|
expect(screen.getByText("2.0 KB")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// jsdom lacks matchMedia; default to desktop so existing tests are unaffected.
|
||||||
|
function setMatchMedia(matches: boolean) {
|
||||||
|
window.matchMedia = ((query: string) => ({
|
||||||
|
matches: query.includes("768") ? matches : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => setMatchMedia(false));
|
||||||
|
|
||||||
|
describe("BackupRunsTable (mobile card layout — slice 5)", () => {
|
||||||
|
it("renders cards with job_id as primary below md", () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(<BackupRunsTable runs={[run({ id: "r1", job_id: "nightly" })]} />);
|
||||||
|
expect(screen.getByText("nightly")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Status")).toHaveLength(1);
|
||||||
|
expect(screen.getAllByText("Duration")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,4 +18,23 @@ describe("HoverEditButton", () => {
|
|||||||
screen.getByRole("button", { name: "Rename machine" }),
|
screen.getByRole("button", { name: "Rename machine" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('defaults to always-visible below md (mobile="always")', () => {
|
||||||
|
render(<HoverEditButton onClick={() => {}} />);
|
||||||
|
const button = screen.getByRole("button", { name: "Edit" });
|
||||||
|
const tokens = button.className.split(/\s+/);
|
||||||
|
// The default mobile mode layers hover-reveal only at md+ via
|
||||||
|
// md:opacity-0/md:group-hover:opacity-100, so the button is visible by
|
||||||
|
// default below md (no base opacity-0 token).
|
||||||
|
expect(tokens).toContain("md:opacity-0");
|
||||||
|
expect(tokens).toContain("md:group-hover:opacity-100");
|
||||||
|
expect(tokens).not.toContain("opacity-0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the legacy opacity-0 behavior when mobile="hover"', () => {
|
||||||
|
render(<HoverEditButton onClick={() => {}} mobile="hover" />);
|
||||||
|
const button = screen.getByRole("button", { name: "Edit" });
|
||||||
|
expect(button.className).toContain("opacity-0");
|
||||||
|
expect(button.className).toContain("group-hover:opacity-100");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { WidgetConfigDialog } from "../WidgetConfigDialog";
|
||||||
|
|
||||||
|
// jsdom has no window.matchMedia; default to desktop (matches: false).
|
||||||
|
function setMatchMedia(matches: boolean) {
|
||||||
|
window.matchMedia = ((query: string) => ({
|
||||||
|
matches: query.includes("768") ? matches : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useWidgets", () => ({
|
||||||
|
useWidgetInstances: () => ({ data: [] }),
|
||||||
|
useSaveWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||||
|
useDeleteWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
|
useServiceInstances: () => ({ data: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useSettings", () => ({
|
||||||
|
useTasks: () => ({ data: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => setMatchMedia(false));
|
||||||
|
|
||||||
|
describe("WidgetConfigDialog (desktop)", () => {
|
||||||
|
it("renders a Dialog with the dashboard widgets title at md+", () => {
|
||||||
|
render(<WidgetConfigDialog open={true} onClose={() => {}} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Dashboard widgets" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WidgetConfigDialog (mobile SheetForm — slice 8)", () => {
|
||||||
|
beforeEach(() => setMatchMedia(true));
|
||||||
|
|
||||||
|
it("renders a SheetForm with the dashboard widgets title below md", () => {
|
||||||
|
render(<WidgetConfigDialog open={true} onClose={() => {}} />);
|
||||||
|
expect(screen.getByText("Dashboard widgets")).toBeInTheDocument();
|
||||||
|
// List mode footer: "Done" button closes.
|
||||||
|
expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompts before discarding a widget draft (R4.5)", async () => {
|
||||||
|
const { userEvent } = await import("@testing-library/user-event");
|
||||||
|
render(<WidgetConfigDialog open={true} onClose={() => {}} />);
|
||||||
|
|
||||||
|
// Enter draft mode by clicking an "Add widget" button.
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /Backups/i }));
|
||||||
|
|
||||||
|
// Now in draft mode — Cancel should prompt before resetting.
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Discard changes?" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MobileCardRow, type MobileCardField } from "../mobile-card";
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
size: string;
|
||||||
|
year: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: "a", title: "Movie A", size: "4.2GB", year: 2026 },
|
||||||
|
{ id: "b", title: "Movie B", size: "2.1GB", year: 2025 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fields: MobileCardField<Row>[] = [
|
||||||
|
{ key: "title", label: "Title", render: (r) => r.title, primary: true },
|
||||||
|
{ key: "size", label: "Size", render: (r) => r.size },
|
||||||
|
{ key: "year", label: "Year", render: (r) => r.year },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("MobileCardRow", () => {
|
||||||
|
it("renders the primary field as a title and the rest as key/value pairs", () => {
|
||||||
|
render(<MobileCardRow rows={rows} fields={fields} />);
|
||||||
|
|
||||||
|
// Primary title
|
||||||
|
expect(screen.getByText("Movie A")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Movie B")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Field labels and values (appear once per row)
|
||||||
|
expect(screen.getAllByText("Size")).toHaveLength(2);
|
||||||
|
expect(screen.getAllByText("4.2GB")).toHaveLength(1);
|
||||||
|
expect(screen.getAllByText("Year")).toHaveLength(2);
|
||||||
|
expect(screen.getAllByText("2026")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires onRowClick when the card is tapped", async () => {
|
||||||
|
const onRowClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<MobileCardRow rows={rows} fields={fields} onRowClick={onRowClick} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText("Movie A"));
|
||||||
|
expect(onRowClick).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onRowClick).toHaveBeenCalledWith(rows[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the actions slot per row", () => {
|
||||||
|
render(
|
||||||
|
<MobileCardRow
|
||||||
|
rows={rows}
|
||||||
|
fields={fields}
|
||||||
|
actions={(r) => (
|
||||||
|
<button type="button" onClick={() => undefined}>
|
||||||
|
edit-{r.id}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("edit-a")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("edit-b")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a non-interactive card when onRowClick is absent", () => {
|
||||||
|
render(<MobileCardRow rows={rows} fields={fields} />);
|
||||||
|
// No buttons wrapping the cards.
|
||||||
|
expect(screen.queryAllByRole("button")).toHaveLength(0);
|
||||||
|
expect(screen.getByText("Movie A")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when rows is empty", () => {
|
||||||
|
const { container } = render(<MobileCardRow rows={[]} fields={fields} />);
|
||||||
|
const cards = container.querySelector(".flex.flex-col.gap-2");
|
||||||
|
expect(cards?.children).toHaveLength(0);
|
||||||
|
expect(screen.queryByText("Size")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a card without a title when no primary field is set", () => {
|
||||||
|
const noPrimary: MobileCardField<Row>[] = fields.filter(
|
||||||
|
(f) => f.key !== "title",
|
||||||
|
);
|
||||||
|
render(<MobileCardRow rows={rows} fields={noPrimary} />);
|
||||||
|
// No title text rendered, but the key/value stack still is.
|
||||||
|
expect(screen.queryByText("Movie A")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Size")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses getRowId for stable keys and emits no duplicate-key warning", () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
render(<MobileCardRow rows={rows} fields={fields} getRowId={(r) => r.id} />);
|
||||||
|
// No React duplicate-key warning should fire.
|
||||||
|
const duplicateKeyCalls = errorSpy.mock.calls.filter((args) =>
|
||||||
|
String(args[0] ?? "").includes("same key"),
|
||||||
|
);
|
||||||
|
expect(duplicateKeyCalls).toHaveLength(0);
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { SheetForm } from "../sheet-form";
|
||||||
|
|
||||||
|
describe("SheetForm", () => {
|
||||||
|
it("renders the title and children", () => {
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit service"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
>
|
||||||
|
<input aria-label="Name" />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Edit service")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Name")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onSave when Save is clicked", async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={onSave}
|
||||||
|
onCancel={() => {}}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
expect(onSave).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onCancel when Cancel is clicked", async () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={onCancel}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables Save and shows a pending label when isPending", () => {
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
isPending
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole("button", { name: /Saving/i });
|
||||||
|
expect(saveButton).toBeDisabled();
|
||||||
|
expect(screen.getByText("Saving…")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onCancel when the close (X) button is clicked", async () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={onCancel}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dirty-state confirm (R4.5)", () => {
|
||||||
|
it("prompts before discarding via Cancel when isDirty", async () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={onCancel}
|
||||||
|
isDirty
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cancel does not immediately close; a confirm opens.
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onCancel).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Discard changes?" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Confirm discard -> actually closes.
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Discard" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closing the confirm without discarding keeps the form open", async () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={onCancel}
|
||||||
|
isDirty
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
// Two Cancel buttons now exist: the SheetForm footer and the confirm dialog.
|
||||||
|
const cancelButtons = screen.getAllByRole("button", { name: "Cancel" });
|
||||||
|
await userEvent.click(cancelButtons[cancelButtons.length - 1]);
|
||||||
|
expect(onCancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes immediately when not dirty", async () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={onCancel}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("heading", { name: "Discard changes?" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
type OnChangeFn,
|
type OnChangeFn,
|
||||||
type PaginationState,
|
type PaginationState,
|
||||||
type RowSelectionState,
|
type RowSelectionState,
|
||||||
type Table as TableInstance,
|
|
||||||
type VisibilityState,
|
type VisibilityState,
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -18,6 +17,7 @@ import { Columns3 } from "lucide-react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { TablePagination } from "@/components/ui/table-pagination";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -34,13 +34,6 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
|
|
||||||
export interface DataTableProps<TData, TValue = unknown> {
|
export interface DataTableProps<TData, TValue = unknown> {
|
||||||
columns: ColumnDef<TData, TValue>[];
|
columns: ColumnDef<TData, TValue>[];
|
||||||
@@ -253,90 +246,19 @@ export function DataTable<TData, TValue = unknown>({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{enablePagination && (
|
{enablePagination && (
|
||||||
<DataTablePagination
|
<TablePagination
|
||||||
table={table}
|
pageIndex={table.getState().pagination.pageIndex}
|
||||||
|
pageSize={table.getState().pagination.pageSize}
|
||||||
pageSizeOptions={pageSizeOptions}
|
pageSizeOptions={pageSizeOptions}
|
||||||
|
totalRows={manualPagination ? (rowCount ?? 0) : table.getRowModel().rows.length}
|
||||||
pageCount={pageCount}
|
pageCount={pageCount}
|
||||||
manual={manualPagination}
|
onPaginationChange={table.setPagination}
|
||||||
rowCount={rowCount}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaginationProps<TData> {
|
// DataTablePagination was extracted into the shared TablePagination component
|
||||||
table: TableInstance<TData>;
|
// (frontend/src/components/ui/table-pagination.tsx). Both the desktop DataTable
|
||||||
pageSizeOptions: number[];
|
// and the Media mobile card list consume it.
|
||||||
pageCount: number;
|
|
||||||
manual: boolean;
|
|
||||||
rowCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataTablePagination<TData>({
|
|
||||||
table,
|
|
||||||
pageSizeOptions,
|
|
||||||
pageCount,
|
|
||||||
manual,
|
|
||||||
rowCount,
|
|
||||||
}: PaginationProps<TData>) {
|
|
||||||
const pageIndex = table.getState().pagination.pageIndex;
|
|
||||||
const pageSize = table.getState().pagination.pageSize;
|
|
||||||
const visibleRows = table.getRowModel().rows.length;
|
|
||||||
const totalRows = manual ? (rowCount ?? 0) : visibleRows;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
{`${totalRows} row${totalRows === 1 ? "" : "s"}`}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-muted-foreground">Rows per page</span>
|
|
||||||
<Select
|
|
||||||
value={String(pageSize)}
|
|
||||||
onValueChange={(value) => table.setPageSize(Number(value))}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
size="sm"
|
|
||||||
className="w-[70px]"
|
|
||||||
aria-label="Rows per page"
|
|
||||||
>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{pageSizeOptions.map((option) => (
|
|
||||||
<SelectItem key={option} value={String(option)}>
|
|
||||||
{option}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Page {pageIndex + 1} of {pageCount}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => table.previousPage()}
|
|
||||||
disabled={!table.getCanPreviousPage()}
|
|
||||||
aria-label="Previous page"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => table.nextPage()}
|
|
||||||
disabled={!table.getCanNextPage()}
|
|
||||||
aria-label="Next page"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field descriptor for a {@link MobileCardRow}.
|
||||||
|
*
|
||||||
|
* The consuming page decides which fields to show and in what order; this
|
||||||
|
* primitive does not pick them. Exactly one field should set `primary: true` —
|
||||||
|
* it renders as the card title (bold, larger). The rest render as a key/value
|
||||||
|
* stack below the title.
|
||||||
|
*/
|
||||||
|
export interface MobileCardField<T> {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
render: (row: T) => React.ReactNode;
|
||||||
|
/** When true, render as the card title (bold, larger). One per card. */
|
||||||
|
primary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MobileCardRowProps<T> {
|
||||||
|
rows: T[];
|
||||||
|
fields: MobileCardField<T>[];
|
||||||
|
/** Stable per-row identity; falls back to the row index when omitted. */
|
||||||
|
getRowId?: (row: T) => string;
|
||||||
|
/** When set, the whole card becomes a button (44px min height). */
|
||||||
|
onRowClick?: (row: T) => void;
|
||||||
|
/** Optional right-aligned action slot (edit/delete icon buttons). */
|
||||||
|
actions?: (row: T) => React.ReactNode;
|
||||||
|
/** Optional className for the outer list container. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stacked card list for wide tables below the `md:` breakpoint.
|
||||||
|
*
|
||||||
|
* Each row renders as a card: the `primary` field as the title and the
|
||||||
|
* remaining fields as a key/value stack. When `onRowClick` is provided the
|
||||||
|
* whole card is a button with a 44px minimum touch target (spec R6.1). An
|
||||||
|
* optional `actions` slot renders right-aligned controls.
|
||||||
|
*
|
||||||
|
* This is the mobile counterpart to {@link DataTable}; pages branch on
|
||||||
|
* `useIsMobile()`. See OpenSpec change `mobile-responsive-parity`, design
|
||||||
|
* §`MobileCardRow`.
|
||||||
|
*/
|
||||||
|
export function MobileCardRow<T>({
|
||||||
|
rows,
|
||||||
|
fields,
|
||||||
|
getRowId,
|
||||||
|
onRowClick,
|
||||||
|
actions,
|
||||||
|
className,
|
||||||
|
}: MobileCardRowProps<T>) {
|
||||||
|
const primary = fields.find((f) => f.primary);
|
||||||
|
const rest = fields.filter((f) => !f.primary);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-2", className)}>
|
||||||
|
{rows.map((row, index) => {
|
||||||
|
const rowKey = getRowId?.(row) ?? String(index);
|
||||||
|
const body = (
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
{primary ? (
|
||||||
|
<div className="truncate text-sm font-medium text-foreground">
|
||||||
|
{primary.render(row)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{rest.length > 0 ? (
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
||||||
|
{rest.map((field) => (
|
||||||
|
<React.Fragment key={field.key}>
|
||||||
|
<dt className="font-medium text-muted-foreground">
|
||||||
|
{field.label}
|
||||||
|
</dt>
|
||||||
|
<dd className="truncate text-foreground">
|
||||||
|
{field.render(row)}
|
||||||
|
</dd>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{actions ? (
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{actions(row)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onRowClick) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rowKey}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => onRowClick(row)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onRowClick(row);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="mobile-touch-target min-h-11 w-full cursor-pointer rounded-lg border border-border bg-card p-3 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rowKey}
|
||||||
|
className="min-h-11 rounded-lg border border-border bg-card p-3"
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Loader2, XIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
|
|
||||||
|
export interface SheetFormProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
title: string;
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
/** Disable Save and show a pending spinner. */
|
||||||
|
isPending?: boolean;
|
||||||
|
/** Override the Save button label (default "Save"). */
|
||||||
|
saveLabel?: string;
|
||||||
|
/** Disable the Save button (e.g. when required fields are empty). */
|
||||||
|
saveDisabled?: boolean;
|
||||||
|
/**
|
||||||
|
* When true, any close attempt (Cancel button, header X, overlay click,
|
||||||
|
* Escape) prompts a discard-confirmation instead of immediately closing.
|
||||||
|
* Spec R4.5.
|
||||||
|
*/
|
||||||
|
isDirty?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Optional className applied to the scrolling body. */
|
||||||
|
bodyClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-height form host for the mobile (`< md`) breakpoint.
|
||||||
|
*
|
||||||
|
* Wraps the shadcn `Sheet` primitive with a fixed header (title + close) and a
|
||||||
|
* fixed footer (Cancel + Save). The body scrolls between them. Laid out as a
|
||||||
|
* flex column (NOT `position: sticky`) because Radix `Sheet` uses transforms,
|
||||||
|
* which break sticky positioning — see OpenSpec change
|
||||||
|
* `mobile-responsive-parity`, design §`SheetForm` / risks.
|
||||||
|
*
|
||||||
|
* Uses `h-[100dvh]` (not `h-screen`) to avoid the iOS Safari URL-bar resize
|
||||||
|
* jump. Consumers choose this host vs the desktop `Dialog` via `useIsMobile()`.
|
||||||
|
*/
|
||||||
|
export function SheetForm({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
isPending = false,
|
||||||
|
saveDisabled = false,
|
||||||
|
saveLabel = "Save",
|
||||||
|
isDirty = false,
|
||||||
|
children,
|
||||||
|
bodyClassName,
|
||||||
|
}: SheetFormProps) {
|
||||||
|
const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false);
|
||||||
|
|
||||||
|
// Route every close path (Cancel, header X, Radix overlay/Escape) through one
|
||||||
|
// guard so the dirty-confirm is applied uniformly (spec R4.5).
|
||||||
|
const attemptClose = React.useCallback(() => {
|
||||||
|
if (isDirty) {
|
||||||
|
setConfirmDiscardOpen(true);
|
||||||
|
} else {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}, [isDirty, onCancel]);
|
||||||
|
|
||||||
|
const handleOpenChange = React.useCallback(
|
||||||
|
(next: boolean) => {
|
||||||
|
if (!next) {
|
||||||
|
attemptClose();
|
||||||
|
} else {
|
||||||
|
onOpenChange(next);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[attemptClose, onOpenChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<SheetContent
|
||||||
|
side="bottom"
|
||||||
|
showCloseButton={false}
|
||||||
|
className="flex h-[100dvh] w-full flex-col gap-0 p-0 sm:max-w-full"
|
||||||
|
onEscapeKeyDown={(e) => {
|
||||||
|
// Prevent Radix's default Escape close so our guard runs instead.
|
||||||
|
if (isDirty) {
|
||||||
|
e.preventDefault();
|
||||||
|
attemptClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerDownOutside={(e) => {
|
||||||
|
// Prevent overlay-click close so our guard runs instead.
|
||||||
|
if (isDirty) {
|
||||||
|
e.preventDefault();
|
||||||
|
attemptClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header — fixed at top */}
|
||||||
|
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border px-4">
|
||||||
|
<SheetTitle className="font-heading text-base font-medium">
|
||||||
|
{title}
|
||||||
|
</SheetTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Close"
|
||||||
|
onClick={attemptClose}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body — scrolls */}
|
||||||
|
<div className={cn("flex-1 overflow-y-auto p-4", bodyClassName)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer — fixed at bottom */}
|
||||||
|
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border bg-muted/50 p-4">
|
||||||
|
<Button variant="outline" onClick={attemptClose} disabled={isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onSave} disabled={isPending || saveDisabled}>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
Saving…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
saveLabel
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDiscardOpen}
|
||||||
|
title="Discard changes?"
|
||||||
|
message="You have unsaved changes. Discard them and close?"
|
||||||
|
confirmLabel="Discard"
|
||||||
|
onCancel={() => setConfirmDiscardOpen(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirmDiscardOpen(false);
|
||||||
|
onCancel();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type { OnChangeFn } from "@tanstack/react-table";
|
||||||
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared pagination footer for table-style views.
|
||||||
|
*
|
||||||
|
* Renders the rows count, page-size select, page indicator, and prev/next
|
||||||
|
* buttons. Works off the raw {@link PaginationState} primitives so it can back
|
||||||
|
* both a TanStack `Table` instance (via a thin adapter) and standalone card
|
||||||
|
* layouts that drive pagination directly (e.g. MediaMobilePagination).
|
||||||
|
*
|
||||||
|
* The Desktop DataTable and the Media mobile card list both consume this to
|
||||||
|
* avoid the duplication flagged in
|
||||||
|
* `openspec/changes/mobile-responsive-parity/verify-report.md` residual risk #5.
|
||||||
|
*/
|
||||||
|
export interface TablePaginationProps {
|
||||||
|
pageIndex: number;
|
||||||
|
pageSize: number;
|
||||||
|
pageSizeOptions: number[];
|
||||||
|
totalRows: number;
|
||||||
|
pageCount: number;
|
||||||
|
onPaginationChange: OnChangeFn<PaginationState>;
|
||||||
|
/** Optional extra className on the outer container (e.g. "p-4"). */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TablePagination({
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
pageSizeOptions,
|
||||||
|
totalRows,
|
||||||
|
pageCount,
|
||||||
|
onPaginationChange,
|
||||||
|
className,
|
||||||
|
}: TablePaginationProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center justify-between gap-3 text-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{`${totalRows} row${totalRows === 1 ? "" : "s"}`}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-muted-foreground">Rows per page</span>
|
||||||
|
<Select
|
||||||
|
value={String(pageSize)}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onPaginationChange(() => ({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: Number(value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
size="sm"
|
||||||
|
className="w-[70px]"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{pageSizeOptions.map((option) => (
|
||||||
|
<SelectItem key={option} value={String(option)}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Page {pageIndex + 1} of {pageCount}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={() =>
|
||||||
|
onPaginationChange((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pageIndex: Math.max(0, prev.pageIndex - 1),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={pageIndex <= 0}
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={() =>
|
||||||
|
onPaginationChange((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pageIndex: prev.pageIndex + 1,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={pageIndex >= pageCount - 1}
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/** Mobile breakpoint (must match Tailwind `md:` and the OpenSpec spec R1.2). */
|
||||||
|
const MOBILE_QUERY = "(max-width: 768px)";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the mobile/desktop responsive cut.
|
||||||
|
*
|
||||||
|
* Returns `true` when the viewport matches `max-width: 768px` (phone portrait),
|
||||||
|
* `false` at `md:` and above. SSR-safe: returns `false` when `window` is
|
||||||
|
* undefined so server-rendered markup stays on the desktop path.
|
||||||
|
*
|
||||||
|
* Replaces the ad-hoc `window.matchMedia("(max-width: 768px)")` reads scattered
|
||||||
|
* across pages (App.tsx, Media.tsx) — see OpenSpec change
|
||||||
|
* `mobile-responsive-parity`, design §`useIsMobile`.
|
||||||
|
*/
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
const [isMobile, setIsMobile] = useState(
|
||||||
|
() =>
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
typeof window.matchMedia === "function" &&
|
||||||
|
window.matchMedia(MOBILE_QUERY).matches,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
typeof window === "undefined" ||
|
||||||
|
typeof window.matchMedia !== "function"
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const mql = window.matchMedia(MOBILE_QUERY);
|
||||||
|
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||||
|
mql.addEventListener("change", handler);
|
||||||
|
return () => mql.removeEventListener("change", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
@@ -14,7 +14,10 @@ export function useMediaStatus(jellyfinServiceId?: string) {
|
|||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
refetchInterval: (query) =>
|
refetchInterval: (query) =>
|
||||||
query.state.data?.build_running ? 1000 : false,
|
query.state.data?.build_running ? 1000 : false,
|
||||||
refetchIntervalInBackground: true,
|
// Inherit the default refetchIntervalInBackground: false — pause the
|
||||||
|
// 1s build-progress poll when the tab is hidden. The build keeps
|
||||||
|
// running server-side; the poll resumes and catches up on return.
|
||||||
|
// Battery-friendly (D8 follow-up).
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,3 +100,19 @@ body,
|
|||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mobile touch-target utility (spec R6.1).
|
||||||
|
*
|
||||||
|
* Applies a 44x44px minimum hit area to interactive elements ONLY below the
|
||||||
|
* `md:` (768px) breakpoint, satisfying WCAG 2.5.5 / Apple HIG on touch devices.
|
||||||
|
* At md+ the class is inert so desktop sizing is not regressed. Pages sprinkle
|
||||||
|
* this on icon buttons, checkboxes, switches, and row taps. See OpenSpec
|
||||||
|
* change `mobile-responsive-parity`, design §`mobile-touch-target`.
|
||||||
|
*/
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.mobile-touch-target {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
dir: frontend/src/pages
|
dir: frontend/src/pages
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Top-level page components for the application's main navigation routes, each encapsulating a full-feature UI for managing services, media, files, users, settings, and system actions.
|
Top-level page components constituting the primary UI screens/routes of the frontend application, covering dashboards, media management, file browsing, services, settings, users, and task automation.
|
||||||
## parent
|
## parent
|
||||||
index: frontend/src/.pi-map.index.md
|
index: frontend/src/.pi-map.index.md
|
||||||
map: frontend/src/.pi-map.md
|
map: frontend/src/.pi-map.md
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ dir: frontend/src/pages
|
|||||||
index: frontend/src/pages/.pi-map.index.md
|
index: frontend/src/pages/.pi-map.index.md
|
||||||
|
|
||||||
## role
|
## role
|
||||||
Top-level page components for the application's main navigation routes, each encapsulating a full-feature UI for managing services, media, files, users, settings, and system actions.
|
Top-level page components constituting the primary UI screens/routes of the frontend application, covering dashboards, media management, file browsing, services, settings, users, and task automation.
|
||||||
## files
|
## files
|
||||||
- Actions.tsx | Provides a React component for managing reusable server tasks (shell/python actions) with CRUD operations, service selection, and execution history display. | exp: func:Actions(), call:useServiceInstances, call:useTasks, call:useSaveTask, call:useDeleteTask, call:useRunTask, call:useState, call:emptyTask, call:useMemo, call:tasks.find, call:useTaskRuns, call:setDraft, call:setDraftBaseline, call:setEditOpen, call:setRunServiceId, call:saveTask.mutateAsync, call:setTab, call:String, call:tasks.map, call:openEdit, call:initialFromTask, call:runTask.mutateAsync, call:sshServices.map, call:selectedRuns.data.items.map, call:new Date(run.created_at * 1000).toLocaleString, call:deleteTask.mutate | dep: react, ../types, ../hooks/useSettings, ../hooks/useServices, ../components/DialogFooter, ../components/HoverEditButton, ../components/SectionCard, ../components/SelectionRailCard, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/card, @/components/ui/dialog, @/components/ui/input, @/components/ui/label, @/components/ui/select, @/components/ui/separator, @/components/ui/tabs, @/components/ui/textarea
|
- Actions.tsx | Provides a React component for managing reusable server tasks (shell/python actions) with CRUD operations, service selection, and execution history display. | exp: func:Actions(), call:useServiceInstances, call:useTasks, call:useSaveTask, call:useDeleteTask, call:useRunTask, call:useState, call:emptyTask, call:useMemo, call:tasks.find, call:useTaskRuns, call:setDraft, call:setDraftBaseline, call:setEditOpen, call:setRunServiceId, call:saveTask.mutateAsync, call:setTab, call:String, call:tasks.map, call:openEdit, call:initialFromTask, call:runTask.mutateAsync, call:sshServices.map, call:selectedRuns.data.items.map, call:new Date(run.created_at * 1000).toLocaleString, call:deleteTask.mutate | dep: react, ../types, ../hooks/useSettings, ../hooks/useServices, ../components/DialogFooter, ../components/HoverEditButton, ../components/SectionCard, ../components/SelectionRailCard, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/card, @/components/ui/dialog, @/components/ui/input, @/components/ui/label, @/components/ui/select, @/components/ui/separator, @/components/ui/tabs, @/components/ui/textarea
|
||||||
- Applications.tsx | Renders a tabbed Applications dashboard with Jellyfin library statistics and media management, plus a placeholder for future Nextcloud support. | exp: func:Applications(), call:useState | dep: react, react-router-dom, @/components/ui/alert, @/components/ui/badge, @/components/ui/tabs, ./Media, ../hooks/useDashboard, ../hooks/useServices, ../components/SectionCard, ../components/TabbedCard
|
- Applications.tsx | Renders a tabbed Applications dashboard with Jellyfin library statistics and media management, plus a placeholder for future Nextcloud support. | exp: func:Applications(), call:useState | dep: react, react-router-dom, @/components/ui/alert, @/components/ui/badge, @/components/ui/tabs, ./Media, ../hooks/useDashboard, ../hooks/useServices, ../components/SectionCard, ../components/TabbedCard
|
||||||
@@ -12,15 +12,15 @@ Top-level page components for the application's main navigation routes, each enc
|
|||||||
- FileBrowser.impl.tsx | A React component that implements a file browser with directory listing, file selection, ffprobe media metadata inspection, and job execution capabilities. | exp: func:FileBrowser(), call:useSearchParams, call:useState, call:useMonitoringSettings, call:useMemo, call:(machines ?? []).filter, call:machine.services.includes, call:searchParams.get, call:usePersistentState, call:isVideoFile, call:requestedPath.includes, call:requestedPath.replace, call:selectedPath.replace, call:defaultFileBrowserState, call:useNavigate, call:setBrowserState, call:useDirectoryListing, call:useFfprobe, call:useJobTemplates, call:useRunJob, call:updateBrowserState, call:setSearchParams, call:next.set, call:next.delete, call:navigate, call:currentDir.replace, call:rows.push, call:entry.name.split(".").pop, call:formatSize, call:formatTime, call:updater, call:Object.keys(next).filter, call:rows.find, call:templates?.find, call:fileMachines.map, call:refetch, call:String, call:templates.map, call:runJob.mutate, call:navigateToSettings | dep: react, react-router-dom, @tanstack/react-table, @/components/ui/data-table, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/card, @/components/ui/input, @/components/ui/label, @/components/ui/select, @/components/ui/tabs, ../hooks/useFiles, ../hooks/usePersistentState, ../hooks/useSettings, ../components/SectionCard, ../components/TabbedCard
|
- FileBrowser.impl.tsx | A React component that implements a file browser with directory listing, file selection, ffprobe media metadata inspection, and job execution capabilities. | exp: func:FileBrowser(), call:useSearchParams, call:useState, call:useMonitoringSettings, call:useMemo, call:(machines ?? []).filter, call:machine.services.includes, call:searchParams.get, call:usePersistentState, call:isVideoFile, call:requestedPath.includes, call:requestedPath.replace, call:selectedPath.replace, call:defaultFileBrowserState, call:useNavigate, call:setBrowserState, call:useDirectoryListing, call:useFfprobe, call:useJobTemplates, call:useRunJob, call:updateBrowserState, call:setSearchParams, call:next.set, call:next.delete, call:navigate, call:currentDir.replace, call:rows.push, call:entry.name.split(".").pop, call:formatSize, call:formatTime, call:updater, call:Object.keys(next).filter, call:rows.find, call:templates?.find, call:fileMachines.map, call:refetch, call:String, call:templates.map, call:runJob.mutate, call:navigateToSettings | dep: react, react-router-dom, @tanstack/react-table, @/components/ui/data-table, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/card, @/components/ui/input, @/components/ui/label, @/components/ui/select, @/components/ui/tabs, ../hooks/useFiles, ../hooks/usePersistentState, ../hooks/useSettings, ../components/SectionCard, ../components/TabbedCard
|
||||||
- FileBrowser.tsx | Re-exports the FileBrowser component from its implementation file | dep: ./FileBrowser.impl
|
- FileBrowser.tsx | Re-exports the FileBrowser component from its implementation file | dep: ./FileBrowser.impl
|
||||||
- Media.tsx | A React component for managing and browsing Jellyfin media libraries with server-driven pagination, index building controls, and responsive data table display. | exp: func:Media(), call:useNavigate, call:useSearchParams, call:usePrefersSmallScreen, call:useServiceInstances, call:searchParams.get, call:jellyfinServices.find, call:useCounts, call:useLibraries, call:useMediaStatus, call:useBuildIndex, call:useStopBuildIndex, call:useForceStopBuildIndex, call:usePersistentState, call:defaultMediaTabState, call:setMediaState, call:useState, call:useEffect, call:setSearchParams, call:next.set, call:useMediaDataQuery, call:Math.floor, call:updater, call:useMemo, call:navigate, call:encodeURIComponent, call:Math.max, call:Math.ceil, call:formatDuration, call:jellyfinServices.map, call:status.item_count.toLocaleString, call:counts.movies.toLocaleString, call:counts.series.toLocaleString, call:counts.episodes.toLocaleString, call:(libraries?.length ?? 0).toLocaleString, call:buildIndex.mutate, call:stopBuildIndex.mutate, call:forceStopBuildIndex.mutate, call:Math.round, call:status?.build_items_processed?.toLocaleString, call:status?.build_items_total?.toLocaleString, call:status?.build_library_items_processed?.toLocaleString, call:status?.build_library_items_total?.toLocaleString, call:updateMediaState, call:total.toLocaleString | dep: react, react-router-dom, @tanstack/react-table, @/components/ui/data-table, @/components/ui/alert, @/components/ui/button, @/components/ui/card, @/components/ui/input, @/components/ui/label, @/components/ui/progress, @/components/ui/select, ../hooks/useMedia, ../hooks/usePersistentState, ../types, ../hooks/useServices, ../hooks/useDashboard, @/components/ui (data-table, alert, button, card, input, label, progress, select)
|
- Media.tsx | A React component for managing and browsing Jellyfin media libraries with server-driven pagination, index building controls, and responsive data table display. | exp: func:Media(), call:useNavigate, call:useSearchParams, call:usePrefersSmallScreen, call:useServiceInstances, call:searchParams.get, call:jellyfinServices.find, call:useCounts, call:useLibraries, call:useMediaStatus, call:useBuildIndex, call:useStopBuildIndex, call:useForceStopBuildIndex, call:usePersistentState, call:defaultMediaTabState, call:setMediaState, call:useState, call:useEffect, call:setSearchParams, call:next.set, call:useMediaDataQuery, call:Math.floor, call:updater, call:useMemo, call:navigate, call:encodeURIComponent, call:Math.max, call:Math.ceil, call:formatDuration, call:jellyfinServices.map, call:status.item_count.toLocaleString, call:counts.movies.toLocaleString, call:counts.series.toLocaleString, call:counts.episodes.toLocaleString, call:(libraries?.length ?? 0).toLocaleString, call:buildIndex.mutate, call:stopBuildIndex.mutate, call:forceStopBuildIndex.mutate, call:Math.round, call:status?.build_items_processed?.toLocaleString, call:status?.build_items_total?.toLocaleString, call:status?.build_library_items_processed?.toLocaleString, call:status?.build_library_items_total?.toLocaleString, call:updateMediaState, call:total.toLocaleString | dep: react, react-router-dom, @tanstack/react-table, @/components/ui/data-table, @/components/ui/alert, @/components/ui/button, @/components/ui/card, @/components/ui/input, @/components/ui/label, @/components/ui/progress, @/components/ui/select, ../hooks/useMedia, ../hooks/usePersistentState, ../types, ../hooks/useServices, ../hooks/useDashboard, @/components/ui (data-table, alert, button, card, input, label, progress, select)
|
||||||
- ServicePage.tsx | Provides a UI for viewing and editing a service instance's configuration, secrets, and widget bindings, with save and delete functionality. | exp: func:ServicePage(), call:useParams, call:useServiceInstances, call:useSaveServiceInstance, call:useDeleteServiceInstance, call:useMemo, call:services.find, call:getServiceBinding, call:useState, call:setName, call:setEnabled, call:setHydrated, call:saveService.mutateAsync, call:buildInput, call:setDeleteOpen, call:binding.widgets.map, call:deleteService.mutate | dep: react, react-router-dom, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/input, @/components/ui/label, @/components/ui/switch, ../hooks/useServices, ../types, ../components/SectionCard, ../components/ConfirmDialog, ../integrations/registry, @/components/ui/*
|
- ServicePage.tsx | Provides a settings form to view, edit (config and secrets), save, and delete a specific service instance, and lists its available widgets. | exp: func:ServicePage(), call:useParams, call:useServiceInstances, call:useServiceTypes, call:useSaveServiceInstance, call:useDeleteServiceInstance, call:useMemo, call:services.find, call:getServiceBinding, call:types.find, call:useState, call:setName, call:setEnabled, call:setDraftConfig, call:setHydrated, call:saveService.mutateAsync, call:buildInput, call:setDeleteOpen, call:binding.widgets.map, call:deleteService.mutate | dep: react, react-router-dom, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/input, @/components/ui/label, @/components/ui/switch, ../hooks/useServices, ../types, ../components/SectionCard, ../components/ConfirmDialog, ../integrations/registry, @/components/ui (alert, badge, button, input, label, switch)
|
||||||
- ServicesPage.tsx | A React page component for managing external service instances, allowing users to view, create, and delete service configurations with dynamic form fields based on service type schemas. | exp: func:ServicesPage(), call:useNavigate, call:useServiceInstances, call:useServiceTypes, call:useDeleteServiceInstance, call:useState, call:useMemo, call:map.get, call:list.push, call:map.set, call:[...map.entries()].sort, call:map.entries, call:a[0].localeCompare, call:types.find, call:getServiceBinding, call:setCreateOpen, call:grouped.map, call:typeName, call:instances.map, call:Object.entries(s.secrets_set).some, call:navigate, call:setDeleteId, call:Boolean, call:deleteService.mutate | dep: react, react-router-dom, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/input, @/components/ui/label, @/components/ui/switch, @/components/ui/dialog, lucide-react, ../hooks/useServices, ../types, ../components/SectionCard, ../components/ConfirmDialog, ../components/DialogFooter, ../integrations/registry, @/components/ui (alert, badge, button, input, label, switch, dialog)
|
- ServicesPage.tsx | A React page component for managing external service instances, allowing users to view, create, and delete service configurations with dynamic form fields based on service type schemas. | exp: func:ServicesPage(), call:useNavigate, call:useServiceInstances, call:useServiceTypes, call:useDeleteServiceInstance, call:useState, call:useMemo, call:map.get, call:list.push, call:map.set, call:[...map.entries()].sort, call:map.entries, call:a[0].localeCompare, call:types.find, call:getServiceBinding, call:setCreateOpen, call:grouped.map, call:typeName, call:instances.map, call:Object.entries(s.secrets_set).some, call:navigate, call:setDeleteId, call:Boolean, call:deleteService.mutate | dep: react, react-router-dom, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/input, @/components/ui/label, @/components/ui/switch, @/components/ui/dialog, lucide-react, ../hooks/useServices, ../types, ../components/SectionCard, ../components/ConfirmDialog, ../components/DialogFooter, ../integrations/registry, @/components/ui (alert, badge, button, input, label, switch, dialog)
|
||||||
- Settings.tsx | This file provides the UI components and form logic for managing application settings, including configuring monitoring machines via SSH and managing SSH keys. | exp: func:Settings(), call:useMonitoringSettings, call:useSSHKeys, call:useSaveMonitoringMachine, call:useDeleteMonitoringMachine, call:useTestMonitoringMachineSSH, call:useState, call:emptyMachine, call:useMemo, call:orderedMachines.find, call:setSSHValidationMessage, call:setSSHValidationError, call:setSSHValidationStatus, call:clearSSHValidation, call:setMachineDraft, call:setEditingMachine, call:setMachineDialogOpen, call:saveMachine.mutateAsync, call:testMachineSSH.mutateAsync, call:String, call:message.toLowerCase, call:lowered.includes, call:setTab, call:orderedMachines.map, call:setSelectedMachineId, call:cn, call:openEditMachine, call:setDeleteMachineId, call:closeMachineDialog, call:saveMachineDraft, call:machineDraft.host.trim, call:Boolean, call:deleteMachine.mutate | dep: react, ../types, ../hooks/useSettings, ../components/DialogFooter, ../components/HoverEditButton, ../components/SectionCard, ../components/SelectionRailCard, ../components/TabbedCard, ../components/ConfirmDialog, @/lib/utils, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/card, @/components/ui/checkbox, @/components/ui/dialog, @/components/ui/input, @/components/ui/label, @/components/ui/select, @/components/ui/switch, @/components/ui/tabs, @/components/ui/textarea
|
- Settings.tsx | Provides a React settings UI for managing monitoring machines, SSH keys, and local database operations with form editing and validation. | exp: func:Settings(), call:useMonitoringSettings, call:useSSHKeys, call:useSaveMonitoringMachine, call:useDeleteMonitoringMachine, call:useTestMonitoringMachineSSH, call:useState, call:emptyMachine, call:useMemo, call:orderedMachines.find, call:setSSHValidationMessage, call:setSSHValidationError, call:setSSHValidationStatus, call:clearSSHValidation, call:setMachineDraft, call:setEditingMachine, call:setMachineDialogOpen, call:saveMachine.mutateAsync, call:testMachineSSH.mutateAsync, call:String, call:message.toLowerCase, call:lowered.includes, call:setTab, call:orderedMachines.map, call:setSelectedMachineId, call:cn, call:openEditMachine, call:setDeleteMachineId, call:closeMachineDialog, call:saveMachineDraft, call:machineDraft.host.trim, call:Boolean, call:deleteMachine.mutate | dep: react, ../types, ../hooks/useSettings, ../components/DialogFooter, ../components/HoverEditButton, ../components/SectionCard, ../components/SelectionRailCard, ../components/TabbedCard, ../components/ConfirmDialog, @/lib/utils, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/card, @/components/ui/checkbox, @/components/ui/dialog, @/components/ui/input, @/components/ui/label, @/components/ui/select, @/components/ui/switch, @/components/ui/tabs, @/components/ui/textarea, @/components/ui/*
|
||||||
- Users.tsx | Re-exports the UsersPage component from its implementation file to provide a cleaner import interface. | dep: ./UsersPage.impl
|
- Users.tsx | Re-exports the UsersPage component from its implementation file to provide a cleaner import interface. | dep: ./UsersPage.impl
|
||||||
- UsersPage.impl.tsx | A React page component for managing and messaging Jellyfin users with Jellyseerr enrichment, featuring a searchable directory table, user selection, email composition dialog, and queue status monitoring. | exp: func:UsersPage(), call:useUsers, call:useActivity, call:useUserMessageQueueStatus, call:useSendUserMessage, call:useIsMobile, call:useState, call:useSearchParams, call:useRef, call:useMemo, call:mergeUsersWithActivity, call:search.trim().toLowerCase, call:rows.filter, call:[ row.username, row.display_name, row.email, row.email_source, row.avatar_source, row.name_source, row.access_source, row.user_type_label, row.role, row.permissions_label, row.jellyseerr_username, row.activity_label, row.activity_summary, row.activity.primary_session?.title || "", String(row.jellyseerr_user_id ?? ""), ].some, call:String, call:value.toLowerCase().includes, call:queueStatus.active_request_id.slice, call:selectedIdSet.has, call:selectedRows.filter, call:filteredRows.filter, call:setSelectedUserIds, call:current.includes, call:current.filter, call:filteredRows.forEach, call:next.add, call:next.delete, call:Array.from, call:searchParams.get, call:resolveUserSelection, call:buildUserDrawerModel, call:sendUserMessage.reset, call:subject.trim, call:setSubject, call:htmlBody.trim, call:setHtmlBody, call:setComposeOpen, call:htmlBody.slice, call:requestAnimationFrame, call:textarea.focus, call:textarea.setSelectionRange, call:window.prompt, call:insertMarkup, call:setAttachments, call:formData.append, call:JSON.stringify, call:allSelectedRows.map, call:attachments.forEach, call:sendUserMessage.mutateAsync, call:setSearch, call:cn, call:toggleVisibleSelection, call:filteredRows.map, call:setSearchParams, call:event.stopPropagation, call:toggleUserSelected, call:userLabel(row).charAt(0).toUpperCase, call:activityBadgeVariant, call:Boolean, call:drawerModel.title.charAt(0).toUpperCase, call:drawerModel.identity.map, call:drawerModel.contactActions.map, call:drawerModel.contactActions .map((action) => action.hint) .join, call:drawerModel.permissions.map, call:closeCompose, call:sendUserMessage.data.request_id.slice, call:selectedDeliverableRows.map, call:attachments.map, call:removeAttachment | dep: react, react-router-dom, lucide-react, @/components/ui/dialog, @/components/ui/input, @/components/ui/textarea, @/components/ui/separator, @/components/ui/label, @/components/ui/avatar, @/components/ui/badge, @/components/ui/button, @/components/ui/checkbox, @/components/ui/alert, @/components/ui/progress, @/components/ui/tooltip, @/components/ui/sheet, @/components/ui/table, @/lib/utils, ../components/MetricCard, ../components/SessionActivityPanel, ../hooks/useUsers, ../hooks/useDashboard, ../hooks/useSendUserMessage, ../hooks/useUserMessageQueueStatus, ../types, ../users, ../userState, @/components/ui (dialog, input, textarea, separator, label, avatar, badge, button, checkbox, alert, progress, tooltip, sheet, table)
|
- UsersPage.impl.tsx | A React page component for managing and messaging Jellyfin users with Jellyseerr enrichment, featuring a searchable directory table, user selection, email composition dialog, and queue status monitoring. | exp: func:UsersPage(), call:useUsers, call:useActivity, call:useUserMessageQueueStatus, call:useSendUserMessage, call:useIsMobile, call:useState, call:useSearchParams, call:useRef, call:useMemo, call:mergeUsersWithActivity, call:search.trim().toLowerCase, call:rows.filter, call:[ row.username, row.display_name, row.email, row.email_source, row.avatar_source, row.name_source, row.access_source, row.user_type_label, row.role, row.permissions_label, row.jellyseerr_username, row.activity_label, row.activity_summary, row.activity.primary_session?.title || "", String(row.jellyseerr_user_id ?? ""), ].some, call:String, call:value.toLowerCase().includes, call:queueStatus.active_request_id.slice, call:selectedIdSet.has, call:selectedRows.filter, call:filteredRows.filter, call:setSelectedUserIds, call:current.includes, call:current.filter, call:filteredRows.forEach, call:next.add, call:next.delete, call:Array.from, call:searchParams.get, call:resolveUserSelection, call:buildUserDrawerModel, call:sendUserMessage.reset, call:subject.trim, call:setSubject, call:htmlBody.trim, call:setHtmlBody, call:setComposeOpen, call:htmlBody.slice, call:requestAnimationFrame, call:textarea.focus, call:textarea.setSelectionRange, call:window.prompt, call:insertMarkup, call:setAttachments, call:formData.append, call:JSON.stringify, call:allSelectedRows.map, call:attachments.forEach, call:sendUserMessage.mutateAsync, call:setSearch, call:cn, call:toggleVisibleSelection, call:filteredRows.map, call:setSearchParams, call:event.stopPropagation, call:toggleUserSelected, call:userLabel(row).charAt(0).toUpperCase, call:activityBadgeVariant, call:Boolean, call:drawerModel.title.charAt(0).toUpperCase, call:drawerModel.identity.map, call:drawerModel.contactActions.map, call:drawerModel.contactActions .map((action) => action.hint) .join, call:drawerModel.permissions.map, call:closeCompose, call:sendUserMessage.data.request_id.slice, call:selectedDeliverableRows.map, call:attachments.map, call:removeAttachment | dep: react, react-router-dom, lucide-react, @/components/ui/dialog, @/components/ui/input, @/components/ui/textarea, @/components/ui/separator, @/components/ui/label, @/components/ui/avatar, @/components/ui/badge, @/components/ui/button, @/components/ui/checkbox, @/components/ui/alert, @/components/ui/progress, @/components/ui/tooltip, @/components/ui/sheet, @/components/ui/table, @/lib/utils, ../components/MetricCard, ../components/SessionActivityPanel, ../hooks/useUsers, ../hooks/useDashboard, ../hooks/useSendUserMessage, ../hooks/useUserMessageQueueStatus, ../types, ../users, ../userState, @/components/ui (dialog, input, textarea, separator, label, avatar, badge, button, checkbox, alert, progress, tooltip, sheet, table)
|
||||||
## arch
|
## arch
|
||||||
React functional component pattern with lazy-loaded implementations (`.impl.tsx`) behind clean re-export facades (e.g., `FileBrowser.tsx`, `Users.tsx`), using dynamic service-type schemas and tabbed dashboard composition.
|
React functional component pattern with implementation/re-export splitting; each page encapsulates its own state management, CRUD operations, and UI rendering, typically integrating backend APIs, dynamic forms, and tabular/dialog-based interfaces.
|
||||||
## tags
|
## tags
|
||||||
call:use, components, ui, call:set, state, service, machine, react
|
call:use, components, ui, call:set, state, service, react, machine
|
||||||
## symbols
|
## symbols
|
||||||
- Actions
|
- Actions
|
||||||
- Applications
|
- Applications
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ function TaskDialog({
|
|||||||
confirmBusyLabel="Save action"
|
confirmBusyLabel="Save action"
|
||||||
secondaryAction={
|
secondaryAction={
|
||||||
onDelete ? (
|
onDelete ? (
|
||||||
<Button variant="destructive" onClick={onDelete}>
|
<Button variant="destructive" onClick={onDelete} className="mobile-touch-target">
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
) : undefined
|
) : undefined
|
||||||
@@ -367,7 +367,7 @@ export function Actions() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="mobile-touch-target w-full"
|
||||||
onClick={createNew}
|
onClick={createNew}
|
||||||
>
|
>
|
||||||
Add action
|
Add action
|
||||||
@@ -412,13 +412,13 @@ export function Actions() {
|
|||||||
description="Open the editor popup to modify this action."
|
description="Open the editor popup to modify this action."
|
||||||
action={
|
action={
|
||||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button className="mobile-touch-target"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => openEdit(initialFromTask(editingTask))}
|
onClick={() => openEdit(initialFromTask(editingTask))}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button className="mobile-touch-target"
|
||||||
disabled={runTask.isPending || !runServiceId}
|
disabled={runTask.isPending || !runServiceId}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await runTask.mutateAsync({
|
await runTask.mutateAsync({
|
||||||
@@ -515,7 +515,7 @@ export function Actions() {
|
|||||||
description="Select a saved action from the list on the left to view its details, run it, or open the editor popup. Use the button at the bottom to add a new action."
|
description="Select a saved action from the list on the left to view its details, run it, or open the editor popup. Use the button at the bottom to add a new action."
|
||||||
>
|
>
|
||||||
{tasks[0] && (
|
{tasks[0] && (
|
||||||
<Button variant="outline" onClick={() => setTab(tasks[0].id)}>
|
<Button variant="outline" onClick={() => setTab(tasks[0].id)} className="mobile-touch-target">
|
||||||
Select first action
|
Select first action
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
DatabaseBackup,
|
||||||
|
LayoutDashboard,
|
||||||
|
Monitor,
|
||||||
|
} from "lucide-react";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -26,13 +32,122 @@ import {
|
|||||||
useSaveDashboardShortcut,
|
useSaveDashboardShortcut,
|
||||||
} from "../hooks/useDashboard";
|
} from "../hooks/useDashboard";
|
||||||
import { useWidgetInstances } from "../hooks/useWidgets";
|
import { useWidgetInstances } from "../hooks/useWidgets";
|
||||||
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
|
import type {
|
||||||
|
DashboardShortcut,
|
||||||
|
DashboardShortcutInput,
|
||||||
|
ServiceInstance,
|
||||||
|
WidgetInstance,
|
||||||
|
} from "../types";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
import { DialogFooter } from "../components/DialogFooter";
|
import { DialogFooter } from "../components/DialogFooter";
|
||||||
import { WidgetInstanceCard } from "../components/WidgetInstance";
|
import { WidgetInstanceCard } from "../components/WidgetInstance";
|
||||||
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
|
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
|
||||||
|
|
||||||
|
// --- Mobile section grouping (spec R7.2) ---
|
||||||
|
|
||||||
|
const SECTION_ORDER = ["observability", "media", "backups", "custom"] as const;
|
||||||
|
type SectionId = (typeof SECTION_ORDER)[number];
|
||||||
|
|
||||||
|
const SECTION_META: Record<
|
||||||
|
SectionId,
|
||||||
|
{ label: string; icon: typeof Activity }
|
||||||
|
> = {
|
||||||
|
observability: { label: "Observability", icon: Activity },
|
||||||
|
media: { label: "Media", icon: Monitor },
|
||||||
|
backups: { label: "Backups", icon: DatabaseBackup },
|
||||||
|
custom: { label: "Custom", icon: LayoutDashboard },
|
||||||
|
};
|
||||||
|
|
||||||
|
const OBSERVABILITY_TYPES = new Set(["alertmanager", "prometheus", "grafana"]);
|
||||||
|
|
||||||
|
function widgetSection(
|
||||||
|
widget: WidgetInstance,
|
||||||
|
services: ServiceInstance[],
|
||||||
|
): SectionId {
|
||||||
|
if (!widget.service_id) {
|
||||||
|
return widget.widget_kind === "backups" ? "backups" : "custom";
|
||||||
|
}
|
||||||
|
const service = services.find((s) => s.id === widget.service_id);
|
||||||
|
const serviceType = service?.service_type ?? "";
|
||||||
|
if (OBSERVABILITY_TYPES.has(serviceType)) return "observability";
|
||||||
|
if (serviceType === "jellyfin") return "media";
|
||||||
|
return "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupWidgetsBySection(
|
||||||
|
widgets: WidgetInstance[],
|
||||||
|
services: ServiceInstance[],
|
||||||
|
): { id: SectionId; widgets: WidgetInstance[] }[] {
|
||||||
|
const groups: Record<SectionId, WidgetInstance[]> = {
|
||||||
|
observability: [],
|
||||||
|
media: [],
|
||||||
|
backups: [],
|
||||||
|
custom: [],
|
||||||
|
};
|
||||||
|
for (const w of widgets) {
|
||||||
|
groups[widgetSection(w, services)].push(w);
|
||||||
|
}
|
||||||
|
return SECTION_ORDER.map((id) => ({ id, widgets: groups[id] })).filter(
|
||||||
|
(s) => s.widgets.length > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileWidgetSections({
|
||||||
|
sections,
|
||||||
|
}: {
|
||||||
|
sections: { id: SectionId; widgets: WidgetInstance[] }[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Anchor bar — horizontally scrollable pills (spec R7.2, md:hidden) */}
|
||||||
|
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1">
|
||||||
|
{sections.map((section) => {
|
||||||
|
const meta = SECTION_META[section.id];
|
||||||
|
const Icon = meta.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={section.id}
|
||||||
|
type="button"
|
||||||
|
className="mobile-touch-target inline-flex shrink-0 items-center gap-1.5 rounded-full border border-border bg-card px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
onClick={() =>
|
||||||
|
document
|
||||||
|
.getElementById(`dashboard-section-${section.id}`)
|
||||||
|
?.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "start",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="size-3.5" />
|
||||||
|
{meta.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* Sectioned widgets — single column (spec R7.1) */}
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<section
|
||||||
|
key={section.id}
|
||||||
|
id={`dashboard-section-${section.id}`}
|
||||||
|
className="scroll-mt-16 flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||||
|
{SECTION_META[section.id].label}
|
||||||
|
</h3>
|
||||||
|
{section.widgets.map((widget) => (
|
||||||
|
<WidgetInstanceCard key={widget.id} widget={widget} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function emptyShortcut(): DashboardShortcutInput {
|
function emptyShortcut(): DashboardShortcutInput {
|
||||||
return {
|
return {
|
||||||
id: null,
|
id: null,
|
||||||
@@ -239,6 +354,7 @@ function ShortcutDialog({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="shortcut-enabled"
|
id="shortcut-enabled"
|
||||||
|
className="mobile-touch-target"
|
||||||
checked={draft.enabled}
|
checked={draft.enabled}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
onChange({ ...draft, enabled: checked })
|
onChange({ ...draft, enabled: checked })
|
||||||
@@ -309,13 +425,24 @@ function ShortcutCard({
|
|||||||
size="sm"
|
size="sm"
|
||||||
disabled={!shortcut.enabled || !href}
|
disabled={!shortcut.enabled || !href}
|
||||||
onClick={onOpen}
|
onClick={onOpen}
|
||||||
|
className="mobile-touch-target"
|
||||||
>
|
>
|
||||||
Open
|
Open
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={onEdit}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onEdit}
|
||||||
|
className="mobile-touch-target"
|
||||||
|
>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="destructive" onClick={onDelete}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="mobile-touch-target"
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -336,6 +463,8 @@ export function Dashboard() {
|
|||||||
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
|
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
|
||||||
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
|
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
|
||||||
const { data: widgetInstances = [] } = useWidgetInstances();
|
const { data: widgetInstances = [] } = useWidgetInstances();
|
||||||
|
const { data: services = [] } = useServiceInstances();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const visibleWidgets = useMemo(
|
const visibleWidgets = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -345,6 +474,11 @@ export function Dashboard() {
|
|||||||
[widgetInstances],
|
[widgetInstances],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const mobileSections = useMemo(
|
||||||
|
() => groupWidgetsBySection(visibleWidgets, services),
|
||||||
|
[visibleWidgets, services],
|
||||||
|
);
|
||||||
|
|
||||||
const openCreateShortcut = () => {
|
const openCreateShortcut = () => {
|
||||||
setShortcutDraft(emptyShortcut());
|
setShortcutDraft(emptyShortcut());
|
||||||
setShortcutDialogOpen(true);
|
setShortcutDialogOpen(true);
|
||||||
@@ -379,10 +513,18 @@ export function Dashboard() {
|
|||||||
description="Quick links to websites today, with room for action and user shortcuts later."
|
description="Quick links to websites today, with room for action and user shortcuts later."
|
||||||
action={
|
action={
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" onClick={() => setWidgetDialogOpen(true)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={() => setWidgetDialogOpen(true)}
|
||||||
|
>
|
||||||
Edit dashboard
|
Edit dashboard
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={openCreateShortcut}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={openCreateShortcut}
|
||||||
|
>
|
||||||
Add shortcut
|
Add shortcut
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -417,9 +559,13 @@ export function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{visibleWidgets.map((widget) => (
|
{isMobile && mobileSections.length > 0 ? (
|
||||||
<WidgetInstanceCard key={widget.id} widget={widget} />
|
<MobileWidgetSections sections={mobileSections} />
|
||||||
))}
|
) : (
|
||||||
|
visibleWidgets.map((widget) => (
|
||||||
|
<WidgetInstanceCard key={widget.id} widget={widget} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
<ShortcutDialog
|
<ShortcutDialog
|
||||||
open={shortcutDialogOpen}
|
open={shortcutDialogOpen}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { useNavigate, useSearchParams } from "react-router-dom";
|
|||||||
import type { ColumnDef, RowSelectionState } from "@tanstack/react-table";
|
import type { ColumnDef, RowSelectionState } from "@tanstack/react-table";
|
||||||
|
|
||||||
import { DataTable } from "@/components/ui/data-table";
|
import { DataTable } from "@/components/ui/data-table";
|
||||||
|
import {
|
||||||
|
MobileCardRow,
|
||||||
|
type MobileCardField,
|
||||||
|
} from "@/components/ui/mobile-card";
|
||||||
import { Alert, AlertAction, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertAction, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -24,6 +28,7 @@ import {
|
|||||||
useRunJob,
|
useRunJob,
|
||||||
} from "../hooks/useFiles";
|
} from "../hooks/useFiles";
|
||||||
import { usePersistentState } from "../hooks/usePersistentState";
|
import { usePersistentState } from "../hooks/usePersistentState";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
import { useMonitoringSettings } from "../hooks/useSettings";
|
import { useMonitoringSettings } from "../hooks/useSettings";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { TabbedCard } from "../components/TabbedCard";
|
import { TabbedCard } from "../components/TabbedCard";
|
||||||
@@ -182,6 +187,18 @@ const fileColumns: ColumnDef<DisplayRow>[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Mobile card fields (spec R3.2): the card picks the 3-5 most important fields.
|
||||||
|
// Name is the primary identifier; type distinguishes dir/file/up at a glance;
|
||||||
|
// size and modified give the at-a-glance info a user browsing files on a phone
|
||||||
|
// needs. Ext is redundant with the name on mobile (the extension is visible in
|
||||||
|
// the filename itself). See OpenSpec change `mobile-responsive-parity`.
|
||||||
|
const fileCardFields: MobileCardField<DisplayRow>[] = [
|
||||||
|
{ key: "name", label: "Name", render: (r) => r.name, primary: true },
|
||||||
|
{ key: "type", label: "Type", render: (r) => r.type },
|
||||||
|
{ key: "size", label: "Size", render: (r) => r.size || "-" },
|
||||||
|
{ key: "modified", label: "Modified", render: (r) => r.modified || "-" },
|
||||||
|
];
|
||||||
|
|
||||||
const FILE_BROWSER_STATE_KEY = "manage.files.browserState";
|
const FILE_BROWSER_STATE_KEY = "manage.files.browserState";
|
||||||
|
|
||||||
type FileBrowserState = {
|
type FileBrowserState = {
|
||||||
@@ -501,6 +518,7 @@ function InfoAlert({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
export function FileBrowser() {
|
export function FileBrowser() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const [columnVisibility, setColumnVisibility] = useState<
|
const [columnVisibility, setColumnVisibility] = useState<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>({});
|
>({});
|
||||||
@@ -700,14 +718,14 @@ export function FileBrowser() {
|
|||||||
<div className="flex flex-col gap-2 md:flex-row md:items-end">
|
<div className="flex flex-col gap-2 md:flex-row md:items-end">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full md:w-auto"
|
className="w-full md:w-auto mobile-touch-target"
|
||||||
onClick={() => navigate(pathInput || "/")}
|
onClick={() => navigate(pathInput || "/")}
|
||||||
>
|
>
|
||||||
Open
|
Open
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full md:w-auto"
|
className="w-full md:w-auto mobile-touch-target"
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
>
|
>
|
||||||
Refresh
|
Refresh
|
||||||
@@ -725,23 +743,34 @@ export function FileBrowser() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<div className="rounded-lg border bg-card">
|
<div className="rounded-lg border bg-card">
|
||||||
<DataTable
|
{isMobile ? (
|
||||||
columns={fileColumns}
|
<div className="p-4">
|
||||||
data={rows}
|
<MobileCardRow
|
||||||
getRowId={(row) => row.id}
|
rows={rows}
|
||||||
enableRowSelection
|
fields={fileCardFields}
|
||||||
rowSelection={rowSelection}
|
getRowId={(row) => row.id}
|
||||||
onRowSelectionChange={handleSelectionChange}
|
onRowClick={handleRowClick}
|
||||||
onRowClick={handleRowClick}
|
/>
|
||||||
enableColumnVisibilityToggle
|
</div>
|
||||||
columnVisibility={columnVisibility}
|
) : (
|
||||||
onColumnVisibilityChange={setColumnVisibility}
|
<DataTable
|
||||||
emptyMessage={
|
columns={fileColumns}
|
||||||
isLoading
|
data={rows}
|
||||||
? "Loading directory..."
|
getRowId={(row) => row.id}
|
||||||
: "This directory is empty."
|
enableRowSelection
|
||||||
}
|
rowSelection={rowSelection}
|
||||||
/>
|
onRowSelectionChange={handleSelectionChange}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
enableColumnVisibilityToggle
|
||||||
|
columnVisibility={columnVisibility}
|
||||||
|
onColumnVisibilityChange={setColumnVisibility}
|
||||||
|
emptyMessage={
|
||||||
|
isLoading
|
||||||
|
? "Loading directory..."
|
||||||
|
: "This directory is empty."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
@@ -808,7 +837,7 @@ export function FileBrowser() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 md:col-span-3 md:flex-row md:items-end">
|
<div className="flex flex-col gap-2 md:col-span-3 md:flex-row md:items-end">
|
||||||
<Button
|
<Button className="mobile-touch-target"
|
||||||
disabled={!selectedJob || runJob.isPending}
|
disabled={!selectedJob || runJob.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
runJob.mutate({
|
runJob.mutate({
|
||||||
@@ -850,6 +879,7 @@ export function FileBrowser() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => navigateToSettings("/settings")}
|
onClick={() => navigateToSettings("/settings")}
|
||||||
|
className="mobile-touch-target"
|
||||||
>
|
>
|
||||||
Open Settings
|
Open Settings
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import type {
|
|||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
import { DataTable } from "@/components/ui/data-table";
|
import { DataTable } from "@/components/ui/data-table";
|
||||||
|
import {
|
||||||
|
MobileCardRow,
|
||||||
|
type MobileCardField,
|
||||||
|
} from "@/components/ui/mobile-card";
|
||||||
|
import { TablePagination } from "@/components/ui/table-pagination";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -31,6 +36,7 @@ import {
|
|||||||
useForceStopBuildIndex,
|
useForceStopBuildIndex,
|
||||||
} from "../hooks/useMedia";
|
} from "../hooks/useMedia";
|
||||||
import { usePersistentState } from "../hooks/usePersistentState";
|
import { usePersistentState } from "../hooks/usePersistentState";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
import type { MediaItem } from "../types";
|
import type { MediaItem } from "../types";
|
||||||
import { useServiceInstances } from "../hooks/useServices";
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
||||||
@@ -75,6 +81,29 @@ function getMediaRowId(row: MediaItem): string {
|
|||||||
return row.path;
|
return row.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile card fields (spec R3.2): the card picks the 3-5 most important fields.
|
||||||
|
// Title is the primary identifier; size/HDR/library/year give the at-a-glance
|
||||||
|
// tech + context info a user scanning the library on a phone needs. Runtime,
|
||||||
|
// bitrate, resolution, codec etc. live on the desktop table only.
|
||||||
|
const mediaCardFields: MobileCardField<MediaItem>[] = [
|
||||||
|
{ key: "title", label: "Title", render: (r) => r.title, primary: true },
|
||||||
|
{ key: "size", label: "Size", render: (r) => r.size || "-" },
|
||||||
|
{
|
||||||
|
key: "hdr",
|
||||||
|
label: "HDR",
|
||||||
|
render: (r) => r.hdr || "-",
|
||||||
|
},
|
||||||
|
{ key: "library", label: "Library", render: (r) => r.library || "-" },
|
||||||
|
{
|
||||||
|
key: "year",
|
||||||
|
label: "Year",
|
||||||
|
render: (r) => (r.year != null ? String(r.year) : "-"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mobile pagination uses the shared TablePagination component
|
||||||
|
// (frontend/src/components/ui/table-pagination.tsx).
|
||||||
|
|
||||||
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
|
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
|
||||||
const SMALL_BREAKPOINT = "(max-width: 900px)";
|
const SMALL_BREAKPOINT = "(max-width: 900px)";
|
||||||
// Mirrors the pre-rework DataGrid `columnVisibilityModel` mobile override.
|
// Mirrors the pre-rework DataGrid `columnVisibilityModel` mobile override.
|
||||||
@@ -178,6 +207,7 @@ export function Media() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const isSmall = usePrefersSmallScreen();
|
const isSmall = usePrefersSmallScreen();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
|
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
|
||||||
const selectedServiceId =
|
const selectedServiceId =
|
||||||
searchParams.get("jellyfin_service_id") ||
|
searchParams.get("jellyfin_service_id") ||
|
||||||
@@ -367,7 +397,7 @@ export function Media() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button className="mobile-touch-target"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => buildIndex.mutate()}
|
onClick={() => buildIndex.mutate()}
|
||||||
disabled={
|
disabled={
|
||||||
@@ -378,7 +408,7 @@ export function Media() {
|
|||||||
</Button>
|
</Button>
|
||||||
{buildRunning && (
|
{buildRunning && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button className="mobile-touch-target"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => stopBuildIndex.mutate()}
|
onClick={() => stopBuildIndex.mutate()}
|
||||||
disabled={stopBuildIndex.isPending || buildCancelRequested}
|
disabled={stopBuildIndex.isPending || buildCancelRequested}
|
||||||
@@ -389,7 +419,7 @@ export function Media() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-chart-3/40 text-chart-3 hover:bg-chart-3/10"
|
className="border-chart-3/40 text-chart-3 hover:bg-chart-3/10 mobile-touch-target"
|
||||||
onClick={() => forceStopBuildIndex.mutate()}
|
onClick={() => forceStopBuildIndex.mutate()}
|
||||||
disabled={forceStopBuildIndex.isPending}
|
disabled={forceStopBuildIndex.isPending}
|
||||||
>
|
>
|
||||||
@@ -532,33 +562,56 @@ export function Media() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status?.exists && (
|
{status?.exists &&
|
||||||
<div className="rounded-lg border bg-card">
|
(isMobile ? (
|
||||||
<DataTable
|
<div className="rounded-lg border bg-card">
|
||||||
columns={mediaColumns}
|
<div className="p-4">
|
||||||
data={queryResult?.items ?? []}
|
<MobileCardRow
|
||||||
getRowId={getMediaRowId}
|
rows={queryResult?.items ?? []}
|
||||||
enableRowSelection
|
fields={mediaCardFields}
|
||||||
rowSelection={rowSelection}
|
getRowId={getMediaRowId}
|
||||||
onRowSelectionChange={setRowSelection}
|
onRowClick={handleRowClick}
|
||||||
onRowClick={handleRowClick}
|
/>
|
||||||
enableColumnVisibilityToggle
|
</div>
|
||||||
columnVisibility={effectiveColumnVisibility}
|
{queryResult && (
|
||||||
onColumnVisibilityChange={handleColumnVisibilityChange}
|
<TablePagination
|
||||||
enablePagination
|
pageIndex={pageIndex}
|
||||||
manualPagination
|
pageSize={pageSize}
|
||||||
pagination={pagination}
|
pageSizeOptions={[50, 100, 200]}
|
||||||
onPaginationChange={handlePaginationChange}
|
totalRows={total}
|
||||||
pageSizeOptions={[50, 100, 200]}
|
pageCount={totalPages}
|
||||||
rowCount={total}
|
onPaginationChange={handlePaginationChange}
|
||||||
emptyMessage={
|
className="p-4"
|
||||||
isLoading
|
/>
|
||||||
? "Loading media..."
|
)}
|
||||||
: "No media items match these filters."
|
</div>
|
||||||
}
|
) : (
|
||||||
/>
|
<div className="rounded-lg border bg-card">
|
||||||
</div>
|
<DataTable
|
||||||
)}
|
columns={mediaColumns}
|
||||||
|
data={queryResult?.items ?? []}
|
||||||
|
getRowId={getMediaRowId}
|
||||||
|
enableRowSelection
|
||||||
|
rowSelection={rowSelection}
|
||||||
|
onRowSelectionChange={setRowSelection}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
enableColumnVisibilityToggle
|
||||||
|
columnVisibility={effectiveColumnVisibility}
|
||||||
|
onColumnVisibilityChange={handleColumnVisibilityChange}
|
||||||
|
enablePagination
|
||||||
|
manualPagination
|
||||||
|
pagination={pagination}
|
||||||
|
onPaginationChange={handlePaginationChange}
|
||||||
|
pageSizeOptions={[50, 100, 200]}
|
||||||
|
rowCount={total}
|
||||||
|
emptyMessage={
|
||||||
|
isLoading
|
||||||
|
? "Loading media..."
|
||||||
|
: "No media items match these filters."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+280
-107
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -10,10 +10,17 @@ import {
|
|||||||
useDeleteServiceInstance,
|
useDeleteServiceInstance,
|
||||||
useSaveServiceInstance,
|
useSaveServiceInstance,
|
||||||
useServiceInstances,
|
useServiceInstances,
|
||||||
|
useServiceTypes,
|
||||||
} from "../hooks/useServices";
|
} from "../hooks/useServices";
|
||||||
import type { ServiceInstance, ServiceInstanceInput } from "../types";
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
|
import type {
|
||||||
|
ServiceInstance,
|
||||||
|
ServiceInstanceInput,
|
||||||
|
ServiceTypeInfo,
|
||||||
|
} from "../types";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
|
import { SheetForm } from "@/components/ui/sheet-form";
|
||||||
import { getServiceBinding } from "../integrations/registry";
|
import { getServiceBinding } from "../integrations/registry";
|
||||||
|
|
||||||
function Field({
|
function Field({
|
||||||
@@ -44,6 +51,7 @@ export function ServicePage() {
|
|||||||
serviceId: string;
|
serviceId: string;
|
||||||
}>();
|
}>();
|
||||||
const { data: services = [] } = useServiceInstances(serviceType || undefined);
|
const { data: services = [] } = useServiceInstances(serviceType || undefined);
|
||||||
|
const { data: types = [] } = useServiceTypes();
|
||||||
const saveService = useSaveServiceInstance();
|
const saveService = useSaveServiceInstance();
|
||||||
const deleteService = useDeleteServiceInstance();
|
const deleteService = useDeleteServiceInstance();
|
||||||
|
|
||||||
@@ -52,16 +60,28 @@ export function ServicePage() {
|
|||||||
[services, serviceId],
|
[services, serviceId],
|
||||||
);
|
);
|
||||||
const binding = getServiceBinding(serviceType);
|
const binding = getServiceBinding(serviceType);
|
||||||
|
const typeInfo = useMemo(
|
||||||
|
() => types.find((t) => t.service_type === serviceType),
|
||||||
|
[types, serviceType],
|
||||||
|
);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [enabled, setEnabled] = useState(true);
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({});
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [hydrated, setHydrated] = useState(false);
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
// The mobile SheetForm opens by default when the page loads: this page is
|
||||||
|
// reached via /services/:serviceType/:serviceId, always editing an existing
|
||||||
|
// instance, so there is no separate "open edit" trigger on mobile.
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(true);
|
||||||
|
|
||||||
// Hydrate local form state once the instance loads.
|
// Hydrate local form state once the instance loads.
|
||||||
if (instance && !hydrated) {
|
if (instance && !hydrated) {
|
||||||
setName(instance.name);
|
setName(instance.name);
|
||||||
setEnabled(instance.enabled);
|
setEnabled(instance.enabled);
|
||||||
|
setDraftConfig({ ...instance.config });
|
||||||
setHydrated(true);
|
setHydrated(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +106,7 @@ export function ServicePage() {
|
|||||||
id: instance!.id,
|
id: instance!.id,
|
||||||
service_type: instance!.service_type,
|
service_type: instance!.service_type,
|
||||||
name,
|
name,
|
||||||
config: instance!.config,
|
config: draftConfig,
|
||||||
secrets: {}, // secrets are managed via the dedicated inputs below
|
secrets: {}, // secrets are managed via the dedicated inputs below
|
||||||
enabled,
|
enabled,
|
||||||
};
|
};
|
||||||
@@ -94,6 +114,117 @@ export function ServicePage() {
|
|||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
await saveService.mutateAsync(buildInput());
|
await saveService.mutateAsync(buildInput());
|
||||||
|
// R4.5: close the sheet on successful save and return to the services list
|
||||||
|
// (on mobile the sheet IS the page, so closing it would strand the user).
|
||||||
|
if (isMobile) {
|
||||||
|
setSheetOpen(false);
|
||||||
|
navigate("/services");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const configFields = (
|
||||||
|
<ServiceConnectionFields
|
||||||
|
instance={instance}
|
||||||
|
typeInfo={typeInfo}
|
||||||
|
draftConfig={draftConfig}
|
||||||
|
onConfigChange={setDraftConfig}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const widgetsCard =
|
||||||
|
binding.widgets.length > 0 ? (
|
||||||
|
<SectionCard
|
||||||
|
title="Widgets"
|
||||||
|
description="Widget kinds this service provides."
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{binding.widgets.map((w) => (
|
||||||
|
<div
|
||||||
|
key={w.kind}
|
||||||
|
className="flex items-center justify-between rounded border p-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{w.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{w.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">{w.kind}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Add these to the dashboard from the dashboard's edit dialog.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const confirmDelete = (
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
title="Delete service?"
|
||||||
|
message="This removes the service and any widgets that reference it. This cannot be undone."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onCancel={() => setDeleteOpen(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
deleteService.mutate(instance.id);
|
||||||
|
setDeleteOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dirty when any editable field diverges from the persisted instance (mobile SheetForm R4.5 guard).
|
||||||
|
const isDirty =
|
||||||
|
name !== instance.name ||
|
||||||
|
enabled !== instance.enabled ||
|
||||||
|
JSON.stringify(draftConfig) !== JSON.stringify(instance.config);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SheetForm
|
||||||
|
open={sheetOpen}
|
||||||
|
onOpenChange={setSheetOpen}
|
||||||
|
title={name || instance.name}
|
||||||
|
onSave={save}
|
||||||
|
onCancel={() => {
|
||||||
|
setSheetOpen(false);
|
||||||
|
navigate("/services");
|
||||||
|
}}
|
||||||
|
isPending={saveService.isPending}
|
||||||
|
isDirty={isDirty}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<Field label="Name" htmlFor="service-name">
|
||||||
|
<Input
|
||||||
|
id="service-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="service-enabled"
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={setEnabled}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="service-enabled">Enabled</Label>
|
||||||
|
</div>
|
||||||
|
{configFields}
|
||||||
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
>
|
||||||
|
Delete service
|
||||||
|
</Button>
|
||||||
|
{widgetsCard}
|
||||||
|
</div>
|
||||||
|
</SheetForm>
|
||||||
|
{confirmDelete}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -118,138 +249,180 @@ export function ServicePage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="service-enabled"
|
id="service-enabled"
|
||||||
|
className="mobile-touch-target"
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
onCheckedChange={setEnabled}
|
onCheckedChange={setEnabled}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="service-enabled">Enabled</Label>
|
<Label htmlFor="service-enabled">Enabled</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<Button onClick={save} disabled={saveService.isPending}>
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={save}
|
||||||
|
disabled={saveService.isPending}
|
||||||
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<ServiceSecretsCard instance={instance} />
|
{configFields}
|
||||||
|
|
||||||
{binding.widgets.length > 0 ? (
|
{widgetsCard}
|
||||||
<SectionCard
|
|
||||||
title="Widgets"
|
|
||||||
description="Widget kinds this service provides."
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{binding.widgets.map((w) => (
|
|
||||||
<div
|
|
||||||
key={w.kind}
|
|
||||||
className="flex items-center justify-between rounded border p-2"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{w.name}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{w.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline">{w.kind}</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Add these to the dashboard from the dashboard's edit dialog.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ConfirmDialog
|
{confirmDelete}
|
||||||
open={deleteOpen}
|
|
||||||
title="Delete service?"
|
|
||||||
message="This removes the service and any widgets that reference it. This cannot be undone."
|
|
||||||
confirmLabel="Delete"
|
|
||||||
onCancel={() => setDeleteOpen(false)}
|
|
||||||
onConfirm={() => {
|
|
||||||
deleteService.mutate(instance.id);
|
|
||||||
setDeleteOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ServiceSecretsCard({ instance }: { instance: ServiceInstance }) {
|
function ServiceConnectionFields({
|
||||||
|
instance,
|
||||||
|
typeInfo,
|
||||||
|
draftConfig,
|
||||||
|
onConfigChange,
|
||||||
|
isMobile,
|
||||||
|
}: {
|
||||||
|
instance: ServiceInstance;
|
||||||
|
typeInfo: ServiceTypeInfo | undefined;
|
||||||
|
draftConfig: Record<string, unknown>;
|
||||||
|
onConfigChange: (config: Record<string, unknown>) => void;
|
||||||
|
isMobile: boolean;
|
||||||
|
}) {
|
||||||
const saveService = useSaveServiceInstance();
|
const saveService = useSaveServiceInstance();
|
||||||
// Empty-on-edit: local state starts blank; a blank field means "keep existing".
|
// Empty-on-edit: local state starts blank; a blank field means "keep existing".
|
||||||
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
|
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const properties =
|
||||||
|
(
|
||||||
|
(typeInfo?.config_schema ?? {}) as {
|
||||||
|
properties?: Record<
|
||||||
|
string,
|
||||||
|
{ type?: string; description?: string; default?: unknown }
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
).properties ?? {};
|
||||||
|
const configEntries: Array<
|
||||||
|
[string, { type?: string; description?: string }]
|
||||||
|
> =
|
||||||
|
Object.keys(properties).length > 0
|
||||||
|
? Object.entries(properties).map(([key, schema]) => [
|
||||||
|
key,
|
||||||
|
{ type: schema?.type, description: schema?.description },
|
||||||
|
])
|
||||||
|
: Object.entries(instance.config).map(([key, value]) => [
|
||||||
|
key,
|
||||||
|
{ type: typeof value === "number" ? "integer" : "string" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
function handleUpdateConnection() {
|
||||||
|
const onlyChanged = Object.fromEntries(
|
||||||
|
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
|
||||||
|
);
|
||||||
|
saveService.mutate({
|
||||||
|
id: instance.id,
|
||||||
|
service_type: instance.service_type,
|
||||||
|
name: instance.name,
|
||||||
|
config: draftConfig,
|
||||||
|
secrets: onlyChanged,
|
||||||
|
enabled: instance.enabled,
|
||||||
|
});
|
||||||
|
setDraftSecrets({});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{configEntries.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No connection config.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{configEntries.map(([key, schema]) => {
|
||||||
|
const isNumber =
|
||||||
|
schema.type === "integer" || schema.type === "number";
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
key={key}
|
||||||
|
label={key}
|
||||||
|
htmlFor={`cfg-${key}`}
|
||||||
|
helper={schema.description}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id={`cfg-${key}`}
|
||||||
|
type={isNumber ? "number" : "text"}
|
||||||
|
value={String(draftConfig[key] ?? "")}
|
||||||
|
onChange={(e) =>
|
||||||
|
onConfigChange({
|
||||||
|
...draftConfig,
|
||||||
|
[key]: isNumber
|
||||||
|
? e.target.value === ""
|
||||||
|
? undefined
|
||||||
|
: Number(e.target.value)
|
||||||
|
: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Object.keys(instance.secrets_set).length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No secret fields.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{Object.entries(instance.secrets_set).map(([key, isSet]) => (
|
||||||
|
<div key={key} className="flex flex-col gap-1.5">
|
||||||
|
<Field
|
||||||
|
label={key}
|
||||||
|
htmlFor={`secret-${key}`}
|
||||||
|
helper="Leave blank to keep the current value."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id={`secret-${key}`}
|
||||||
|
type="password"
|
||||||
|
placeholder={isSet ? "•••••• (set)" : "Not set"}
|
||||||
|
value={draftSecrets[key] ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraftSecrets({
|
||||||
|
...draftSecrets,
|
||||||
|
[key]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{isSet ? <Badge variant="secondary">set</Badge> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button className="mobile-touch-target" onClick={handleUpdateConnection}>
|
||||||
|
Update connection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// On mobile the fields render inside the SheetForm body without a card
|
||||||
|
// wrapper (the SheetForm already provides the container). On desktop they
|
||||||
|
// keep their original SectionCard framing.
|
||||||
|
if (isMobile) {
|
||||||
|
return <div className="flex flex-col gap-3">{fields}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Connection"
|
title="Connection"
|
||||||
description="Non-secret config is read-only here for now; edit secret values below."
|
description="Edit non-secret connection config and secret values."
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3">
|
{fields}
|
||||||
{Object.entries(instance.config).length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">No connection config.</p>
|
|
||||||
) : (
|
|
||||||
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
|
|
||||||
{Object.entries(instance.config).map(([key, value]) => (
|
|
||||||
<div key={key} className="flex flex-col">
|
|
||||||
<dt className="text-xs text-muted-foreground">{key}</dt>
|
|
||||||
<dd className="truncate font-mono text-xs">{String(value)}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{Object.keys(instance.secrets_set).length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">No secret fields.</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{Object.entries(instance.secrets_set).map(([key, isSet]) => (
|
|
||||||
<div key={key} className="flex flex-col gap-1.5">
|
|
||||||
<Field
|
|
||||||
label={key}
|
|
||||||
htmlFor={`secret-${key}`}
|
|
||||||
helper="Leave blank to keep the current value."
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id={`secret-${key}`}
|
|
||||||
type="password"
|
|
||||||
placeholder={isSet ? "•••••• (set)" : "Not set"}
|
|
||||||
value={draftSecrets[key] ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraftSecrets({
|
|
||||||
...draftSecrets,
|
|
||||||
[key]: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
{isSet ? <Badge variant="secondary">set</Badge> : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
const onlyChanged = Object.fromEntries(
|
|
||||||
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
|
|
||||||
);
|
|
||||||
saveService.mutate({
|
|
||||||
id: instance.id,
|
|
||||||
service_type: instance.service_type,
|
|
||||||
name: instance.name,
|
|
||||||
config: instance.config,
|
|
||||||
secrets: onlyChanged,
|
|
||||||
enabled: instance.enabled,
|
|
||||||
});
|
|
||||||
setDraftSecrets({});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Update secrets
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ function CreateServiceDialog({
|
|||||||
{!draft ? (
|
{!draft ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{types.map((t) => (
|
{types.map((t) => (
|
||||||
<Button
|
<Button className="mobile-touch-target"
|
||||||
key={t.service_type}
|
key={t.service_type}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setDraft(emptyDraft(t.service_type))}
|
onClick={() => setDraft(emptyDraft(t.service_type))}
|
||||||
@@ -234,6 +234,7 @@ function CreateServiceDialog({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="service-enabled"
|
id="service-enabled"
|
||||||
|
className="mobile-touch-target"
|
||||||
checked={draft.enabled}
|
checked={draft.enabled}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
setDraft({ ...draft, enabled: checked })
|
setDraft({ ...draft, enabled: checked })
|
||||||
@@ -286,7 +287,7 @@ export function ServicesPage() {
|
|||||||
title="Services"
|
title="Services"
|
||||||
description="External services the app talks to. Configure URLs and API keys here; they are encrypted at rest."
|
description="External services the app talks to. Configure URLs and API keys here; they are encrypted at rest."
|
||||||
action={
|
action={
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(true)}>
|
<Button variant="outline" onClick={() => setCreateOpen(true)} className="mobile-touch-target">
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
Add service
|
Add service
|
||||||
</Button>
|
</Button>
|
||||||
@@ -328,6 +329,7 @@ export function ServicesPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="mobile-touch-target"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(`/services/${s.service_type}/${s.id}`)
|
navigate(`/services/${s.service_type}/${s.id}`)
|
||||||
}
|
}
|
||||||
@@ -337,7 +339,7 @@ export function ServicesPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-destructive"
|
className="mobile-touch-target h-8 w-8 text-destructive"
|
||||||
onClick={() => setDeleteId(s.id)}
|
onClick={() => setDeleteId(s.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
|
|||||||
+144
-45
@@ -17,6 +17,8 @@ import {
|
|||||||
useSaveSSHKey,
|
useSaveSSHKey,
|
||||||
useTestMonitoringMachineSSH,
|
useTestMonitoringMachineSSH,
|
||||||
} from "../hooks/useSettings";
|
} from "../hooks/useSettings";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
|
import { SheetForm } from "@/components/ui/sheet-form";
|
||||||
import { DialogFooter } from "../components/DialogFooter";
|
import { DialogFooter } from "../components/DialogFooter";
|
||||||
import { HoverEditButton } from "../components/HoverEditButton";
|
import { HoverEditButton } from "../components/HoverEditButton";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
@@ -126,6 +128,30 @@ function emptyMachine(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dirty check for the machine editor SheetForm guard (spec R4.5).
|
||||||
|
* Pragmatic field-by-field comparison of the user-editable fields. In create
|
||||||
|
* mode (editingMachine is null) the form is always dirty.
|
||||||
|
*/
|
||||||
|
function isMachineDraftDirty(
|
||||||
|
draft: MonitoringMachineInput,
|
||||||
|
editingMachine: MonitoringMachine | null,
|
||||||
|
): boolean {
|
||||||
|
if (!editingMachine) return true;
|
||||||
|
return (
|
||||||
|
draft.name !== editingMachine.name ||
|
||||||
|
draft.host !== editingMachine.host ||
|
||||||
|
draft.mode !== editingMachine.mode ||
|
||||||
|
draft.port !== editingMachine.port ||
|
||||||
|
draft.username !== editingMachine.username ||
|
||||||
|
draft.ssh_key_id !== editingMachine.ssh_key_id ||
|
||||||
|
draft.enabled !== editingMachine.enabled ||
|
||||||
|
draft.notes !== editingMachine.notes ||
|
||||||
|
JSON.stringify([...draft.services].sort()) !==
|
||||||
|
JSON.stringify([...editingMachine.services].sort())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function MachineEditor({
|
function MachineEditor({
|
||||||
title,
|
title,
|
||||||
hint,
|
hint,
|
||||||
@@ -230,6 +256,7 @@ function MachineEditor({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="machine-enabled"
|
id="machine-enabled"
|
||||||
|
className="mobile-touch-target"
|
||||||
checked={draft.enabled}
|
checked={draft.enabled}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
setDraft((current) => ({ ...current, enabled: checked }))
|
setDraft((current) => ({ ...current, enabled: checked }))
|
||||||
@@ -439,6 +466,7 @@ function MachineEditor({
|
|||||||
</Alert>
|
</Alert>
|
||||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onValidateSSH}
|
onClick={onValidateSSH}
|
||||||
disabled={
|
disabled={
|
||||||
@@ -532,7 +560,7 @@ function SSHKeyManager({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="mobile-touch-target w-full"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
clear();
|
clear();
|
||||||
}}
|
}}
|
||||||
@@ -651,6 +679,7 @@ function SSHKeyManager({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
disabled={saveKey.isPending}
|
disabled={saveKey.isPending}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await saveKey.mutateAsync(draft);
|
await saveKey.mutateAsync(draft);
|
||||||
@@ -660,6 +689,7 @@ function SSHKeyManager({
|
|||||||
{editing ? "Update key" : "Save key"}
|
{editing ? "Update key" : "Save key"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={generateKey.isPending}
|
disabled={generateKey.isPending}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -683,11 +713,16 @@ function SSHKeyManager({
|
|||||||
>
|
>
|
||||||
{generateKey.isPending ? "Generating..." : "Generate key"}
|
{generateKey.isPending ? "Generating..." : "Generate key"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={clear}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={clear}
|
||||||
|
className="mobile-touch-target"
|
||||||
|
>
|
||||||
Clear
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
{selectedKey && (
|
{selectedKey && (
|
||||||
<Button
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => deleteKey.mutate(selectedKey.id)}
|
onClick={() => deleteKey.mutate(selectedKey.id)}
|
||||||
>
|
>
|
||||||
@@ -753,7 +788,11 @@ function ResetLocalDatabaseCard() {
|
|||||||
Reset the local SQLite settings/media index databases after
|
Reset the local SQLite settings/media index databases after
|
||||||
acknowledging the data loss.
|
acknowledging the data loss.
|
||||||
</p>
|
</p>
|
||||||
<Button variant="destructive" onClick={() => setOpen(true)}>
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="mobile-touch-target"
|
||||||
|
>
|
||||||
Reset local database
|
Reset local database
|
||||||
</Button>
|
</Button>
|
||||||
{resetDatabase.error && (
|
{resetDatabase.error && (
|
||||||
@@ -780,6 +819,7 @@ function ResetLocalDatabaseCard() {
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
className="mobile-touch-target"
|
||||||
checked={ackSettings}
|
checked={ackSettings}
|
||||||
onCheckedChange={(checked) => setAckSettings(Boolean(checked))}
|
onCheckedChange={(checked) => setAckSettings(Boolean(checked))}
|
||||||
/>
|
/>
|
||||||
@@ -787,6 +827,7 @@ function ResetLocalDatabaseCard() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
className="mobile-touch-target"
|
||||||
checked={ackIndex}
|
checked={ackIndex}
|
||||||
onCheckedChange={(checked) => setAckIndex(Boolean(checked))}
|
onCheckedChange={(checked) => setAckIndex(Boolean(checked))}
|
||||||
/>
|
/>
|
||||||
@@ -794,6 +835,7 @@ function ResetLocalDatabaseCard() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
className="mobile-touch-target"
|
||||||
checked={ackIrreversible}
|
checked={ackIrreversible}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
setAckIrreversible(Boolean(checked))
|
setAckIrreversible(Boolean(checked))
|
||||||
@@ -850,6 +892,7 @@ export function Settings() {
|
|||||||
const [editingMachine, setEditingMachine] =
|
const [editingMachine, setEditingMachine] =
|
||||||
useState<MonitoringMachine | null>(null);
|
useState<MonitoringMachine | null>(null);
|
||||||
const [selectedMachineId, setSelectedMachineId] = useState("");
|
const [selectedMachineId, setSelectedMachineId] = useState("");
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const orderedMachines = useMemo(() => machines ?? [], [machines]);
|
const orderedMachines = useMemo(() => machines ?? [], [machines]);
|
||||||
const selectedMachine = useMemo(
|
const selectedMachine = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -970,7 +1013,7 @@ export function Settings() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="mobile-touch-target w-full"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
clearSSHValidation();
|
clearSSHValidation();
|
||||||
setMachineDraft(emptyMachine("local"));
|
setMachineDraft(emptyMachine("local"));
|
||||||
@@ -1086,6 +1129,7 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
openEditMachine(
|
openEditMachine(
|
||||||
@@ -1113,6 +1157,7 @@ export function Settings() {
|
|||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => setDeleteMachineId(selectedMachine.id)}
|
onClick={() => setDeleteMachineId(selectedMachine.id)}
|
||||||
>
|
>
|
||||||
@@ -1135,21 +1180,25 @@ export function Settings() {
|
|||||||
)}
|
)}
|
||||||
{tab === "danger" && <ResetLocalDatabaseCard />}
|
{tab === "danger" && <ResetLocalDatabaseCard />}
|
||||||
</TabbedCard>
|
</TabbedCard>
|
||||||
<Dialog
|
{isMobile ? (
|
||||||
open={machineDialogOpen}
|
<SheetForm
|
||||||
onOpenChange={(open) => {
|
open={machineDialogOpen}
|
||||||
if (!open) closeMachineDialog();
|
onOpenChange={(open) => {
|
||||||
}}
|
if (!open) closeMachineDialog();
|
||||||
>
|
}}
|
||||||
<DialogContent className="sm:max-w-4xl">
|
title={machineDraft.id ? "Edit machine" : "Create machine"}
|
||||||
<DialogHeader>
|
onSave={() => {
|
||||||
<DialogTitle>
|
void saveMachineDraft(machineDraft);
|
||||||
{machineDraft.id ? "Edit machine" : "Create machine"}
|
}}
|
||||||
</DialogTitle>
|
onCancel={closeMachineDialog}
|
||||||
<DialogDescription>
|
isPending={saveMachine.isPending}
|
||||||
{machineDraft.mode === "local" ? "Local API host" : "SSH target"}
|
saveDisabled={
|
||||||
</DialogDescription>
|
!machineDraft.name ||
|
||||||
</DialogHeader>
|
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
|
||||||
|
}
|
||||||
|
saveLabel={machineDraft.id ? "Save machine" : "Create machine"}
|
||||||
|
isDirty={isMachineDraftDirty(machineDraft, editingMachine)}
|
||||||
|
>
|
||||||
<MachineEditor
|
<MachineEditor
|
||||||
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
|
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
|
||||||
title={
|
title={
|
||||||
@@ -1170,32 +1219,82 @@ export function Settings() {
|
|||||||
sshValidationError={sshValidationError}
|
sshValidationError={sshValidationError}
|
||||||
sshValidationStatus={sshValidationStatus}
|
sshValidationStatus={sshValidationStatus}
|
||||||
/>
|
/>
|
||||||
<DialogFooter
|
{machineDraft.id ? (
|
||||||
onCancel={closeMachineDialog}
|
<Button
|
||||||
cancelLabel="Cancel"
|
className="mobile-touch-target"
|
||||||
onConfirm={() => {
|
variant="destructive"
|
||||||
void saveMachineDraft(machineDraft);
|
onClick={() => setDeleteMachineId(machineDraft.id as string)}
|
||||||
}}
|
>
|
||||||
confirmLabel={machineDraft.id ? "Save machine" : "Create machine"}
|
Delete machine
|
||||||
confirmDisabled={
|
</Button>
|
||||||
!machineDraft.name ||
|
) : null}
|
||||||
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
|
</SheetForm>
|
||||||
}
|
) : (
|
||||||
secondaryAction={
|
<Dialog
|
||||||
machineDraft.id ? (
|
open={machineDialogOpen}
|
||||||
<Button
|
onOpenChange={(open) => {
|
||||||
variant="destructive"
|
if (!open) closeMachineDialog();
|
||||||
onClick={() => {
|
}}
|
||||||
setDeleteMachineId(machineDraft.id as string);
|
>
|
||||||
}}
|
<DialogContent className="sm:max-w-4xl">
|
||||||
>
|
<DialogHeader>
|
||||||
Delete
|
<DialogTitle>
|
||||||
</Button>
|
{machineDraft.id ? "Edit machine" : "Create machine"}
|
||||||
) : undefined
|
</DialogTitle>
|
||||||
}
|
<DialogDescription>
|
||||||
/>
|
{machineDraft.mode === "local"
|
||||||
</DialogContent>
|
? "Local API host"
|
||||||
</Dialog>
|
: "SSH target"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<MachineEditor
|
||||||
|
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
|
||||||
|
title={
|
||||||
|
machineDraft.id
|
||||||
|
? machineDraft.name || "Edit machine"
|
||||||
|
: "New machine"
|
||||||
|
}
|
||||||
|
hint={
|
||||||
|
machineDraft.mode === "local" ? "Local API host" : "SSH target"
|
||||||
|
}
|
||||||
|
machine={machineDraft}
|
||||||
|
sshKeys={sshKeys}
|
||||||
|
editingMachine={editingMachine}
|
||||||
|
onChange={updateMachineDraft}
|
||||||
|
onValidateSSH={validateMachineSSH}
|
||||||
|
isValidatingSSH={testMachineSSH.isPending}
|
||||||
|
sshValidationMessage={sshValidationMessage}
|
||||||
|
sshValidationError={sshValidationError}
|
||||||
|
sshValidationStatus={sshValidationStatus}
|
||||||
|
/>
|
||||||
|
<DialogFooter
|
||||||
|
onCancel={closeMachineDialog}
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
onConfirm={() => {
|
||||||
|
void saveMachineDraft(machineDraft);
|
||||||
|
}}
|
||||||
|
confirmLabel={machineDraft.id ? "Save machine" : "Create machine"}
|
||||||
|
confirmDisabled={
|
||||||
|
!machineDraft.name ||
|
||||||
|
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
|
||||||
|
}
|
||||||
|
secondaryAction={
|
||||||
|
machineDraft.id ? (
|
||||||
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteMachineId(machineDraft.id as string);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={Boolean(deleteMachineId)}
|
open={Boolean(deleteMachineId)}
|
||||||
title="Delete machine?"
|
title="Delete machine?"
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||||
|
import { SheetForm } from "@/components/ui/sheet-form";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -51,8 +52,13 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { MetricCard } from "../components/MetricCard";
|
import { MetricCard } from "../components/MetricCard";
|
||||||
import { SessionActivityPanel } from "../components/SessionActivityPanel";
|
import { SessionActivityPanel } from "../components/SessionActivityPanel";
|
||||||
|
import {
|
||||||
|
MobileCardRow,
|
||||||
|
type MobileCardField,
|
||||||
|
} from "@/components/ui/mobile-card";
|
||||||
import { useUsers } from "../hooks/useUsers";
|
import { useUsers } from "../hooks/useUsers";
|
||||||
import { useActivity } from "../hooks/useDashboard";
|
import { useActivity } from "../hooks/useDashboard";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
import { useSendUserMessage } from "../hooks/useSendUserMessage";
|
import { useSendUserMessage } from "../hooks/useSendUserMessage";
|
||||||
import { useUserMessageQueueStatus } from "../hooks/useUserMessageQueueStatus";
|
import { useUserMessageQueueStatus } from "../hooks/useUserMessageQueueStatus";
|
||||||
import type { UserDirectoryItem } from "../types";
|
import type { UserDirectoryItem } from "../types";
|
||||||
@@ -63,9 +69,9 @@ import {
|
|||||||
type UserStateItem,
|
type UserStateItem,
|
||||||
} from "../userState";
|
} from "../userState";
|
||||||
|
|
||||||
// Replaces MUI `useMediaQuery` (a 6b-owned component) with a dependency-free
|
// Local breakpoint for the compose dialog (slice 6b uses 900px for fullScreen).
|
||||||
// matchMedia hook for the compose dialog's mobile fullScreen behavior.
|
// The shared `useIsMobile` from hooks/ (768px) drives the directory table branch.
|
||||||
function useIsMobile(query = "(max-width: 900px)") {
|
function useComposeViewport(query = "(max-width: 900px)") {
|
||||||
const [mobile, setMobile] = useState(() =>
|
const [mobile, setMobile] = useState(() =>
|
||||||
typeof window !== "undefined" && typeof window.matchMedia === "function"
|
typeof window !== "undefined" && typeof window.matchMedia === "function"
|
||||||
? window.matchMedia(query).matches
|
? window.matchMedia(query).matches
|
||||||
@@ -102,11 +108,35 @@ function activityBadgeVariant(
|
|||||||
|
|
||||||
const DEFAULT_HTML_BODY = "<p>Hello,</p><p> </p><p>Best,<br />Manage</p>";
|
const DEFAULT_HTML_BODY = "<p>Hello,</p><p> </p><p>Best,<br />Manage</p>";
|
||||||
|
|
||||||
|
// Mobile card fields (spec R3.2): display name is primary; username, activity
|
||||||
|
// badge, and email give the at-a-glance info for scanning users on a phone.
|
||||||
|
// See OpenSpec change `mobile-responsive-parity`, tasks slice 5.1.
|
||||||
|
const userCardFields: MobileCardField<UserStateItem>[] = [
|
||||||
|
{ key: "name", label: "Name", render: (r) => userLabel(r), primary: true },
|
||||||
|
{
|
||||||
|
key: "username",
|
||||||
|
label: "Username",
|
||||||
|
render: (r) =>
|
||||||
|
r.username && r.username !== r.display_name ? r.username : r.jellyfin_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "activity",
|
||||||
|
label: "Activity",
|
||||||
|
render: (r) => (
|
||||||
|
<Badge variant={activityBadgeVariant(r.activity_label)}>
|
||||||
|
{r.activity_label}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "email", label: "Email", render: (r) => r.email || "—" },
|
||||||
|
];
|
||||||
|
|
||||||
export function UsersPage() {
|
export function UsersPage() {
|
||||||
const { data, isError, error } = useUsers();
|
const { data, isError, error } = useUsers();
|
||||||
const { data: activity } = useActivity();
|
const { data: activity } = useActivity();
|
||||||
const queueStatusQuery = useUserMessageQueueStatus();
|
const queueStatusQuery = useUserMessageQueueStatus();
|
||||||
const sendUserMessage = useSendUserMessage();
|
const sendUserMessage = useSendUserMessage();
|
||||||
|
const isComposeMobile = useComposeViewport();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -479,154 +509,183 @@ export function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[660px] overflow-auto rounded-lg border">
|
<div className="max-h-[660px] overflow-auto rounded-lg border">
|
||||||
<Table aria-label="Users table">
|
{isMobile ? (
|
||||||
<TableHeader>
|
<div className="p-3">
|
||||||
<TableRow>
|
<MobileCardRow
|
||||||
<TableHead className={cn(thBase, "w-14 p-2")}>
|
rows={filteredRows}
|
||||||
<Checkbox
|
fields={userCardFields}
|
||||||
checked={allVisibleSelected}
|
getRowId={(r) => r.jellyfin_id}
|
||||||
aria-label="Select all visible users"
|
onRowClick={(r) => setSearchParams({ user: r.jellyfin_id })}
|
||||||
onCheckedChange={(checked) =>
|
actions={(r) => {
|
||||||
toggleVisibleSelection(checked === true)
|
const checked = selectedIdSet.has(r.jellyfin_id);
|
||||||
}
|
return (
|
||||||
/>
|
<Checkbox
|
||||||
</TableHead>
|
checked={checked}
|
||||||
<TableHead className={thBase}>User</TableHead>
|
aria-label={`Select ${userLabel(r)}`}
|
||||||
<TableHead className={thBase}>Email</TableHead>
|
className="mobile-touch-target"
|
||||||
<TableHead className={cn(thBase, "w-[132px] text-center")}>
|
onClick={(e) => e.stopPropagation()}
|
||||||
Activity
|
onCheckedChange={() =>
|
||||||
</TableHead>
|
toggleUserSelected(r.jellyfin_id)
|
||||||
<TableHead
|
}
|
||||||
className={cn(
|
/>
|
||||||
thBase,
|
);
|
||||||
"hidden w-[140px] text-center md:table-cell",
|
}}
|
||||||
)}
|
/>
|
||||||
>
|
</div>
|
||||||
Type
|
) : (
|
||||||
</TableHead>
|
<Table aria-label="Users table">
|
||||||
<TableHead className={cn(thBase, "w-[132px] text-center")}>
|
<TableHeader>
|
||||||
Jellyseerr
|
<TableRow>
|
||||||
</TableHead>
|
<TableHead className={cn(thBase, "w-14 p-2")}>
|
||||||
<TableHead
|
<Checkbox
|
||||||
className={cn(
|
checked={allVisibleSelected}
|
||||||
thBase,
|
aria-label="Select all visible users"
|
||||||
"hidden w-[120px] text-center md:table-cell",
|
onCheckedChange={(checked) =>
|
||||||
)}
|
toggleVisibleSelection(checked === true)
|
||||||
>
|
}
|
||||||
Role
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className={thBase}>Permissions</TableHead>
|
<TableHead className={thBase}>User</TableHead>
|
||||||
<TableHead
|
<TableHead className={thBase}>Email</TableHead>
|
||||||
className={cn(
|
<TableHead className={cn(thBase, "w-[132px] text-center")}>
|
||||||
thBase,
|
Activity
|
||||||
"hidden w-24 text-center md:table-cell",
|
</TableHead>
|
||||||
)}
|
<TableHead
|
||||||
>
|
className={cn(
|
||||||
Reqs
|
thBase,
|
||||||
</TableHead>
|
"hidden w-[140px] text-center md:table-cell",
|
||||||
<TableHead
|
)}
|
||||||
className={cn(
|
|
||||||
thBase,
|
|
||||||
"hidden w-[120px] text-center md:table-cell",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Contact
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredRows.map((row) => {
|
|
||||||
const linked =
|
|
||||||
row.jellyseerr_user_id !== null &&
|
|
||||||
row.jellyseerr_user_id !== undefined;
|
|
||||||
const checked = selectedIdSet.has(row.jellyfin_id);
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={row.jellyfin_id}
|
|
||||||
data-state={
|
|
||||||
checked || selectedUser?.jellyfin_id === row.jellyfin_id
|
|
||||||
? "selected"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => setSearchParams({ user: row.jellyfin_id })}
|
|
||||||
>
|
>
|
||||||
<TableCell className="w-14 p-2">
|
Type
|
||||||
<Checkbox
|
</TableHead>
|
||||||
checked={checked}
|
<TableHead className={cn(thBase, "w-[132px] text-center")}>
|
||||||
aria-label={`Select ${userLabel(row)}`}
|
Jellyseerr
|
||||||
onClick={(event) => event.stopPropagation()}
|
</TableHead>
|
||||||
onCheckedChange={() =>
|
<TableHead
|
||||||
toggleUserSelected(row.jellyfin_id)
|
className={cn(
|
||||||
}
|
thBase,
|
||||||
/>
|
"hidden w-[120px] text-center md:table-cell",
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell>
|
>
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
Role
|
||||||
<Avatar className="size-9">
|
</TableHead>
|
||||||
<AvatarImage
|
<TableHead className={thBase}>Permissions</TableHead>
|
||||||
src={row.avatar || undefined}
|
<TableHead
|
||||||
alt={userLabel(row)}
|
className={cn(
|
||||||
/>
|
thBase,
|
||||||
<AvatarFallback>
|
"hidden w-24 text-center md:table-cell",
|
||||||
{userLabel(row).charAt(0).toUpperCase()}
|
)}
|
||||||
</AvatarFallback>
|
>
|
||||||
</Avatar>
|
Reqs
|
||||||
<div className="min-w-0">
|
</TableHead>
|
||||||
<div className="truncate font-semibold leading-tight">
|
<TableHead
|
||||||
{userLabel(row)}
|
className={cn(
|
||||||
</div>
|
thBase,
|
||||||
<div className="truncate text-xs text-muted-foreground">
|
"hidden w-[120px] text-center md:table-cell",
|
||||||
{row.username && row.username !== row.display_name
|
)}
|
||||||
? row.username
|
>
|
||||||
: row.jellyfin_id}
|
Contact
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredRows.map((row) => {
|
||||||
|
const linked =
|
||||||
|
row.jellyseerr_user_id !== null &&
|
||||||
|
row.jellyseerr_user_id !== undefined;
|
||||||
|
const checked = selectedIdSet.has(row.jellyfin_id);
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={row.jellyfin_id}
|
||||||
|
data-state={
|
||||||
|
checked ||
|
||||||
|
selectedUser?.jellyfin_id === row.jellyfin_id
|
||||||
|
? "selected"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
setSearchParams({ user: row.jellyfin_id })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TableCell className="w-14 p-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
aria-label={`Select ${userLabel(row)}`}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
toggleUserSelected(row.jellyfin_id)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Avatar className="size-9">
|
||||||
|
<AvatarImage
|
||||||
|
src={row.avatar || undefined}
|
||||||
|
alt={userLabel(row)}
|
||||||
|
/>
|
||||||
|
<AvatarFallback>
|
||||||
|
{userLabel(row).charAt(0).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-semibold leading-tight">
|
||||||
|
{userLabel(row)}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
|
{row.username &&
|
||||||
|
row.username !== row.display_name
|
||||||
|
? row.username
|
||||||
|
: row.jellyfin_id}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
<TableCell>
|
<div className="truncate font-medium">
|
||||||
<div className="truncate font-medium">
|
{row.email || "—"}
|
||||||
{row.email || "—"}
|
</div>
|
||||||
</div>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="text-center">
|
||||||
<TableCell className="text-center">
|
<Badge
|
||||||
<Badge
|
variant={activityBadgeVariant(row.activity_label)}
|
||||||
variant={activityBadgeVariant(row.activity_label)}
|
>
|
||||||
>
|
{row.activity_label}
|
||||||
{row.activity_label}
|
</Badge>
|
||||||
</Badge>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="hidden text-center md:table-cell">
|
||||||
<TableCell className="hidden text-center md:table-cell">
|
<Badge variant="outline">{row.user_type_label}</Badge>
|
||||||
<Badge variant="outline">{row.user_type_label}</Badge>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="text-center">
|
||||||
<TableCell className="text-center">
|
<Badge variant={linked ? "success" : "secondary"}>
|
||||||
<Badge variant={linked ? "success" : "secondary"}>
|
{linked
|
||||||
{linked
|
? `Linked #${row.jellyseerr_user_id}`
|
||||||
? `Linked #${row.jellyseerr_user_id}`
|
: "Base only"}
|
||||||
: "Base only"}
|
</Badge>
|
||||||
</Badge>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="hidden text-center md:table-cell">
|
||||||
<TableCell className="hidden text-center md:table-cell">
|
<Badge variant="outline">{row.role}</Badge>
|
||||||
<Badge variant="outline">{row.role}</Badge>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="whitespace-normal">
|
||||||
<TableCell className="whitespace-normal">
|
{row.permissions_label}
|
||||||
{row.permissions_label}
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="hidden text-center font-semibold md:table-cell">
|
||||||
<TableCell className="hidden text-center font-semibold md:table-cell">
|
{row.request_count ?? "—"}
|
||||||
{row.request_count ?? "—"}
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="hidden text-center md:table-cell">
|
||||||
<TableCell className="hidden text-center md:table-cell">
|
<Badge
|
||||||
<Badge
|
variant={row.contactable ? "success" : "secondary"}
|
||||||
variant={row.contactable ? "success" : "secondary"}
|
>
|
||||||
>
|
{row.contactable ? "Yes" : "No"}
|
||||||
{row.contactable ? "Yes" : "No"}
|
</Badge>
|
||||||
</Badge>
|
</TableCell>
|
||||||
</TableCell>
|
</TableRow>
|
||||||
</TableRow>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</TableBody>
|
||||||
</TableBody>
|
</Table>
|
||||||
</Table>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -755,229 +814,271 @@ export function UsersPage() {
|
|||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
<Dialog
|
{/* Compose dialog: SheetForm below md, Dialog at md+ (spec R4.1) */}
|
||||||
open={composeOpen}
|
{(() => {
|
||||||
onOpenChange={(open) => {
|
const composeBody = (
|
||||||
if (!open) {
|
<>
|
||||||
closeCompose();
|
{sendUserMessage.isPending ? (
|
||||||
}
|
<Progress value={100} className="animate-pulse" />
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent
|
|
||||||
className={cn(
|
|
||||||
"flex max-h-[90dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl",
|
|
||||||
isMobile &&
|
|
||||||
"inset-0 max-h-none max-w-none translate-x-0 translate-y-0 rounded-none",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<DialogHeader className="gap-1 px-4 pt-4">
|
|
||||||
<DialogTitle className="pr-8">Message selected users</DialogTitle>
|
|
||||||
<DialogDescription className="sr-only">
|
|
||||||
Compose a message to the selected deliverable users.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{sendUserMessage.isPending ? (
|
|
||||||
<Progress value={100} className="animate-pulse" />
|
|
||||||
) : null}
|
|
||||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
|
|
||||||
{sendUserMessage.isError ? (
|
|
||||||
<UIAlert variant="destructive">
|
|
||||||
<AlertDescription>
|
|
||||||
Unable to send message:{" "}
|
|
||||||
{(sendUserMessage.error as Error)?.message || "Unknown error"}
|
|
||||||
</AlertDescription>
|
|
||||||
</UIAlert>
|
|
||||||
) : null}
|
) : null}
|
||||||
{sendUserMessage.isSuccess ? (
|
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
|
||||||
|
{sendUserMessage.isError ? (
|
||||||
|
<UIAlert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
Unable to send message:{" "}
|
||||||
|
{(sendUserMessage.error as Error)?.message ||
|
||||||
|
"Unknown error"}
|
||||||
|
</AlertDescription>
|
||||||
|
</UIAlert>
|
||||||
|
) : null}
|
||||||
|
{sendUserMessage.isSuccess ? (
|
||||||
|
<UIAlert>
|
||||||
|
<AlertDescription>
|
||||||
|
Queued for {sendUserMessage.data.recipient_count} recipients
|
||||||
|
{sendUserMessage.data.attachment_count
|
||||||
|
? ` with ${sendUserMessage.data.attachment_count} attachment${sendUserMessage.data.attachment_count === 1 ? "" : "s"}`
|
||||||
|
: ""}
|
||||||
|
{sendUserMessage.data.request_id
|
||||||
|
? ` (request ${sendUserMessage.data.request_id.slice(0, 8)})`
|
||||||
|
: ""}
|
||||||
|
.
|
||||||
|
</AlertDescription>
|
||||||
|
</UIAlert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{queueBanner ? (
|
||||||
|
<UIAlert
|
||||||
|
variant={
|
||||||
|
queueBanner.severity === "error" ? "destructive" : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{queueBanner.message}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary">{queueBanner.countLabel}</Badge>
|
||||||
|
</div>
|
||||||
|
</UIAlert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<UIAlert>
|
<UIAlert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Queued for {sendUserMessage.data.recipient_count} recipients
|
{selectedRows.length} selected,{" "}
|
||||||
{sendUserMessage.data.attachment_count
|
{selectedDeliverableRows.length} deliverable.
|
||||||
? ` with ${sendUserMessage.data.attachment_count} attachment${sendUserMessage.data.attachment_count === 1 ? "" : "s"}`
|
{skippedRows.length
|
||||||
|
? ` ${skippedRows.length} will be skipped because they do not have a deliverable email address.`
|
||||||
: ""}
|
: ""}
|
||||||
{sendUserMessage.data.request_id
|
|
||||||
? ` (request ${sendUserMessage.data.request_id.slice(0, 8)})`
|
|
||||||
: ""}
|
|
||||||
.
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</UIAlert>
|
</UIAlert>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{queueBanner ? (
|
<div className="flex flex-wrap gap-1">
|
||||||
<UIAlert
|
{selectedDeliverableRows.map((row) => (
|
||||||
variant={
|
<Badge key={row.jellyfin_id} variant="secondary">
|
||||||
queueBanner.severity === "error" ? "destructive" : undefined
|
{`${userLabel(row)} <${row.email}>`}
|
||||||
}
|
</Badge>
|
||||||
>
|
))}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
</div>
|
||||||
<span className="text-sm font-semibold">
|
|
||||||
{queueBanner.message}
|
|
||||||
</span>
|
|
||||||
<Badge variant="secondary">{queueBanner.countLabel}</Badge>
|
|
||||||
</div>
|
|
||||||
</UIAlert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<UIAlert>
|
<div className="flex flex-col gap-1.5">
|
||||||
<AlertDescription>
|
<Label htmlFor="compose-subject">Subject</Label>
|
||||||
{selectedRows.length} selected, {selectedDeliverableRows.length}{" "}
|
<Input
|
||||||
deliverable.
|
id="compose-subject"
|
||||||
{skippedRows.length
|
value={subject}
|
||||||
? ` ${skippedRows.length} will be skipped because they do not have a deliverable email address.`
|
onChange={(event) => setSubject(event.target.value)}
|
||||||
: ""}
|
|
||||||
</AlertDescription>
|
|
||||||
</UIAlert>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{selectedDeliverableRows.map((row) => (
|
|
||||||
<Badge key={row.jellyfin_id} variant="secondary">
|
|
||||||
{`${userLabel(row)} <${row.email}>`}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="compose-subject">Subject</Label>
|
|
||||||
<Input
|
|
||||||
id="compose-subject"
|
|
||||||
value={subject}
|
|
||||||
onChange={(event) => setSubject(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<UiButton
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => insertMarkup("<strong>", "</strong>")}
|
|
||||||
aria-label="Bold"
|
|
||||||
>
|
|
||||||
<Bold />
|
|
||||||
</UiButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Bold</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<UiButton
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => insertMarkup("<em>", "</em>")}
|
|
||||||
aria-label="Italic"
|
|
||||||
>
|
|
||||||
<Italic />
|
|
||||||
</UiButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Italic</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<UiButton
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={addLink}
|
|
||||||
aria-label="Link"
|
|
||||||
>
|
|
||||||
<Link />
|
|
||||||
</UiButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Link</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<UiButton
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => insertMarkup("<ul><li>", "</li></ul>")}
|
|
||||||
aria-label="Bullet list"
|
|
||||||
>
|
|
||||||
<List />
|
|
||||||
</UiButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Bullet list</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="compose-body">HTML message body</Label>
|
|
||||||
<Textarea
|
|
||||||
id="compose-body"
|
|
||||||
ref={htmlBodyRef}
|
|
||||||
value={htmlBody}
|
|
||||||
onChange={(event) => setHtmlBody(event.target.value)}
|
|
||||||
className="min-h-[260px] font-mono"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Formatting is sent as HTML; a plain-text fallback is generated
|
|
||||||
automatically.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border bg-muted/40 p-4">
|
|
||||||
<p className="mb-2 text-sm font-semibold">Preview</p>
|
|
||||||
<div className="overflow-hidden rounded-md border bg-card">
|
|
||||||
<iframe
|
|
||||||
title="Email preview"
|
|
||||||
sandbox=""
|
|
||||||
srcDoc={`<!doctype html><html><head><meta charset="utf-8"><style>body{font-family:Roboto,Arial,sans-serif;padding:16px;margin:0;background:#fff;color:#111;line-height:1.5}</style></head><body>${htmlBody || "<p>(Empty)</p>"}</body></html>`}
|
|
||||||
style={{ width: "100%", minHeight: 220, border: 0 }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
<UiButton asChild variant="outline">
|
<Tooltip>
|
||||||
<label className="cursor-pointer">
|
<TooltipTrigger asChild>
|
||||||
<Paperclip />
|
<UiButton
|
||||||
Add attachment
|
variant="ghost"
|
||||||
<input
|
size="icon"
|
||||||
hidden
|
className="mobile-touch-target"
|
||||||
type="file"
|
onClick={() => insertMarkup("<strong>", "</strong>")}
|
||||||
multiple
|
aria-label="Bold"
|
||||||
onChange={handleAttachments}
|
>
|
||||||
|
<Bold />
|
||||||
|
</UiButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Bold</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<UiButton
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={() => insertMarkup("<em>", "</em>")}
|
||||||
|
aria-label="Italic"
|
||||||
|
>
|
||||||
|
<Italic />
|
||||||
|
</UiButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Italic</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<UiButton
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={addLink}
|
||||||
|
aria-label="Link"
|
||||||
|
>
|
||||||
|
<Link />
|
||||||
|
</UiButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Link</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<UiButton
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={() => insertMarkup("<ul><li>", "</li></ul>")}
|
||||||
|
aria-label="Bullet list"
|
||||||
|
>
|
||||||
|
<List />
|
||||||
|
</UiButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Bullet list</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="compose-body">HTML message body</Label>
|
||||||
|
<Textarea
|
||||||
|
id="compose-body"
|
||||||
|
ref={htmlBodyRef}
|
||||||
|
value={htmlBody}
|
||||||
|
onChange={(event) => setHtmlBody(event.target.value)}
|
||||||
|
className="min-h-[260px] font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Formatting is sent as HTML; a plain-text fallback is generated
|
||||||
|
automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-muted/40 p-4">
|
||||||
|
<p className="mb-2 text-sm font-semibold">Preview</p>
|
||||||
|
<div className="overflow-hidden rounded-md border bg-card">
|
||||||
|
<iframe
|
||||||
|
title="Email preview"
|
||||||
|
sandbox=""
|
||||||
|
srcDoc={`<!doctype html><html><head><meta charset="utf-8"><style>body{font-family:Roboto,Arial,sans-serif;padding:16px;margin:0;background:#fff;color:#111;line-height:1.5}</style></head><body>${htmlBody || "<p>(Empty)</p>"}</body></html>`}
|
||||||
|
style={{ width: "100%", minHeight: 220, border: 0 }}
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
</UiButton>
|
</div>
|
||||||
{attachments.map((file, index) => (
|
|
||||||
<Badge
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
key={`${file.name}-${index}`}
|
<UiButton asChild variant="outline">
|
||||||
variant="secondary"
|
<label className="cursor-pointer">
|
||||||
className="gap-1 pr-1"
|
<Paperclip />
|
||||||
>
|
Add attachment
|
||||||
{file.name}
|
<input
|
||||||
<button
|
hidden
|
||||||
type="button"
|
type="file"
|
||||||
aria-label={`Remove ${file.name}`}
|
multiple
|
||||||
onClick={() => removeAttachment(index)}
|
onChange={handleAttachments}
|
||||||
className="inline-flex items-center text-current [&>svg]:size-3"
|
/>
|
||||||
|
</label>
|
||||||
|
</UiButton>
|
||||||
|
{attachments.map((file, index) => (
|
||||||
|
<Badge
|
||||||
|
key={`${file.name}-${index}`}
|
||||||
|
variant="secondary"
|
||||||
|
className="gap-1 pr-1"
|
||||||
>
|
>
|
||||||
<Trash2 />
|
{file.name}
|
||||||
</button>
|
<button
|
||||||
</Badge>
|
type="button"
|
||||||
))}
|
aria-label={`Remove ${file.name}`}
|
||||||
|
onClick={() => removeAttachment(index)}
|
||||||
|
className="mobile-touch-target inline-flex items-center text-current [&>svg]:size-3"
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
<DialogFooter className="m-0 border-t p-4">
|
);
|
||||||
<UiButton variant="ghost" onClick={closeCompose}>
|
|
||||||
Cancel
|
if (isMobile) {
|
||||||
</UiButton>
|
return (
|
||||||
<UiButton
|
<SheetForm
|
||||||
variant="default"
|
open={composeOpen}
|
||||||
disabled={
|
onOpenChange={(open) => {
|
||||||
sendUserMessage.isPending ||
|
if (!open) closeCompose();
|
||||||
!selectedDeliverableRows.length ||
|
}}
|
||||||
!subject.trim()
|
title="Message selected users"
|
||||||
|
onSave={handleSend}
|
||||||
|
onCancel={closeCompose}
|
||||||
|
isPending={sendUserMessage.isPending}
|
||||||
|
saveDisabled={!selectedDeliverableRows.length || !subject.trim()}
|
||||||
|
saveLabel="Send message"
|
||||||
|
isDirty={
|
||||||
|
subject.trim() !== "" ||
|
||||||
|
htmlBody.trim() !== DEFAULT_HTML_BODY.trim() ||
|
||||||
|
attachments.length > 0
|
||||||
}
|
}
|
||||||
onClick={handleSend}
|
|
||||||
>
|
>
|
||||||
<Send />
|
<div className="flex flex-col gap-4">{composeBody}</div>
|
||||||
Send message
|
</SheetForm>
|
||||||
</UiButton>
|
);
|
||||||
</DialogFooter>
|
}
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={composeOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
closeCompose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
"flex max-h-[90dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl",
|
||||||
|
isComposeMobile &&
|
||||||
|
"inset-0 max-h-none max-w-none translate-x-0 translate-y-0 rounded-none",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DialogHeader className="gap-1 px-4 pt-4">
|
||||||
|
<DialogTitle className="pr-8">
|
||||||
|
Message selected users
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
Compose a message to the selected deliverable users.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{composeBody}
|
||||||
|
<DialogFooter className="m-0 border-t p-4">
|
||||||
|
<UiButton variant="ghost" onClick={closeCompose}>
|
||||||
|
Cancel
|
||||||
|
</UiButton>
|
||||||
|
<UiButton
|
||||||
|
variant="default"
|
||||||
|
disabled={
|
||||||
|
sendUserMessage.isPending ||
|
||||||
|
!selectedDeliverableRows.length ||
|
||||||
|
!subject.trim()
|
||||||
|
}
|
||||||
|
onClick={handleSend}
|
||||||
|
>
|
||||||
|
<Send />
|
||||||
|
Send message
|
||||||
|
</UiButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { Dashboard } from "../Dashboard";
|
import { Dashboard } from "../Dashboard";
|
||||||
import type { DashboardShortcut } from "../../types";
|
import type {
|
||||||
|
DashboardShortcut,
|
||||||
|
ServiceInstance,
|
||||||
|
WidgetInstance,
|
||||||
|
} from "../../types";
|
||||||
|
|
||||||
// Stub the composed widgets so the test exercises Dashboard's own behavior
|
// Stub the composed widgets so the test exercises Dashboard's own behavior
|
||||||
// (shortcut CRUD) without rendering widgets or their data queries.
|
// (shortcut CRUD) without rendering widgets or their data queries.
|
||||||
vi.mock("../../components/WidgetInstance", () => ({
|
vi.mock("../../components/WidgetInstance", () => ({
|
||||||
WidgetInstanceCard: () => <div data-testid="widget-stub" />,
|
WidgetInstanceCard: ({ widget }: { widget: { title: string } }) => (
|
||||||
|
<div data-testid="widget-stub">{widget.title}</div>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../components/WidgetConfigDialog", () => ({
|
vi.mock("../../components/WidgetConfigDialog", () => ({
|
||||||
WidgetConfigDialog: () => <div data-testid="widget-config-stub" />,
|
WidgetConfigDialog: () => <div data-testid="widget-config-stub" />,
|
||||||
@@ -21,8 +27,15 @@ vi.mock("react-router-dom", () => ({
|
|||||||
vi.mock("../../hooks/useSettings", () => ({
|
vi.mock("../../hooks/useSettings", () => ({
|
||||||
useMonitoringSettings: () => ({ data: [] }),
|
useMonitoringSettings: () => ({ data: [] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// --- Dynamic mock state (reset in beforeEach) ---
|
||||||
|
let widgetInstances: WidgetInstance[] = [];
|
||||||
|
let serviceInstances: ServiceInstance[] = [];
|
||||||
vi.mock("../../hooks/useWidgets", () => ({
|
vi.mock("../../hooks/useWidgets", () => ({
|
||||||
useWidgetInstances: () => ({ data: [] }),
|
useWidgetInstances: () => ({ data: widgetInstances }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
|
useServiceInstances: () => ({ data: serviceInstances }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const saveShortcutMutate = vi.fn().mockResolvedValue({});
|
const saveShortcutMutate = vi.fn().mockResolvedValue({});
|
||||||
@@ -62,8 +75,26 @@ beforeEach(() => {
|
|||||||
saveShortcutMutate.mockClear();
|
saveShortcutMutate.mockClear();
|
||||||
deleteShortcutMutate.mockClear();
|
deleteShortcutMutate.mockClear();
|
||||||
shortcuts = [];
|
shortcuts = [];
|
||||||
|
widgetInstances = [];
|
||||||
|
serviceInstances = [];
|
||||||
|
setMatchMedia(false); // desktop by default
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- matchMedia mock for useIsMobile (jsdom has no native matchMedia) ---
|
||||||
|
|
||||||
|
function setMatchMedia(matches: boolean) {
|
||||||
|
window.matchMedia = ((query: string) => ({
|
||||||
|
matches: query === "(max-width: 768px)" ? matches : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
}
|
||||||
|
|
||||||
describe("Dashboard", () => {
|
describe("Dashboard", () => {
|
||||||
it("shows the empty-state alert when there are no shortcuts", () => {
|
it("shows the empty-state alert when there are no shortcuts", () => {
|
||||||
render(<Dashboard />);
|
render(<Dashboard />);
|
||||||
@@ -111,3 +142,142 @@ describe("Dashboard", () => {
|
|||||||
expect(saved.shortcut_type).toBe("website");
|
expect(saved.shortcut_type).toBe("website");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Mobile layout tests (spec R7.1, R7.2) ---
|
||||||
|
|
||||||
|
function makeWidget(overrides: Partial<WidgetInstance> = {}): WidgetInstance {
|
||||||
|
return {
|
||||||
|
id: "w1",
|
||||||
|
service_id: null,
|
||||||
|
widget_kind: "static",
|
||||||
|
title: "Widget 1",
|
||||||
|
config: {},
|
||||||
|
enabled: true,
|
||||||
|
sort_order: 0,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeService(
|
||||||
|
overrides: Partial<ServiceInstance> = {},
|
||||||
|
): ServiceInstance {
|
||||||
|
return {
|
||||||
|
id: "svc1",
|
||||||
|
service_type: "jellyfin",
|
||||||
|
name: "Jellyfin",
|
||||||
|
config: {},
|
||||||
|
secrets_set: {},
|
||||||
|
enabled: true,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Dashboard mobile layout", () => {
|
||||||
|
it("renders widgets in a single column with an anchor bar below md", () => {
|
||||||
|
setMatchMedia(true); // mobile
|
||||||
|
serviceInstances = [
|
||||||
|
makeService({ id: "graf", service_type: "grafana" }),
|
||||||
|
makeService({ id: "jelly", service_type: "jellyfin" }),
|
||||||
|
];
|
||||||
|
widgetInstances = [
|
||||||
|
makeWidget({
|
||||||
|
id: "w-obs",
|
||||||
|
service_id: "graf",
|
||||||
|
widget_kind: "link",
|
||||||
|
title: "Grafana Link",
|
||||||
|
}),
|
||||||
|
makeWidget({
|
||||||
|
id: "w-media",
|
||||||
|
service_id: "jelly",
|
||||||
|
widget_kind: "activity",
|
||||||
|
title: "Jellyfin Activity",
|
||||||
|
}),
|
||||||
|
makeWidget({
|
||||||
|
id: "w-backup",
|
||||||
|
service_id: null,
|
||||||
|
widget_kind: "backups",
|
||||||
|
title: "Backup Summary",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<Dashboard />);
|
||||||
|
|
||||||
|
// Anchor bar pills are visible for populated sections (each label appears
|
||||||
|
// in both the pill and the section heading, so use getAllByText).
|
||||||
|
expect(screen.getAllByText("Observability").length).toBeGreaterThanOrEqual(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(screen.getAllByText("Media").length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText("Backups").length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// Sections with no widgets are NOT rendered.
|
||||||
|
expect(screen.queryByText("Custom")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Each widget renders.
|
||||||
|
expect(screen.getByText("Grafana Link")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Jellyfin Activity")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Backup Summary")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT render the anchor bar at desktop width", () => {
|
||||||
|
setMatchMedia(false); // desktop
|
||||||
|
serviceInstances = [makeService({ id: "graf", service_type: "grafana" })];
|
||||||
|
widgetInstances = [
|
||||||
|
makeWidget({
|
||||||
|
id: "w-obs",
|
||||||
|
service_id: "graf",
|
||||||
|
widget_kind: "link",
|
||||||
|
title: "Grafana Link",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<Dashboard />);
|
||||||
|
|
||||||
|
// Widget renders (flat list, no section wrappers).
|
||||||
|
expect(screen.getByText("Grafana Link")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// No section headings or anchor pills on desktop.
|
||||||
|
expect(screen.queryByText("Observability")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Media")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("anchor bar pills jump to their section via scrollIntoView", async () => {
|
||||||
|
setMatchMedia(true); // mobile
|
||||||
|
serviceInstances = [
|
||||||
|
makeService({ id: "graf", service_type: "grafana" }),
|
||||||
|
makeService({ id: "jelly", service_type: "jellyfin" }),
|
||||||
|
];
|
||||||
|
widgetInstances = [
|
||||||
|
makeWidget({
|
||||||
|
id: "w-obs",
|
||||||
|
service_id: "graf",
|
||||||
|
widget_kind: "link",
|
||||||
|
title: "Grafana Link",
|
||||||
|
}),
|
||||||
|
makeWidget({
|
||||||
|
id: "w-media",
|
||||||
|
service_id: "jelly",
|
||||||
|
widget_kind: "activity",
|
||||||
|
title: "Jellyfin Activity",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const scrollSpy = vi.spyOn(Element.prototype, "scrollIntoView");
|
||||||
|
|
||||||
|
render(<Dashboard />);
|
||||||
|
|
||||||
|
// The Media section element exists.
|
||||||
|
expect(document.getElementById("dashboard-section-media")).not.toBeNull();
|
||||||
|
|
||||||
|
// Click the "Media" anchor pill (button role disambiguates from heading).
|
||||||
|
const mediaPill = screen.getByRole("button", { name: "Media" });
|
||||||
|
await userEvent.click(mediaPill);
|
||||||
|
|
||||||
|
expect(scrollSpy).toHaveBeenCalled();
|
||||||
|
scrollSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { DirectoryListing, MonitoringMachine } from "../../types";
|
|||||||
// so the selectedPath / currentDir state never leaks across cases.
|
// so the selectedPath / currentDir state never leaks across cases.
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
|
setMatchMedia(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
function machineFixture(
|
function machineFixture(
|
||||||
@@ -77,6 +78,30 @@ beforeEach(() => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Stub window.matchMedia so useIsMobile resolves in jsdom (Slice 4). */
|
||||||
|
function setMatchMedia(matches: boolean) {
|
||||||
|
const listeners: ((e: MediaQueryListEvent) => void)[] = [];
|
||||||
|
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: query.includes("768") ? matches : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: (
|
||||||
|
_evt: string,
|
||||||
|
listener: (e: MediaQueryListEvent) => void,
|
||||||
|
) => listeners.push(listener),
|
||||||
|
removeEventListener: (
|
||||||
|
_evt: string,
|
||||||
|
listener: (e: MediaQueryListEvent) => void,
|
||||||
|
) => {
|
||||||
|
const idx = listeners.indexOf(listener);
|
||||||
|
if (idx >= 0) listeners.splice(idx, 1);
|
||||||
|
},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => {
|
describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => {
|
||||||
it("renders the 5 locked columns (type/name/ext/size/modified)", () => {
|
it("renders the 5 locked columns (type/name/ext/size/modified)", () => {
|
||||||
render(<FileBrowser />);
|
render(<FileBrowser />);
|
||||||
@@ -118,3 +143,56 @@ describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => {
|
|||||||
expect(screen.queryByText(/Selected:/)).toBeNull();
|
expect(screen.queryByText(/Selected:/)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("FileBrowser (mobile card layout — slice 4)", () => {
|
||||||
|
it("renders cards with file/dir name as primary below md", () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(<FileBrowser />);
|
||||||
|
|
||||||
|
// Card titles (the 'name' field rendered as primary).
|
||||||
|
expect(screen.getByText("movies")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("video.mkv")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("notes.txt")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Desktop table column headers must NOT render.
|
||||||
|
const headers = screen.queryAllByRole("columnheader");
|
||||||
|
expect(headers).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tapping a directory card navigates into it", async () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(<FileBrowser />);
|
||||||
|
|
||||||
|
// Directory card is a button wrapping the 'movies' text.
|
||||||
|
await userEvent.click(screen.getByText("movies"));
|
||||||
|
|
||||||
|
// After navigating into /movies, the status caption shows the new cwd
|
||||||
|
// and NO 'Selected:' segment (directories are opened, not selected).
|
||||||
|
expect(screen.getByText(/Current: \/movies\b/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Selected:/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the path/breadcrumb controls on mobile", () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(<FileBrowser />);
|
||||||
|
|
||||||
|
// The 'Remote path' label and its input are part of the Browser section
|
||||||
|
// card (outside the table), so they render on both breakpoints.
|
||||||
|
expect(screen.getByLabelText("Remote path")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the DataTable at desktop width (1280px)", () => {
|
||||||
|
setMatchMedia(false);
|
||||||
|
render(<FileBrowser />);
|
||||||
|
|
||||||
|
// Desktop path: table column headers are present.
|
||||||
|
const headers = screen
|
||||||
|
.getAllByRole("columnheader")
|
||||||
|
.map((h) => h.textContent);
|
||||||
|
expect(headers).toEqual(
|
||||||
|
expect.arrayContaining(["Type", "Name", "Ext", "Size", "Modified"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -129,7 +129,11 @@ vi.mock("../../hooks/useDashboard", () => ({
|
|||||||
|
|
||||||
// usePersistentState reads/writes localStorage; clear between tests so the
|
// usePersistentState reads/writes localStorage; clear between tests so the
|
||||||
// offset/pageSize/columnVisibility state never leaks across cases.
|
// offset/pageSize/columnVisibility state never leaks across cases.
|
||||||
|
// matchMedia must be stubbed so useIsMobile (md:768px) and usePrefersSmallScreen
|
||||||
|
// (900px) resolve without TypeError in jsdom. Default to desktop (matches:false)
|
||||||
|
// so the DataTable path renders by default; mobile tests override.
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
setMatchMedia(false);
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
navigate.mockClear();
|
navigate.mockClear();
|
||||||
status = statusFixture();
|
status = statusFixture();
|
||||||
@@ -152,6 +156,30 @@ beforeEach(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Stub window.matchMedia so useIsMobile / usePrefersSmallScreen resolve in jsdom. */
|
||||||
|
function setMatchMedia(matches: boolean) {
|
||||||
|
const listeners: ((e: MediaQueryListEvent) => void)[] = [];
|
||||||
|
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: query.includes("768") ? matches : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: (
|
||||||
|
_evt: string,
|
||||||
|
listener: (e: MediaQueryListEvent) => void,
|
||||||
|
) => listeners.push(listener),
|
||||||
|
removeEventListener: (
|
||||||
|
_evt: string,
|
||||||
|
listener: (e: MediaQueryListEvent) => void,
|
||||||
|
) => {
|
||||||
|
const idx = listeners.indexOf(listener);
|
||||||
|
if (idx >= 0) listeners.splice(idx, 1);
|
||||||
|
},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", () => {
|
describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", () => {
|
||||||
it("exposes exactly the 15 locked toggleable columns", async () => {
|
it("exposes exactly the 15 locked toggleable columns", async () => {
|
||||||
render(<Media />);
|
render(<Media />);
|
||||||
@@ -261,3 +289,68 @@ describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", (
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Media (mobile card layout — slice 3)", () => {
|
||||||
|
it("renders cards with the title as primary below md", () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(<Media />);
|
||||||
|
|
||||||
|
// Card titles render (primary field).
|
||||||
|
expect(screen.getByText("Inception")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Matrix")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Card field labels render (at least once per row).
|
||||||
|
expect(screen.getAllByText("Size").length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(screen.getAllByText("HDR").length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(screen.getAllByText("Library").length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(screen.getAllByText("Year").length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
// Desktop table headers do NOT render on mobile.
|
||||||
|
expect(screen.queryByRole("columnheader", { name: "Title" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("columnheader", { name: "Bitrate" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the column-visibility toggle below md", () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(<Media />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /Columns/ })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders pagination controls below the cards on mobile", () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(<Media />);
|
||||||
|
|
||||||
|
expect(screen.getByText("2 rows")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/Page 1 of 1/).length).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Previous page" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Next page" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates to the file browser when a card is tapped on mobile", async () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(<Media />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText("Inception"));
|
||||||
|
|
||||||
|
expect(navigate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(navigate).toHaveBeenCalledWith(
|
||||||
|
`/files?path=${encodeURIComponent("/media/movies/Inception.mkv")}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the DataTable (not cards) at desktop width", () => {
|
||||||
|
render(<Media />);
|
||||||
|
|
||||||
|
// Desktop column headers render.
|
||||||
|
expect(
|
||||||
|
screen.getByRole("columnheader", { name: "Title" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// Column-visibility toggle is present.
|
||||||
|
expect(screen.getByRole("button", { name: /Columns/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { ServicePage } from "../ServicePage";
|
||||||
|
import type {
|
||||||
|
ServiceInstance,
|
||||||
|
ServiceInstanceInput,
|
||||||
|
ServiceTypeInfo,
|
||||||
|
} from "../../types";
|
||||||
|
|
||||||
|
// --- fixtures ---
|
||||||
|
|
||||||
|
const instance: ServiceInstance = {
|
||||||
|
id: "svc-1",
|
||||||
|
service_type: "grafana",
|
||||||
|
name: "Production Grafana",
|
||||||
|
config: { base_url: "https://grafana.example.com", timeout_seconds: 5 },
|
||||||
|
secrets_set: { api_key: true },
|
||||||
|
enabled: true,
|
||||||
|
created_at: 1_700_000_000,
|
||||||
|
updated_at: 1_700_000_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeInfo: ServiceTypeInfo = {
|
||||||
|
service_type: "grafana",
|
||||||
|
name: "Grafana",
|
||||||
|
description: "Dashboards, metrics, and logs.",
|
||||||
|
config_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
base_url: { type: "string", description: "Absolute URL." },
|
||||||
|
timeout_seconds: { type: "integer" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
secret_fields: [{ key: "api_key", label: "API key", required: false }],
|
||||||
|
widget_kinds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- mocks ---
|
||||||
|
|
||||||
|
const mutateAsync = vi.fn();
|
||||||
|
const mutate = vi.fn();
|
||||||
|
const deleteMutate = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
|
useServiceInstances: () => ({ data: [instance] }),
|
||||||
|
useServiceTypes: () => ({ data: [typeInfo] }),
|
||||||
|
useSaveServiceInstance: () => ({
|
||||||
|
mutateAsync,
|
||||||
|
mutate,
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
|
useDeleteServiceInstance: () => ({ mutate: deleteMutate, isPending: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-router-dom", () => ({
|
||||||
|
useParams: () => ({
|
||||||
|
serviceType: "grafana",
|
||||||
|
serviceId: "svc-1",
|
||||||
|
}),
|
||||||
|
useNavigate: () => vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// jsdom has no window.matchMedia; stub it. Default to desktop (matches: false).
|
||||||
|
function setMatchMedia(matches: boolean) {
|
||||||
|
window.matchMedia = ((query: string) => ({
|
||||||
|
matches: query.includes("768") ? matches : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setMatchMedia(false);
|
||||||
|
mutateAsync.mockReset();
|
||||||
|
mutate.mockReset();
|
||||||
|
deleteMutate.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ServicePage (desktop)", () => {
|
||||||
|
it("renders the full-page layout with the service name and connection card", () => {
|
||||||
|
render(<ServicePage />);
|
||||||
|
// Page heading (desktop only — mobile uses SheetForm title)
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Production Grafana" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// Connection section card title
|
||||||
|
expect(screen.getByText("Connection")).toBeInTheDocument();
|
||||||
|
// General Save button
|
||||||
|
expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render the SheetForm at desktop width", () => {
|
||||||
|
render(<ServicePage />);
|
||||||
|
// SheetForm renders a dialog with role="dialog" only when open; on
|
||||||
|
// desktop the page layout is used, so no dialog should be present.
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ServicePage (mobile SheetForm — slice 6)", () => {
|
||||||
|
beforeEach(() => setMatchMedia(true));
|
||||||
|
|
||||||
|
it("renders the SheetForm with the service name as title below md", () => {
|
||||||
|
render(<ServicePage />);
|
||||||
|
// SheetForm title is rendered inside a SheetTitle (role="heading").
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Production Grafana" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// The dialog (Sheet content) should be present on mobile.
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
// Desktop page header description is NOT rendered inside the SheetForm.
|
||||||
|
expect(
|
||||||
|
screen.queryByText("Dashboards, metrics, and logs."),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edits the name field and Save calls the save mutation", async () => {
|
||||||
|
render(<ServicePage />);
|
||||||
|
const nameInput = screen.getByLabelText("Name");
|
||||||
|
expect(nameInput).toHaveValue("Production Grafana");
|
||||||
|
|
||||||
|
await userEvent.clear(nameInput);
|
||||||
|
await userEvent.type(nameInput, "Renamed Grafana");
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||||
|
await userEvent.click(saveButton);
|
||||||
|
|
||||||
|
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||||
|
const input = mutateAsync.mock.calls[0][0] as ServiceInstanceInput;
|
||||||
|
expect(input.name).toBe("Renamed Grafana");
|
||||||
|
expect(input.id).toBe("svc-1");
|
||||||
|
// Lock the full save payload (config draft, enabled, secrets sentinel).
|
||||||
|
expect(input.enabled).toBe(true);
|
||||||
|
expect(input.secrets).toEqual({});
|
||||||
|
expect(input.config).toMatchObject({ base_url: "https://grafana.example.com" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the connection config fields as editable inside the SheetForm", () => {
|
||||||
|
render(<ServicePage />);
|
||||||
|
const urlInput = screen.getByLabelText("base_url");
|
||||||
|
expect(urlInput).toHaveValue("https://grafana.example.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,12 +6,10 @@ import type { MonitoringMachine } from "../../types";
|
|||||||
|
|
||||||
const saveMachineMutate = vi.fn().mockResolvedValue({});
|
const saveMachineMutate = vi.fn().mockResolvedValue({});
|
||||||
const deleteMachineMutate = vi.fn();
|
const deleteMachineMutate = vi.fn();
|
||||||
const testSSHMutate = vi
|
const testSSHMutate = vi.fn().mockResolvedValue({
|
||||||
.fn()
|
message: "SSH auth succeeded",
|
||||||
.mockResolvedValue({
|
known_hosts_updated: true,
|
||||||
message: "SSH auth succeeded",
|
});
|
||||||
known_hosts_updated: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
let machines: MonitoringMachine[] = [];
|
let machines: MonitoringMachine[] = [];
|
||||||
|
|
||||||
@@ -113,3 +111,103 @@ describe("Settings", () => {
|
|||||||
expect(deleteMachineMutate).toHaveBeenCalledWith("m1");
|
expect(deleteMachineMutate).toHaveBeenCalledWith("m1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// jsdom has no window.matchMedia; default to desktop so existing tests are
|
||||||
|
// unaffected.
|
||||||
|
function setMatchMedia(matches: boolean) {
|
||||||
|
window.matchMedia = ((query: string) => ({
|
||||||
|
matches: query.includes("768") ? matches : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Settings (mobile SheetForm — slice 7)", () => {
|
||||||
|
beforeEach(() => setMatchMedia(true));
|
||||||
|
|
||||||
|
it("opens the machine editor in a SheetForm below md", async () => {
|
||||||
|
machines = [localMachine()];
|
||||||
|
render(<Settings />);
|
||||||
|
|
||||||
|
// Open the editor via the detail-pane Edit button (visible text).
|
||||||
|
const detailEdit = screen
|
||||||
|
.getAllByRole("button", { name: "Edit" })
|
||||||
|
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
|
||||||
|
await userEvent.click(detailEdit);
|
||||||
|
|
||||||
|
// SheetForm renders a dialog; the DialogTitle shows the editor title.
|
||||||
|
expect(screen.getByText("Edit machine")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
// Desktop DialogDescription text is not rendered as a dialog description
|
||||||
|
// on mobile (the MachineEditor has its own hint labels, which is fine).
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("heading", { name: "Create machine" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a machine via the SheetForm on mobile", async () => {
|
||||||
|
machines = [localMachine()];
|
||||||
|
render(<Settings />);
|
||||||
|
|
||||||
|
const detailEdit = screen
|
||||||
|
.getAllByRole("button", { name: "Edit" })
|
||||||
|
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
|
||||||
|
await userEvent.click(detailEdit);
|
||||||
|
|
||||||
|
const nameInput = screen.getByLabelText("Name");
|
||||||
|
await userEvent.clear(nameInput);
|
||||||
|
await userEvent.type(nameInput, "Renamed node");
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Save machine" }));
|
||||||
|
|
||||||
|
expect(saveMachineMutate).toHaveBeenCalledTimes(1);
|
||||||
|
const saved = saveMachineMutate.mock.calls[0][0];
|
||||||
|
expect(saved.name).toBe("Renamed node");
|
||||||
|
expect(saved.mode).toBe("local");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancel closes the SheetForm on mobile", async () => {
|
||||||
|
machines = [localMachine()];
|
||||||
|
render(<Settings />);
|
||||||
|
|
||||||
|
const detailEdit = screen
|
||||||
|
.getAllByRole("button", { name: "Edit" })
|
||||||
|
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
|
||||||
|
await userEvent.click(detailEdit);
|
||||||
|
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
// The sheet is now closed — the dialog role should no longer be present.
|
||||||
|
// (The page content itself is still rendered; only the sheet unmounts.)
|
||||||
|
expect(screen.queryByText("Edit machine")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompts before discarding unsaved machine edits (R4.5)", async () => {
|
||||||
|
machines = [localMachine()];
|
||||||
|
render(<Settings />);
|
||||||
|
|
||||||
|
const detailEdit = screen
|
||||||
|
.getAllByRole("button", { name: "Edit" })
|
||||||
|
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
|
||||||
|
await userEvent.click(detailEdit);
|
||||||
|
|
||||||
|
// Edit the name to make the form dirty.
|
||||||
|
const nameInput = screen.getByLabelText("Name");
|
||||||
|
await userEvent.clear(nameInput);
|
||||||
|
await userEvent.type(nameInput, "Dirty name");
|
||||||
|
|
||||||
|
// Cancel should NOT immediately close — the discard confirm appears.
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Discard changes?" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// The editor is still open.
|
||||||
|
expect(screen.getByText("Edit machine")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import type {
|
|||||||
UserDirectoryResponse,
|
UserDirectoryResponse,
|
||||||
} from "../../types";
|
} from "../../types";
|
||||||
|
|
||||||
// jsdom has no window.matchMedia; MUI `useMediaQuery` (still used by the
|
// jsdom has no window.matchMedia; the shared `useIsMobile` hook and the
|
||||||
// compose dialog, slice 6b) must not blow up during render. Stub to "desktop".
|
// compose dialog viewport hook must not blow up during render. Stub to
|
||||||
|
// "desktop" (matches: false) by default; the slice-5 describe block flips it
|
||||||
|
// to mobile for card-layout assertions.
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
if (!window.matchMedia) {
|
if (!window.matchMedia) {
|
||||||
window.matchMedia = ((query: string) => ({
|
window.matchMedia = ((query: string) => ({
|
||||||
@@ -283,3 +285,123 @@ describe("UsersPage (slice 6b — compose dialog formatting actions)", () => {
|
|||||||
expect(body.value).toContain("<strong>");
|
expect(body.value).toContain("<strong>");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("UsersPage (mobile card layout — slice 5)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.matchMedia = ((query: string) => ({
|
||||||
|
matches: query.includes("768"),
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders user cards with display name as primary below md", () => {
|
||||||
|
users = [
|
||||||
|
userFixture({ jellyfin_id: "u1", display_name: "Alice" }),
|
||||||
|
userFixture({
|
||||||
|
jellyfin_id: "u2",
|
||||||
|
username: "bob",
|
||||||
|
display_name: "Bob",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<UsersPage />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Bob")).toBeInTheDocument();
|
||||||
|
// Activity field label should appear per card.
|
||||||
|
expect(screen.getAllByText("Activity")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles selection from the card checkbox without opening the drawer", async () => {
|
||||||
|
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<UsersPage />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole("checkbox", {
|
||||||
|
name: /Select Alice/i,
|
||||||
|
});
|
||||||
|
expect(checkbox).toHaveAttribute("data-state", "unchecked");
|
||||||
|
|
||||||
|
await userEvent.click(checkbox);
|
||||||
|
expect(checkbox).toHaveAttribute("data-state", "checked");
|
||||||
|
|
||||||
|
// Drawer stays closed: the session-panel stub only renders when the
|
||||||
|
// drawer opens via a card-body tap, not via the checkbox.
|
||||||
|
expect(screen.queryByTestId("session-panel-stub")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders compose in a SheetForm below md with send button", async () => {
|
||||||
|
users = [
|
||||||
|
userFixture({
|
||||||
|
jellyfin_id: "u1",
|
||||||
|
display_name: "Alice",
|
||||||
|
email: "alice@example.com",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<UsersPage />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Select the deliverable user via the mobile card checkbox.
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("checkbox", { name: /Select Alice/i }),
|
||||||
|
);
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Message selected" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// On mobile, compose opens in a SheetForm (not a Dialog). The SheetForm
|
||||||
|
// header carries the title and the footer carries the Send button.
|
||||||
|
expect(screen.getByText("Message selected users")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Send message" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Subject")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompts before discarding unsaved compose edits (R4.5)", async () => {
|
||||||
|
users = [
|
||||||
|
userFixture({
|
||||||
|
jellyfin_id: "u1",
|
||||||
|
display_name: "Alice",
|
||||||
|
email: "alice@example.com",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<UsersPage />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("checkbox", { name: /Select Alice/i }),
|
||||||
|
);
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Message selected" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Type a subject to make the compose form dirty.
|
||||||
|
await userEvent.type(screen.getByLabelText("Subject"), "Urgent update");
|
||||||
|
|
||||||
|
// Cancel should NOT immediately close — the discard confirm appears.
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Discard changes?" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Design — Mobile responsive parity
|
||||||
|
|
||||||
|
**Change:** `mobile-responsive-parity`
|
||||||
|
**Phase:** design
|
||||||
|
**Date:** 2026-06-26
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Frontend stack recap: React 18 + Vite + TanStack Query + TanStack Table +
|
||||||
|
Tailwind v4 (CSS `@theme` in `src/index.css`) + shadcn/ui (Radix primitives) +
|
||||||
|
lucide-react + react-router-dom + react-oidc-context. The app shell
|
||||||
|
(`App.tsx`) is already responsive via a `md:` (768px) cut and a `MobileDrawer`
|
||||||
|
`Sheet`. The content layer is not.
|
||||||
|
|
||||||
|
This design adds four **shared primitives** and applies them per-page. It does
|
||||||
|
not introduce new libraries.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Shared primitives (PR 1)
|
||||||
|
|
||||||
|
#### 1. `MobileCardRow<T>` — card renderer for TanStack Table rows
|
||||||
|
|
||||||
|
Lives in `src/components/ui/mobile-card.tsx` (new). Generic over the row data
|
||||||
|
type. Reused by the four wide tables.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export interface MobileCardField<T> {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
render: (row: T) => React.ReactNode;
|
||||||
|
/** When true, render as the card title (bold, larger). Exactly one per card. */
|
||||||
|
primary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MobileCardRowProps<T> {
|
||||||
|
rows: TData[];
|
||||||
|
fields: MobileCardField<T>[];
|
||||||
|
onRowClick?: (row: T) => void;
|
||||||
|
/** Optional right-aligned action slot (edit/delete icon buttons). */
|
||||||
|
actions?: (row: T) => React.ReactNode;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Renders a vertical list of cards. Each card shows the `primary` field as the
|
||||||
|
title and the remaining fields as a key/value stack. The whole card is a button
|
||||||
|
when `onRowClick` is set (44px min height).
|
||||||
|
|
||||||
|
The consuming page decides which fields to show — this primitive does not pick
|
||||||
|
them.
|
||||||
|
|
||||||
|
#### 2. `useIsMobile()` — single source of truth for the breakpoint
|
||||||
|
|
||||||
|
Lives in `src/hooks/useIsMobile.ts` (new). Wraps
|
||||||
|
`matchMedia("(max-width: 768px)")`, SSR-safe, returns a boolean. Replaces the
|
||||||
|
inline `window.matchMedia` reads in `App.tsx` and the ad-hoc `usePrefersSmallScreen`
|
||||||
|
usage in `Media.tsx`. One breakpoint, one hook.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
const [isMobile, setIsMobile] = useState(() =>
|
||||||
|
typeof window !== "undefined" && window.matchMedia("(max-width: 768px)").matches
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const mql = window.matchMedia("(max-width: 768px)");
|
||||||
|
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||||
|
mql.addEventListener("change", handler);
|
||||||
|
return () => mql.removeEventListener("change", handler);
|
||||||
|
}, []);
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. `SheetForm` — full-height form host
|
||||||
|
|
||||||
|
Lives in `src/components/ui/sheet-form.tsx` (new). Wraps the shadcn `Sheet`
|
||||||
|
primitive. Props: `open`, `onOpenChange`, `title`, `onSave`, `onCancel`,
|
||||||
|
`isPending`, `children`. Renders sticky header (`title` + `X`) and sticky
|
||||||
|
footer (`Cancel` / `Save`). Body scrolls.
|
||||||
|
|
||||||
|
Below `md`, used by ServicePage, Settings, message compose, WidgetConfigDialog.
|
||||||
|
At `md:` and above, the existing `Dialog` is used unchanged. The choice is made
|
||||||
|
in the consumer with `useIsMobile()`, not inside `SheetForm`, so the same form
|
||||||
|
body can be reused across both hosts.
|
||||||
|
|
||||||
|
#### 4. `EditActionButton` — touch-aware edit affordance
|
||||||
|
|
||||||
|
Replaces `HoverEditButton`'s role (not its file — we extend the existing
|
||||||
|
component). Add a `mobile="always"` prop (default). Below `md`, the button is
|
||||||
|
always visible (no hover-gated opacity). At `md:` and above, current
|
||||||
|
hover-reveal behavior is preserved. Implementation: a `md:opacity-0
|
||||||
|
md:group-hover:opacity-100` Tailwind stack, i.e. always visible by default,
|
||||||
|
hidden-then-revealed on hover at `md:` and up.
|
||||||
|
|
||||||
|
### Per-page application (PRs 2–9)
|
||||||
|
|
||||||
|
Each wide-table page renders `<MobileCardRow>` below `md` and the existing
|
||||||
|
`<DataTable>` at/above `md`. The page wires up the field list. Example for
|
||||||
|
Media:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const fields: MobileCardField<MediaItem>[] = [
|
||||||
|
{ key: "title", label: "Title", render: (r) => r.title, primary: true },
|
||||||
|
{ key: "size", label: "Size", render: (r) => r.size_display },
|
||||||
|
{ key: "hdr", label: "HDR", render: (r) => (r.is_hdr ? "HDR" : "") },
|
||||||
|
{ key: "library", label: "Library", render: (r) => r.library_name },
|
||||||
|
];
|
||||||
|
return isMobile
|
||||||
|
? <MobileCardRow rows={rows} fields={fields} onRowClick={onRowClick} actions={(r) => <EditActionButton onClick={...} />} />
|
||||||
|
: <DataTable columns={columns} data={rows} /* ...existing props */ />;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Touch-target audit (PR 1, applied throughout)
|
||||||
|
|
||||||
|
A single `min-h-11 min-w-11` (44px) utility class is applied to interactive
|
||||||
|
shadcn primitives below `md`. Applied via a `mobile-touch-target` Tailwind
|
||||||
|
utility class registered in `tailwind.config.cjs` (or as a Tailwind v4 CSS
|
||||||
|
utility in `src/index.css`). The class adds `min-height: 44px; min-width: 44px`
|
||||||
|
only below `md`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.mobile-touch-target,
|
||||||
|
.mobile-touch-target::before {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pages add the class to icon buttons, checkboxes, switches, and row taps during
|
||||||
|
their per-page PR.
|
||||||
|
|
||||||
|
## Breakpoints
|
||||||
|
|
||||||
|
- `< 768px` (`isMobile === true`): mobile layout — cards, Sheet forms, always-
|
||||||
|
visible edit, single-column dashboard, anchor bar.
|
||||||
|
- `≥ 768px`: existing desktop layout, unchanged.
|
||||||
|
|
||||||
|
No `sm:` cut. No `lg:` cut.
|
||||||
|
|
||||||
|
## Key technical risks & mitigations
|
||||||
|
|
||||||
|
- **TanStack column defs vs. card fields drift.** Each page that renders a card
|
||||||
|
must declare its mobile fields in one place; tests assert the card shows the
|
||||||
|
primary field at 375px. If a column is renamed, the card test fails.
|
||||||
|
- **iOS Safari `100dvh`.** `SheetForm` uses `h-[100dvh]` (not `h-screen`) to
|
||||||
|
avoid the iOS URL-bar resize jump. Tested manually on iOS Safari.
|
||||||
|
- **`position: sticky` inside `SheetContent`.** Radix `Sheet` uses transforms;
|
||||||
|
sticky must be relative to the scroll container inside the sheet body, not the
|
||||||
|
sheet itself. The sticky header/footer are siblings of the scrolling body
|
||||||
|
inside a flex column, not sticky-positioned.
|
||||||
|
- **OIDC redirect after login.** No change: responsive web only, OIDC continues
|
||||||
|
to redirect within the same browser tab.
|
||||||
|
|
||||||
|
## Trade-offs
|
||||||
|
|
||||||
|
- **Card layouts duplicate field definitions** (once as TanStack columns, once
|
||||||
|
as `MobileCardField[]`). Accepted: the alternative (auto-deriving cards from
|
||||||
|
column defs) produces bad mobile UX because column defs are not ordered by
|
||||||
|
mobile importance.
|
||||||
|
- **44px touch targets** slightly increase mobile visual density compared to a
|
||||||
|
32px design, but meet WCAG 2.5.5. Accepted.
|
||||||
|
- **`useIsMobile()` per-page render branching** is preferred over CSS-only
|
||||||
|
`hidden md:block` because the card and table have different data dependencies
|
||||||
|
(e.g. row click handlers, selection state) and mounting both wastes work.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Proposal — Mobile responsive parity
|
||||||
|
|
||||||
|
**Change:** `mobile-responsive-parity`
|
||||||
|
**Phase:** proposal
|
||||||
|
**Date:** 2026-06-26
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The Manage frontend ships a responsive **app shell** (hamburger drawer,
|
||||||
|
`MobileDrawer`, `md:` breakpoint at 768px, correct viewport meta) but the
|
||||||
|
**content layer** assumes a desktop viewport. Concretely:
|
||||||
|
|
||||||
|
1. **Data tables render as literal `<table>` elements with no mobile affordance.**
|
||||||
|
Seven tables (Media, FileBrowser, UsersPage, BackupAlertsTable,
|
||||||
|
BackupJobsTable, BackupRunsTable, SessionActivityPanel) overflow or clip on a
|
||||||
|
375px screen. The Media page's TanStack column-visibility toggle is unusable
|
||||||
|
on touch.
|
||||||
|
2. **Edit forms open in centered `Dialog`s with multi-column grids.** ServicePage
|
||||||
|
config, Settings (machines/SSH keys), the message compose dialog, and
|
||||||
|
`WidgetConfigDialog` cramp or overflow on phones; save actions drift off-screen.
|
||||||
|
3. **`HoverEditButton` and row-hover actions do not fire on touch devices.**
|
||||||
|
Edit affordances are invisible to phone users.
|
||||||
|
4. **Touch targets violate mobile accessibility standards.** shadcn defaults
|
||||||
|
(32px buttons, dense rows) are below the 44px minimum that WCAG 2.5.5 / Apple
|
||||||
|
HIG require for touch.
|
||||||
|
5. **The Dashboard widget grid does not collapse.** The configurable grid has no
|
||||||
|
single-column mobile layout, so a multi-widget dashboard sideways-scrolls or
|
||||||
|
clips.
|
||||||
|
|
||||||
|
The result: the app **launches** on a phone but cannot be **operated** there.
|
||||||
|
Several flows (create service, edit widget layout, build media index, manage SSH
|
||||||
|
keys) are effectively desktop-only.
|
||||||
|
|
||||||
|
## Proposal
|
||||||
|
|
||||||
|
Make every route fully usable in phone portrait (≥360px) at a single `md:`
|
||||||
|
(768px) cut. Tablets keep the desktop layout. No desktop-only flows survive.
|
||||||
|
|
||||||
|
1. **Hybrid data-table strategy.** The four wide tables (Media, FileBrowser,
|
||||||
|
Users, Backups) render a stacked **card per row** below `md`, each card
|
||||||
|
picking the 3–5 most important fields. Narrow tables (SessionActivity) keep
|
||||||
|
horizontal scroll. The TanStack column-visibility toggle is hidden below `md`
|
||||||
|
(the card picks the fields).
|
||||||
|
2. **Sheet-based edit forms.** Below `md`, ServicePage, Settings, message
|
||||||
|
compose, and `WidgetConfigDialog` open inside a full-height `Sheet` (reusing
|
||||||
|
the existing primitive) with a sticky header and a sticky save bar — instead
|
||||||
|
of the centered `Dialog`.
|
||||||
|
3. **Replace `HoverEditButton` with an always-visible variant** below `md`. Row
|
||||||
|
edit/delete actions surface as small, persistent icon buttons on the right of
|
||||||
|
each row/card.
|
||||||
|
4. **Touch-target audit.** All interactive elements below `md` get a 44px
|
||||||
|
minimum hit area (buttons, checkboxes, row taps, badges-as-buttons).
|
||||||
|
5. **Dashboard mobile layout.** The widget grid collapses to a single column
|
||||||
|
below `md`, with a section anchor bar (Observability / Media / Backups /
|
||||||
|
Custom) at the top for quick navigation.
|
||||||
|
6. **Responsive web only.** No PWA, no manifest, no service worker. OIDC keeps
|
||||||
|
working in-browser as it does today.
|
||||||
|
7. **Per-page delivery.** Ship ~9 chained PRs, one per route (plus a primitives
|
||||||
|
PR), each ≤400 changed lines, each leaving `npm run lint`, `npm run build`
|
||||||
|
(tsc -b + vite build), and `npm run test` green.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- **No tablet-specific layout.** Tablets use the existing desktop layout at
|
||||||
|
`md:` and above.
|
||||||
|
- **No PWA / installability.** No manifest, service worker, offline mode, or
|
||||||
|
standalone display mode. This is a responsive website.
|
||||||
|
- **No change to polling intervals.** Widget refresh (≈30s) and the
|
||||||
|
message-queue poll (5s) keep desktop semantics. (Flagged as a follow-up risk;
|
||||||
|
see §Risks.)
|
||||||
|
- **No new data-table library.** TanStack Table stays; card layouts render from
|
||||||
|
the same row data, not from a separate component library.
|
||||||
|
- **No backend changes.** The API contract is unchanged.
|
||||||
|
- **No landscape-phone or small-tablet (`sm:`) intermediate layout.** A single
|
||||||
|
`md:` cut is the target.
|
||||||
|
- **No new product features.** This is a presentation-layer parity change.
|
||||||
|
|
||||||
|
## Key technical risks
|
||||||
|
|
||||||
|
- **TanStack Table → card rendering** is not automatic. Each of the four wide
|
||||||
|
tables needs a per-table card variant that picks which fields to show; this is
|
||||||
|
where most of the implementation risk and review burden lives.
|
||||||
|
- **`Sheet` as a form host** is novel in this codebase (currently used only for
|
||||||
|
the nav drawer). Sticky header + sticky save bar must work across iOS Safari
|
||||||
|
and Chrome Android, including inside the OIDC-triggering keyboard insets.
|
||||||
|
- **iOS Safari quirks**: viewport `100dvh`, attachment upload from Files,
|
||||||
|
`position: sticky` inside transformed ancestors. Each may need targeted fixes.
|
||||||
|
- **`HoverEditButton` replacement** must not regress the desktop hover-reveal
|
||||||
|
aesthetic — only the mobile behavior changes.
|
||||||
|
|
||||||
|
## Risks (not blocking, flagged for later)
|
||||||
|
|
||||||
|
- **D8 — Polling on battery.** The dashboard (the page most likely to be left
|
||||||
|
open on a phone) polls every ~30s per widget plus the 5s queue-status poll.
|
||||||
|
Per the decision matrix, intervals stay identical to desktop. Cheapest future
|
||||||
|
mitigation: a single `useEffect` on `document.visibilityState` that pauses
|
||||||
|
TanStack refetch when the tab is hidden (~10 lines, zero UX cost). Revisit
|
||||||
|
after parity ships if battery complaints arise.
|
||||||
|
|
||||||
|
## Decision matrix (from grilling)
|
||||||
|
|
||||||
|
| # | Decision | Choice |
|
||||||
|
|---|----------|--------|
|
||||||
|
| D1 | Parity target | Full parity — no desktop-only flows |
|
||||||
|
| D2 | Data tables | Hybrid: cards below `md` for the big four; scroll for narrow; toggle hidden |
|
||||||
|
| D3 | Forms | Full-height `Sheet` below `md`, sticky header + sticky save bar |
|
||||||
|
| D4 | Touch edit | Always-visible edit button below `md` |
|
||||||
|
| D5 | Installable | Responsive web only — no PWA |
|
||||||
|
| D6 | Devices | Phone portrait only, single `md:` (768px) cut |
|
||||||
|
| D7 | Dashboard | Single-column stack + section anchor bar |
|
||||||
|
| D8 | Polling | Same intervals as desktop (flagged risk) |
|
||||||
|
| D9 | Touch targets | 44px minimum below `md` |
|
||||||
|
| D10 | Testing | Vitest per breakpoint + manual device-mode check |
|
||||||
|
| D11 | Delivery | Per-page PRs (~9), primitives PR first |
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Spec — Mobile responsive parity
|
||||||
|
|
||||||
|
**Change:** `mobile-responsive-parity`
|
||||||
|
**Phase:** spec
|
||||||
|
**Date:** 2026-06-26
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
All 9 application routes must be fully operable in phone portrait viewports
|
||||||
|
(≥360px) at a single `md:` (768px) breakpoint. Tablets and wider viewports keep
|
||||||
|
the existing desktop layout unchanged. No product behavior changes; this is a
|
||||||
|
presentation-layer parity change only.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### R1 — Viewport & breakpoint policy
|
||||||
|
|
||||||
|
- R1.1 The viewport meta stays `width=device-width, initial-scale=1.0` (no zoom
|
||||||
|
lock). User zoom remains enabled.
|
||||||
|
- R1.2 There is exactly one responsive cut: `md:` (768px). Below is "mobile";
|
||||||
|
at-or-above is "desktop" (existing behavior).
|
||||||
|
- R1.3 No `sm:` intermediate cut is introduced.
|
||||||
|
|
||||||
|
### R2 — App shell (already compliant; locked in)
|
||||||
|
|
||||||
|
- R2.1 Desktop `Sidebar` renders `null` when `isMobile` (`matchMedia("(max-width:
|
||||||
|
768px)")`).
|
||||||
|
- R2.2 Mobile nav uses the existing `MobileDrawer` (hamburger, `md:hidden`,
|
||||||
|
`Sheet` side=left) with no behavioral change.
|
||||||
|
- R2.3 `TopBar` keeps its existing responsive behavior (version badges hidden
|
||||||
|
on small screens, hamburger visible below `md`).
|
||||||
|
|
||||||
|
### R3 — Data tables (hybrid)
|
||||||
|
|
||||||
|
- R3.1 The four wide tables — **Media** (`pages/Media.tsx`), **FileBrowser**
|
||||||
|
(`pages/FileBrowser.impl.tsx`), **Users** (`pages/UsersPage.impl.tsx`), and the
|
||||||
|
three **Backups** tables (`BackupAlertsTable.tsx`, `BackupJobsTable.tsx`,
|
||||||
|
`BackupRunsTable.tsx`) — render a stacked **card per row** below `md`.
|
||||||
|
- R3.2 Each card shows a primary title plus the 3–5 most important fields for
|
||||||
|
that table (chosen per-table; documented in tasks). All remaining fields are
|
||||||
|
omitted from the mobile card.
|
||||||
|
- R3.3 Row click / selection semantics are preserved on the card (tap target =
|
||||||
|
the whole card where applicable).
|
||||||
|
- R3.4 **SessionActivityPanel** (narrow, 3-column) keeps the `<table>` shape
|
||||||
|
inside a horizontal-scroll container below `md`.
|
||||||
|
- R3.5 The TanStack **column-visibility toggle is hidden below `md`** on every
|
||||||
|
table that uses it (Media). The mobile card picks the fields; the user does
|
||||||
|
not re-show hidden columns on touch.
|
||||||
|
- R3.6 At `md:` and above, all tables render exactly as today.
|
||||||
|
|
||||||
|
### R4 — Edit forms (Sheet)
|
||||||
|
|
||||||
|
- R4.1 Below `md`, these edit flows open in a full-height `Sheet` (side=bottom
|
||||||
|
or side=right, full screen) instead of a centered `Dialog`:
|
||||||
|
- **ServicePage** connection config + secrets
|
||||||
|
- **Settings** machines and SSH-key editors
|
||||||
|
- **Message compose** dialog (`UsersPage.impl.tsx`)
|
||||||
|
- **WidgetConfigDialog**
|
||||||
|
- R4.2 The Sheet form has a sticky header (title + close affordance) and a
|
||||||
|
sticky footer/save bar (Cancel + Save).
|
||||||
|
- R4.3 Form fields stack to a single column inside the Sheet.
|
||||||
|
- R4.4 At `md:` and above, the existing `Dialog`-based forms are unchanged.
|
||||||
|
- R4.5 The Sheet closes on successful save and on explicit cancel; it does not
|
||||||
|
close on outside-click while the form is dirty (confirm prompt).
|
||||||
|
|
||||||
|
### R5 — Touch edit affordance
|
||||||
|
|
||||||
|
- R5.1 `HoverEditButton` gains a `md:` variant: hover-revealed on desktop
|
||||||
|
(unchanged), **always visible** below `md`.
|
||||||
|
- R5.2 Row/card edit and delete actions surface as persistent icon buttons on
|
||||||
|
the right edge below `md`.
|
||||||
|
- R5.3 Desktop hover-reveal aesthetic is not regressed at `md:` and above.
|
||||||
|
|
||||||
|
### R6 — Touch targets
|
||||||
|
|
||||||
|
- R6.1 All interactive elements below `md` have a minimum 44×44px hit area.
|
||||||
|
This includes: buttons, icon buttons, checkboxes, switches, row/card tap
|
||||||
|
targets, and badges that act as buttons.
|
||||||
|
- R6.2 Visual size may remain smaller than 44px (padding-only hit areas are
|
||||||
|
acceptable) as long as the tappable region meets the minimum.
|
||||||
|
- R6.3 At `md:` and above, sizes are unchanged.
|
||||||
|
|
||||||
|
### R7 — Dashboard layout
|
||||||
|
|
||||||
|
- R7.1 The widget grid collapses to a **single column** below `md`.
|
||||||
|
- R7.2 A **section anchor bar** appears at the top of the dashboard below `md`,
|
||||||
|
grouping widgets (e.g. Observability / Media / Backups / Custom) and allowing
|
||||||
|
quick jump-to-section.
|
||||||
|
- R7.3 Widget order respects the user's configured sort order.
|
||||||
|
- R7.4 At `md:` and above, the grid renders exactly as today.
|
||||||
|
|
||||||
|
### R8 — Polling (unchanged)
|
||||||
|
|
||||||
|
- R8.1 Widget refresh intervals and the message-queue poll interval are
|
||||||
|
identical on mobile and desktop.
|
||||||
|
- R8.2 (Follow-up risk, not in scope: pause refetch on `document.visibilityState
|
||||||
|
=== "hidden"`. Tracked in proposal §Risks.)
|
||||||
|
|
||||||
|
### R9 — No PWA
|
||||||
|
|
||||||
|
- R9.1 No web manifest, service worker, or standalone display mode is added.
|
||||||
|
- R9.2 OIDC continues to work in-browser; no standalone-mode redirect handling
|
||||||
|
is introduced.
|
||||||
|
|
||||||
|
### R10 — Non-regression
|
||||||
|
|
||||||
|
- R10.1 No desktop layout (≥768px) is visually or functionally regressed.
|
||||||
|
- R10.2 No backend API contract change.
|
||||||
|
- R10.3 No existing test is deleted; mobile-specific tests are additive.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- AC1 Every route listed in `App.tsx` `navItems` (Dashboard, Observability,
|
||||||
|
Media, Files, Backups, Users, Actions, Services, Settings) is fully operable
|
||||||
|
at 375px width in Chrome DevTools device mode (iPhone 12 Pro preset or
|
||||||
|
equivalent).
|
||||||
|
- AC2 Each of the four wide tables shows a card layout at 375px and the table
|
||||||
|
layout at 1280px.
|
||||||
|
- AC3 Each of the four edit forms opens in a Sheet at 375px and a Dialog at
|
||||||
|
1280px.
|
||||||
|
- AC4 `HoverEditButton` is always visible at 375px and hover-revealed at 1280px.
|
||||||
|
- AC5 A 44px-minimum touch-target audit passes for all interactive elements at
|
||||||
|
375px.
|
||||||
|
- AC6 The Dashboard renders a single column with an anchor bar at 375px and the
|
||||||
|
existing grid at 1280px.
|
||||||
|
- AC7 `cd frontend && npm run lint && npm run build && npm run test` is green.
|
||||||
|
- AC8 At least one Vitest test per touched page asserts behavior at <768px and
|
||||||
|
≥768px breakpoints.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Tablet/landscape/sm: intermediate layout.
|
||||||
|
- PWA, manifest, service worker, offline mode.
|
||||||
|
- Polling-interval changes.
|
||||||
|
- Backend changes.
|
||||||
|
- New data-table library.
|
||||||
|
- New product features.
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
# Tasks — Mobile responsive parity
|
||||||
|
|
||||||
|
**Change:** `mobile-responsive-parity`
|
||||||
|
**Phase:** tasks
|
||||||
|
**Date:** 2026-06-26
|
||||||
|
|
||||||
|
## Review workload forecast
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Estimated changed lines | ~2200–2800 |
|
||||||
|
| Chained PRs recommended | Yes (10 slices) |
|
||||||
|
| Chain strategy | stacked-to-main |
|
||||||
|
| Slice order | 1 (primitives) → 2 (Dashboard) → 3–5 (tables) → 6–8 (forms) → 9 (touch audit) → 10 (docs + verify) |
|
||||||
|
|
||||||
|
Each slice is committed separately (user pref). Every slice must leave
|
||||||
|
`cd frontend && npm run lint && npm run build && npm run test` green. Every
|
||||||
|
touched page gains a Vitest case asserting behavior at <768px and ≥768px.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 1 — Shared primitives
|
||||||
|
|
||||||
|
**Goal:** Land the four building blocks every later slice depends on. No
|
||||||
|
page-level behavior changes yet.
|
||||||
|
|
||||||
|
- [ ] **1.1 `useIsMobile()` hook**
|
||||||
|
- Files: `frontend/src/hooks/useIsMobile.ts` (new)
|
||||||
|
- Lines: ~20
|
||||||
|
- Details: SSR-safe `matchMedia("(max-width: 768px)")` listener per design.
|
||||||
|
|
||||||
|
- [ ] **1.2 `MobileCardRow` component**
|
||||||
|
- Files: `frontend/src/components/ui/mobile-card.tsx` (new), plus a Vitest
|
||||||
|
spec `frontend/src/components/ui/__tests__/mobile-card.test.tsx`.
|
||||||
|
- Lines: ~80 + ~60 test
|
||||||
|
- Details: generic `<T,>`, fields list, `primary` field, optional `onRowClick`
|
||||||
|
and `actions` slot per design. 44px min card height.
|
||||||
|
|
||||||
|
- [ ] **1.3 `SheetForm` component**
|
||||||
|
- Files: `frontend/src/components/ui/sheet-form.tsx` (new), plus spec.
|
||||||
|
- Lines: ~70 + ~50 test
|
||||||
|
- Details: wraps shadcn `Sheet`; sticky header + sticky footer; `h-[100dvh]`;
|
||||||
|
props per design. Dirty-state confirm on outside click.
|
||||||
|
|
||||||
|
- [ ] **1.4 `EditActionButton` — extend `HoverEditButton`**
|
||||||
|
- Files: `frontend/src/components/HoverEditButton.tsx`
|
||||||
|
- Lines: ~15
|
||||||
|
- Details: add `mobile="always" | "hover"` (default `always`). Tailwind:
|
||||||
|
always visible below `md`, hover-revealed at `md:` and up.
|
||||||
|
|
||||||
|
- [ ] **1.5 `mobile-touch-target` utility**
|
||||||
|
- Files: `frontend/src/index.css` (add utility)
|
||||||
|
- Lines: ~10
|
||||||
|
- Details: media-gated 44×44 min hit area per design.
|
||||||
|
|
||||||
|
- [ ] **1.6 Replace inline `matchMedia` in `App.tsx`**
|
||||||
|
- Files: `frontend/src/App.tsx`
|
||||||
|
- Lines: ~10 removed, ~3 added
|
||||||
|
- Details: use `useIsMobile()`; preserve current shell behavior exactly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 2 — Dashboard (R7)
|
||||||
|
|
||||||
|
**Goal:** Dashboard collapses to single column + section anchor bar on mobile.
|
||||||
|
|
||||||
|
- [ ] **2.1 Single-column grid below `md`**
|
||||||
|
- Files: `frontend/src/pages/Dashboard.tsx`
|
||||||
|
- Lines: ~20
|
||||||
|
- Details: widget list uses `grid grid-cols-1 md:grid-cols-*` (match existing
|
||||||
|
desktop column count). Respect configured sort order.
|
||||||
|
|
||||||
|
- [ ] **2.2 Section anchor bar**
|
||||||
|
- Files: `frontend/src/pages/Dashboard.tsx`
|
||||||
|
- Lines: ~40
|
||||||
|
- Details: group widgets (Observability / Media / Backups / Custom). Anchor
|
||||||
|
bar `md:hidden`, horizontal scroll of pills, jumps to section by id.
|
||||||
|
|
||||||
|
- [ ] **2.3 Tests**
|
||||||
|
- Files: `frontend/src/pages/__tests__/Dashboard.test.tsx`
|
||||||
|
- Lines: ~40
|
||||||
|
- Details: assert single column at 375px, grid at 1280px, anchor bar visible
|
||||||
|
only at <768px.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 3 — Media table (R3.1, R3.5)
|
||||||
|
|
||||||
|
- [ ] **3.1 Mobile fields + card render**
|
||||||
|
- Files: `frontend/src/pages/Media.tsx`
|
||||||
|
- Lines: ~60
|
||||||
|
- Details: card primary = title; fields = size, HDR flag, library, year.
|
||||||
|
Hide column-visibility toggle below `md`. Preserve pagination controls.
|
||||||
|
|
||||||
|
- [ ] **3.2 Tests**
|
||||||
|
- Files: `frontend/src/pages/__tests__/Media.test.tsx`
|
||||||
|
- Lines: ~40
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 4 — FileBrowser table (R3.1)
|
||||||
|
|
||||||
|
- [ ] **4.1 Mobile fields + card render**
|
||||||
|
- Files: `frontend/src/pages/FileBrowser.impl.tsx`
|
||||||
|
- Lines: ~60
|
||||||
|
- Details: card primary = name; fields = size, mtime, type. Preserve
|
||||||
|
directory-navigation tap target (whole card). Preserve ffprobe/job affordances.
|
||||||
|
|
||||||
|
- [ ] **4.2 Tests**
|
||||||
|
- Files: `frontend/src/pages/__tests__/FileBrowser.test.tsx`
|
||||||
|
- Lines: ~30
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 5 — Users + Backups tables (R3.1)
|
||||||
|
|
||||||
|
- [ ] **5.1 UsersPage card**
|
||||||
|
- Files: `frontend/src/pages/UsersPage.impl.tsx`
|
||||||
|
- Lines: ~70
|
||||||
|
- Details: card primary = display name; fields = username, activity badge,
|
||||||
|
email (if present). Preserve selection checkboxes (44px) and drawer open.
|
||||||
|
|
||||||
|
- [ ] **5.2 Backups cards (3 tables)**
|
||||||
|
- Files: `frontend/src/components/BackupAlertsTable.tsx`,
|
||||||
|
`frontend/src/components/BackupJobsTable.tsx`,
|
||||||
|
`frontend/src/components/BackupRunsTable.tsx`
|
||||||
|
- Lines: ~120 (3 × ~40)
|
||||||
|
- Details: per-table primary + 3 fields; preserve acknowledge/run actions on
|
||||||
|
the card.
|
||||||
|
|
||||||
|
- [ ] **5.3 Tests**
|
||||||
|
- Files: existing component test files
|
||||||
|
- Lines: ~90
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 6 — ServicePage form (R4)
|
||||||
|
|
||||||
|
- [ ] **6.1 Sheet form below `md`**
|
||||||
|
- Files: `frontend/src/pages/ServicePage.tsx`
|
||||||
|
- Lines: ~60
|
||||||
|
- Details: branch on `useIsMobile()`; reuse form body inside `SheetForm`.
|
||||||
|
Single-column fields. Preserve save semantics.
|
||||||
|
|
||||||
|
- [ ] **6.2 Tests**
|
||||||
|
- Files: `frontend/src/pages/__tests__/ServicePage.test.tsx` (new or extend)
|
||||||
|
- Lines: ~50
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 7 — Settings form (R4)
|
||||||
|
|
||||||
|
- [ ] **7.1 Machines + SSH-key editors in Sheet**
|
||||||
|
- Files: `frontend/src/pages/Settings.tsx`
|
||||||
|
- Lines: ~100
|
||||||
|
- Details: both machine editor and SSH-key editor open in `SheetForm` below
|
||||||
|
`md`. Validate-on-save preserved.
|
||||||
|
|
||||||
|
- [ ] **7.2 Tests**
|
||||||
|
- Files: `frontend/src/pages/__tests__/Settings.test.tsx`
|
||||||
|
- Lines: ~40
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 8 — Message compose + WidgetConfigDialog (R4)
|
||||||
|
|
||||||
|
- [ ] **8.1 Message compose Sheet**
|
||||||
|
- Files: `frontend/src/pages/UsersPage.impl.tsx`
|
||||||
|
- Lines: ~60
|
||||||
|
- Details: compose dialog → `SheetForm` below `md`. HTML body textarea + iOS
|
||||||
|
Safari attachment upload verified manually.
|
||||||
|
|
||||||
|
- [ ] **8.2 WidgetConfigDialog Sheet**
|
||||||
|
- Files: `frontend/src/components/WidgetConfigDialog.tsx`
|
||||||
|
- Lines: ~60
|
||||||
|
- Details: reorder list and per-widget config render inside `SheetForm` below
|
||||||
|
`md`. Sticky save bar.
|
||||||
|
|
||||||
|
- [ ] **8.3 Tests**
|
||||||
|
- Files: extend existing
|
||||||
|
- Lines: ~60
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 9 — Touch-target audit (R6)
|
||||||
|
|
||||||
|
- [ ] **9.1 Apply `mobile-touch-target` across routes**
|
||||||
|
- Files: all 9 pages + shared components (`SessionActivityPanel`,
|
||||||
|
`ObservabilityPage`, etc.)
|
||||||
|
- Lines: ~150 (sprinkled)
|
||||||
|
- Details: icon buttons, checkboxes, switches, badges-as-buttons, row taps.
|
||||||
|
Manual device-mode pass at 375px logging violations; fix each.
|
||||||
|
|
||||||
|
- [ ] **9.2 Audit log**
|
||||||
|
- Files: this PR description
|
||||||
|
- Details: list every element touched with before/after hit-area size.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 10 — Docs + verify
|
||||||
|
|
||||||
|
- [ ] **10.1 Update `docs/REQUIREMENTS.md`**
|
||||||
|
- Files: `docs/REQUIREMENTS.md`
|
||||||
|
- Lines: ~20
|
||||||
|
- Details: add a Mobile section documenting the breakpoint, card/Sheet
|
||||||
|
behavior, 44px policy, and the polling follow-up risk.
|
||||||
|
|
||||||
|
- [ ] **10.2 Cross-route manual pass**
|
||||||
|
- Details: walk all 9 routes at 375px (iPhone 12 Pro preset) and at 1280px.
|
||||||
|
Confirm no regressions; file follow-ups for any iOS Safari quirks found.
|
||||||
|
|
||||||
|
- [ ] **10.3 Verify report**
|
||||||
|
- Files: `openspec/changes/mobile-responsive-parity/verify-report.md`
|
||||||
|
- Lines: ~80
|
||||||
|
- Details: per-AC evidence (AC1–AC8), tool versions, manual test notes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Each slice's diff should stay well under 400 changed lines. If a slice (e.g.
|
||||||
|
Settings at ~100 + 40 test) approaches the budget, split along the natural
|
||||||
|
sub-section boundary.
|
||||||
|
- Slices 3–5 (tables) and 6–8 (forms) can be reordered or parallelized across
|
||||||
|
branches if helpful, but each must merge green.
|
||||||
|
- No slice touches the backend.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Verify Report — Mobile responsive parity
|
||||||
|
|
||||||
|
**Change:** `mobile-responsive-parity`
|
||||||
|
**Phase:** verify
|
||||||
|
**Date:** 2026-06-26
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
All 9 routes are fully operable in phone portrait (≥360px) at a single `md:`
|
||||||
|
(768px) breakpoint. Desktop layout (≥768px) is unchanged. No backend changes.
|
||||||
|
No new product features.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
### AC1 — Every route fully operable at 375px ✅
|
||||||
|
|
||||||
|
All 9 routes (Dashboard, Observability, Media, Files, Backups, Users, Actions,
|
||||||
|
Services, Settings) render and operate at phone-portrait width:
|
||||||
|
|
||||||
|
- **Dashboard**: single-column widget stack + section anchor bar (Slice 2).
|
||||||
|
- **Observability**: existing responsive layout + touch-target audit (Slice 9).
|
||||||
|
- **Media**: card layout with mobile pagination, card-tap navigation (Slice 3).
|
||||||
|
- **Files**: card layout with directory navigation, preserved ffprobe/jobs (Slice 4).
|
||||||
|
- **Backups**: card layouts for alerts/jobs/runs tables (Slice 5).
|
||||||
|
- **Users**: card layout with selection checkboxes + drawer navigation (Slice 5).
|
||||||
|
- **Actions**: existing responsive layout + touch-target audit (Slice 9).
|
||||||
|
- **Services**: list renders stacked; service edit via SheetForm (Slices 6, 9).
|
||||||
|
- **Settings**: machine editor via SheetForm; existing inline panels stack (Slice 7, 9).
|
||||||
|
|
||||||
|
### AC2 — Four wide tables show cards at 375px and tables at 1280px ✅
|
||||||
|
|
||||||
|
Media, FileBrowser, UsersPage, and the three Backups tables each render
|
||||||
|
`MobileCardRow` cards below `md` and `<DataTable>` tables at/above `md`. Each
|
||||||
|
card shows a primary title + 3–5 fields chosen per-table. Tested in Vitest
|
||||||
|
with mocked `matchMedia` at both breakpoints.
|
||||||
|
|
||||||
|
### AC3 — Four edit forms open in Sheet at 375px and Dialog at 1280px ✅
|
||||||
|
|
||||||
|
ServicePage, Settings (machine editor), message compose, and WidgetConfigDialog
|
||||||
|
each branch on `useIsMobile()` to render `SheetForm` (side=bottom, full-height)
|
||||||
|
below `md` and the existing `Dialog` at/above `md`. Tested in Vitest.
|
||||||
|
|
||||||
|
### AC4 — HoverEditButton always visible at 375px, hover-revealed at 1280px ✅
|
||||||
|
|
||||||
|
`HoverEditButton` defaults to `mobile="always"` (always visible below `md`,
|
||||||
|
hover-revealed at `md:`+). Tested in HoverEditButton.test.tsx with class-
|
||||||
|
composition assertions.
|
||||||
|
|
||||||
|
### AC5 — 44px minimum touch-target audit ✅
|
||||||
|
|
||||||
|
40 interactive elements across 12 files now carry the `mobile-touch-target`
|
||||||
|
class (applies `min-height: 44px; min-width: 44px` only below 768px). Covers
|
||||||
|
icon buttons, checkboxes, switches, and small text buttons. Default-size text
|
||||||
|
buttons (32px) were deliberately skipped to stay surgical — flagged as a
|
||||||
|
residual risk if strict WCAG 2.5.5 on ALL elements is required.
|
||||||
|
|
||||||
|
### AC6 — Dashboard single column + anchors at 375px, grid at 1280px ✅
|
||||||
|
|
||||||
|
Tested in Dashboard.test.tsx: mobile test asserts single column + section
|
||||||
|
labels + anchor pills; desktop test asserts no anchor bar + widgets present.
|
||||||
|
|
||||||
|
### AC7 — lint/build/test green ✅
|
||||||
|
|
||||||
|
```
|
||||||
|
cd frontend && npm run lint → 0 errors (2 pre-existing warnings)
|
||||||
|
cd frontend && npm run build → ✓ built (tsc -b + vite)
|
||||||
|
cd frontend && npm run test → 28 files / 116 tests passed
|
||||||
|
```
|
||||||
|
|
||||||
|
### AC8 — Vitest test per touched page at <768px and ≥768px ✅
|
||||||
|
|
||||||
|
Each touched page has at least one mobile and one desktop test:
|
||||||
|
|
||||||
|
| Page/Component | Mobile tests | Desktop tests |
|
||||||
|
|----------------|-------------|---------------|
|
||||||
|
| Dashboard | 3 | 3 (existing) |
|
||||||
|
| Media | 5 | existing |
|
||||||
|
| FileBrowser | 4 | existing |
|
||||||
|
| UsersPage | 2 | existing |
|
||||||
|
| Backups (Alerts/Runs) | 3 | existing |
|
||||||
|
| BackupJobs | 2 (new file) | — |
|
||||||
|
| ServicePage | 3 | 2 (new file) |
|
||||||
|
| Settings | 3 | existing |
|
||||||
|
| WidgetConfigDialog | 1 | 1 (new file) |
|
||||||
|
| MobileCardRow | 7 | — (primitive) |
|
||||||
|
| SheetForm | 5 | — (primitive) |
|
||||||
|
| HoverEditButton | 2 | 2 |
|
||||||
|
|
||||||
|
## Non-goals confirmed
|
||||||
|
|
||||||
|
- No tablet/landscape/sm: intermediate layout.
|
||||||
|
- No PWA, manifest, service worker.
|
||||||
|
- No polling-interval changes.
|
||||||
|
- No backend changes.
|
||||||
|
- No new data-table library.
|
||||||
|
|
||||||
|
## Residual risks / known gaps
|
||||||
|
|
||||||
|
1. **R4.5 dirty-state outside-click confirm** — RESOLVED. `SheetForm` gained an
|
||||||
|
`isDirty` prop; when true, any close path (Cancel, header X, Radix overlay
|
||||||
|
click, Escape) opens a "Discard changes?" confirm. All four form consumers
|
||||||
|
(ServicePage, Settings machine editor, message compose, WidgetConfigDialog)
|
||||||
|
compute and pass `isDirty`.
|
||||||
|
|
||||||
|
2. **Default-size text buttons (32px)** — RESOLVED. A second touch-target pass
|
||||||
|
applied `.mobile-touch-target` to 32 default-size buttons across 9 files
|
||||||
|
(Save, Cancel, Delete, Validate SSH, Run job, etc.) plus the shared
|
||||||
|
`DialogFooter`. Combined with Slice 9, all interactive elements below `md`
|
||||||
|
now meet the 44px minimum.
|
||||||
|
|
||||||
|
3. **Polling on battery** (D8 risk) — RESOLVED. `refetchIntervalInBackground:
|
||||||
|
false` is now a `QueryClient` default, so all interval polls (widgets ~30s,
|
||||||
|
queue status 5s, media build progress 1s) pause when the tab is hidden. The
|
||||||
|
`useMedia` build-progress poll no longer overrides this. Build progress
|
||||||
|
resumes and catches up on return.
|
||||||
|
|
||||||
|
4. **iOS Safari manual verification** not performed in CI. `h-[100dvh]` on
|
||||||
|
SheetForm, `position: sticky` behavior, and attachment upload from Files
|
||||||
|
need real-device testing. The flex-column layout (not `position: sticky`)
|
||||||
|
avoids the known sticky-inside-transform pitfall. UNRESOLVED — requires a
|
||||||
|
physical device pass.
|
||||||
|
|
||||||
|
5. **Pagination duplication** — RESOLVED. Extracted a shared `TablePagination`
|
||||||
|
component consumed by both the desktop `DataTable` and the Media mobile
|
||||||
|
card list. Removes ~90 lines of duplication.
|
||||||
Reference in New Issue
Block a user