Compare commits
2
Commits
4d67567d4c
...
1851fa9753
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1851fa9753 | ||
|
|
dec1dd902c |
@@ -241,6 +241,8 @@ def _operation_messages(service: str, method: str, path: str) -> tuple[str, str]
|
||||
if service in {"Radarr", "Sonarr"} and "/release" in normalized_path:
|
||||
return f"Checking releases through {service}…", f"{service} returned release information"
|
||||
if service in {"Radarr", "Sonarr"} and "/command" in normalized_path:
|
||||
if normalized_method == "GET":
|
||||
return f"Checking {service}'s search activity…", f"{service} returned its current activity"
|
||||
return f"Sending a command to {service}…", f"{service} accepted the command"
|
||||
if service == "Bazarr" and "/subtitles" in normalized_path and normalized_method == "PATCH":
|
||||
return "Asking Bazarr for fresh subtitles…", "Bazarr started the subtitle search"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Read title-specific search activity without starting a search or changing monitoring."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..clients.base import ApiClient
|
||||
from ..models import RequestType
|
||||
|
||||
|
||||
def _ids(values: Any) -> set[int]:
|
||||
if not isinstance(values, list):
|
||||
return set()
|
||||
return {value for value in values if type(value) is int and value > 0}
|
||||
|
||||
|
||||
def search_status(commands: Any, request_type: RequestType, item_id: int, episodes: Any = None) -> str:
|
||||
"""Only a matching queued/started search is evidence of current activity.
|
||||
|
||||
Completed commands, RSS syncs and library-wide jobs do not establish that this
|
||||
title is being searched. Episode searches are matched using Sonarr episode IDs.
|
||||
"""
|
||||
if not isinstance(commands, list):
|
||||
return "unavailable"
|
||||
episode_ids = _ids([
|
||||
episode.get("id") for episode in (episodes if isinstance(episodes, list) else [])
|
||||
if isinstance(episode, dict) and episode.get("seriesId", item_id) == item_id
|
||||
])
|
||||
queued = False
|
||||
for command in commands:
|
||||
if not isinstance(command, dict):
|
||||
continue
|
||||
body = command.get("body")
|
||||
if not isinstance(body, dict):
|
||||
continue
|
||||
name = str(command.get("name") or body.get("name") or "").lower()
|
||||
if request_type == RequestType.movie:
|
||||
matches = name == "moviessearch" and item_id in _ids(body.get("movieIds"))
|
||||
else:
|
||||
matches = (
|
||||
name in {"seriessearch", "seasonsearch"} and body.get("seriesId") == item_id
|
||||
) or (
|
||||
name == "episodesearch" and bool(episode_ids & _ids(body.get("episodeIds")))
|
||||
)
|
||||
if not matches or command.get("ended"):
|
||||
continue
|
||||
status = str(command.get("status", "")).lower()
|
||||
if status in {"started", "1"}:
|
||||
return "searching"
|
||||
if status in {"queued", "0"}:
|
||||
queued = True
|
||||
return "queued" if queued else "idle"
|
||||
|
||||
|
||||
async def read_search_status(
|
||||
client: ApiClient, request_type: RequestType, item_id: int, episodes: Any = None,
|
||||
) -> str:
|
||||
try:
|
||||
commands = await client.get("/api/v3/command", timeout_seconds=3.0)
|
||||
except Exception:
|
||||
# Search telemetry must not turn a healthy library record into an error.
|
||||
return "unavailable"
|
||||
return search_status(commands, request_type, item_id, episodes)
|
||||
@@ -27,6 +27,7 @@ from ..db import (
|
||||
clear_seerr_media_failure,
|
||||
)
|
||||
from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
|
||||
from .collector_search import read_search_status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -666,6 +667,19 @@ def _build_presentation(
|
||||
fully_available = bool(jellyfin_found and not jellyfin_partial)
|
||||
download_visible = bool(download.get("visible"))
|
||||
download_state = str(download.get("state") or "not_started")
|
||||
search_status = str((arr_details.get("search") or {}).get("state") or "unavailable")
|
||||
search_in_progress = search_status in {"searching", "queued"}
|
||||
search_label = {
|
||||
"searching": "Searching",
|
||||
"queued": "Search queued",
|
||||
"idle": "Not searching",
|
||||
}.get(search_status, "Search unknown")
|
||||
search_target = "episode releases" if snapshot.request_type == RequestType.tv else "a matching release"
|
||||
search_detail = {
|
||||
"searching": f"{collector} is searching for {search_target}.",
|
||||
"queued": f"Search queued — waiting for {collector} to start.",
|
||||
"idle": f"Not currently searching for this {'series' if snapshot.request_type == RequestType.tv else 'movie'}.",
|
||||
}.get(search_status, f"Search status unavailable — unable to check {collector}.")
|
||||
|
||||
if snapshot.state == NormalizedState.requested:
|
||||
status_label = "Waiting for approval"
|
||||
@@ -679,8 +693,8 @@ def _build_presentation(
|
||||
elif jellyfin_partial:
|
||||
status_label = f"Partially available — {available} of {total} episodes collected"
|
||||
meaning = (
|
||||
f"Some of this request is ready to watch. {collector} is still looking for "
|
||||
f"{missing} missing episode{'s' if missing != 1 else ''}."
|
||||
f"Some of this request is ready to watch; {missing} episode{'s' if missing != 1 else ''} "
|
||||
f"{'is' if missing == 1 else 'are'} still missing. {search_detail}"
|
||||
)
|
||||
elif snapshot.state in {NormalizedState.completed, NormalizedState.available}:
|
||||
status_label = "Available to watch"
|
||||
@@ -760,6 +774,10 @@ def _build_presentation(
|
||||
next_title = "Add this request to the library queue"
|
||||
next_description = f"Send the approved request to {collector} so collection can begin."
|
||||
recommended = ["readd_to_arr"]
|
||||
elif search_in_progress and arr_state != "available":
|
||||
next_title = "Wait for the search results" if search_status == "searching" else "Wait for the queued search"
|
||||
next_description = f"{search_detail} This page will update automatically."
|
||||
recommended = []
|
||||
elif "search_auto" in action_ids or "search_releases" in action_ids:
|
||||
if snapshot.request_type == RequestType.tv and missing:
|
||||
target = f"the {missing} missing episode{'s' if missing != 1 else ''}"
|
||||
@@ -806,38 +824,38 @@ def _build_presentation(
|
||||
"state": "complete" if approved else "active",
|
||||
"summary": "Approved for collection" if approved else "Waiting for approval",
|
||||
}
|
||||
library_state_label = None
|
||||
if arr_state == "missing":
|
||||
library_state, library_summary = "attention", "Not yet added to the collector"
|
||||
elif arr_state == "error":
|
||||
library_state, library_summary = "attention", f"Unable to read {collector}"
|
||||
elif partial:
|
||||
library_state, library_summary = "partial", f"{available} of {total} episodes collected"
|
||||
elif arr_state == "available":
|
||||
library_state, library_summary = "complete", "Collection complete"
|
||||
elif arr_state in {"added", "searching"}:
|
||||
library_state = "active" if missing or not available else "complete"
|
||||
library_summary = (
|
||||
f"{missing} episode{'s' if missing != 1 else ''} still missing"
|
||||
if snapshot.request_type == RequestType.tv and missing
|
||||
else "In the library queue"
|
||||
)
|
||||
elif fully_available or (arr_state == "available" and not partial):
|
||||
library_state, library_summary = "complete", "Collection complete — no search needed"
|
||||
elif arr_state in {"added", "searching", "available"}:
|
||||
library_state = "active" if search_in_progress else "attention" if search_status == "unavailable" else "waiting"
|
||||
library_state_label = search_label
|
||||
library_summary = search_detail
|
||||
if download_visible and download_state == "downloading" and not search_in_progress:
|
||||
library_state, library_state_label = "active", "Downloading"
|
||||
library_summary = f"Download in progress. {search_detail}"
|
||||
if partial:
|
||||
library_state = "partial"
|
||||
library_summary = f"{available} of {total} episodes collected. {library_summary}"
|
||||
else:
|
||||
library_state, library_summary = "waiting", "Waiting for collector information"
|
||||
|
||||
if fully_available:
|
||||
search_state, search_summary = "complete", "No further search needed"
|
||||
elif arr_state == "available":
|
||||
elif arr_state == "available" and not partial:
|
||||
search_state, search_summary = "complete", "A release was collected"
|
||||
elif download_visible:
|
||||
elif search_in_progress:
|
||||
search_state, search_summary = "active", search_detail
|
||||
elif download_visible and download_state in {"downloading", "paused", "completed"} and not partial:
|
||||
search_state = "complete"
|
||||
search_summary = "A release was found"
|
||||
elif arr_state in {"added", "searching"} and (missing or snapshot.request_type == RequestType.movie):
|
||||
search_state = "active" if prowlarr_state == "ok" else "attention"
|
||||
search_summary = (
|
||||
f"Ready to search for {missing} missing episode{'s' if missing != 1 else ''}"
|
||||
if snapshot.request_type == RequestType.tv and missing
|
||||
else "Ready to search for a release"
|
||||
)
|
||||
search_state = "waiting" if search_status == "idle" and prowlarr_state == "ok" else "attention"
|
||||
search_summary = search_detail
|
||||
else:
|
||||
search_state, search_summary = "waiting", "Search has not started"
|
||||
|
||||
@@ -917,6 +935,8 @@ def _build_presentation(
|
||||
"id": "library",
|
||||
"label": "Library collection",
|
||||
"state": library_state,
|
||||
"stateLabel": library_state_label or library_state,
|
||||
"searchStatus": search_status,
|
||||
"summary": library_summary,
|
||||
"available": available,
|
||||
"missing": missing,
|
||||
@@ -929,7 +949,7 @@ def _build_presentation(
|
||||
"label": "Release search",
|
||||
"state": search_state,
|
||||
"summary": search_summary,
|
||||
"actionIds": [] if fully_available else [
|
||||
"actionIds": [] if fully_available or search_in_progress else [
|
||||
action_id
|
||||
for action_id in ("search_auto", "search_releases")
|
||||
if action_id in action_ids
|
||||
@@ -1144,6 +1164,9 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
arr_queue = _filter_queue(arr_queue, series_id, RequestType.tv)
|
||||
arr_details["queue"] = arr_queue
|
||||
episodes = await sonarr.get_episodes(series_id)
|
||||
arr_details["search"] = {
|
||||
"state": await read_search_status(sonarr, RequestType.tv, series_id, episodes)
|
||||
}
|
||||
arr_details["availability"] = _episode_availability(episodes)
|
||||
missing_by_season = _missing_episode_numbers_by_season(episodes)
|
||||
if missing_by_season:
|
||||
@@ -1187,8 +1210,6 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
if arr_item:
|
||||
if arr_item.get("hasFile"):
|
||||
arr_state = "available"
|
||||
elif arr_item.get("isAvailable"):
|
||||
arr_state = "searching"
|
||||
else:
|
||||
arr_state = "added"
|
||||
else:
|
||||
@@ -1203,12 +1224,17 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
arr_queue = await radarr.get_queue(int(arr_item["id"]))
|
||||
arr_queue = _filter_queue(arr_queue, int(arr_item["id"]), RequestType.movie)
|
||||
arr_details["queue"] = arr_queue
|
||||
arr_details["search"] = {
|
||||
"state": await read_search_status(radarr, RequestType.movie, int(arr_item["id"]))
|
||||
}
|
||||
except Exception as exc:
|
||||
arr_state = "error"
|
||||
arr_details["error"] = str(exc)
|
||||
|
||||
if arr_state is None:
|
||||
arr_state = "unknown"
|
||||
if arr_state == "added" and (arr_details.get("search") or {}).get("state") == "searching":
|
||||
arr_state = "searching"
|
||||
|
||||
_apply_arr_identity(snapshot, arr_item)
|
||||
timeline.append(TimelineHop(service="Sonarr/Radarr", status=arr_state, details=arr_details))
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
from contextlib import ExitStack
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from backend.app.config import settings
|
||||
from backend.app.models import NormalizedState, RequestType, Snapshot
|
||||
from backend.app.services import snapshot as snapshot_service
|
||||
from backend.app.services.collector_search import read_search_status, search_status
|
||||
|
||||
|
||||
def command(name="MoviesSearch", status="started", **body):
|
||||
return {"name": name, "status": status, "body": body}
|
||||
|
||||
|
||||
class CollectorSearchTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_movie_search_is_scoped_to_the_movie(self):
|
||||
self.assertEqual(search_status([command(movieIds=[12])], RequestType.movie, 12), "searching")
|
||||
self.assertEqual(search_status([command(movieIds=[13])], RequestType.movie, 12), "idle")
|
||||
|
||||
def test_queued_search_and_running_search_priority(self):
|
||||
queued = command(status="queued", movieIds=[12])
|
||||
self.assertEqual(search_status([queued], RequestType.movie, 12), "queued")
|
||||
self.assertEqual(search_status([queued, command(movieIds=[12])], RequestType.movie, 12), "searching")
|
||||
|
||||
def test_terminal_commands_are_not_searching(self):
|
||||
for state in ["completed", "failed", "aborted", "cancelled", "orphaned", 2, 3, 4, 5, 6]:
|
||||
with self.subTest(state=state):
|
||||
self.assertEqual(search_status([command(status=state, movieIds=[12])], RequestType.movie, 12), "idle")
|
||||
ended = {**command(movieIds=[12]), "ended": "2026-09-06T00:00:00Z"}
|
||||
self.assertEqual(search_status([ended], RequestType.movie, 12), "idle")
|
||||
|
||||
def test_numeric_statuses(self):
|
||||
for state, expected in [(0, "queued"), (1, "searching")]:
|
||||
self.assertEqual(search_status([command(status=state, movieIds=[12])], RequestType.movie, 12), expected)
|
||||
|
||||
def test_series_and_season_searches(self):
|
||||
for name in ["SeriesSearch", "SeasonSearch"]:
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(search_status([command(name, seriesId=12, seasonNumber=5)], RequestType.tv, 12), "searching")
|
||||
self.assertEqual(search_status([command(name, seriesId=13, seasonNumber=5)], RequestType.tv, 12), "idle")
|
||||
|
||||
def test_episode_search_uses_episode_ids_not_numbers(self):
|
||||
episodes = [{"id": 109, "seriesId": 12, "seasonNumber": 5, "episodeNumber": 9}]
|
||||
for ids, expected in [([109], "searching"), ([9], "idle"), ([110], "idle")]:
|
||||
self.assertEqual(search_status([command("EpisodeSearch", episodeIds=ids)], RequestType.tv, 12, episodes), expected)
|
||||
self.assertEqual(search_status([command("EpisodeSearch", episodeIds=[109])], RequestType.tv, 13, episodes), "idle")
|
||||
|
||||
def test_background_tasks_and_unscoped_searches_are_not_title_searches(self):
|
||||
for name in ["RssSync", "RefreshMovie", "RefreshSeries", "MissingEpisodeSearch", "MoviesSearch"]:
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(search_status([command(name)], RequestType.movie, 12), "idle")
|
||||
|
||||
def test_empty_commands_are_idle_but_missing_response_is_unknown(self):
|
||||
self.assertEqual(search_status([], RequestType.movie, 12), "idle")
|
||||
for payload in [None, {}, {"error": "unavailable"}]:
|
||||
self.assertEqual(search_status(payload, RequestType.movie, 12), "unavailable")
|
||||
|
||||
async def test_check_is_read_only_with_a_short_timeout(self):
|
||||
client = SimpleNamespace(get=AsyncMock(return_value=[command(movieIds=[12])]))
|
||||
self.assertEqual(await read_search_status(client, RequestType.movie, 12), "searching")
|
||||
client.get.assert_awaited_once_with("/api/v3/command", timeout_seconds=3.0)
|
||||
|
||||
async def test_service_failure_is_unknown_not_idle(self):
|
||||
client = SimpleNamespace(get=AsyncMock(side_effect=TimeoutError()))
|
||||
self.assertEqual(await read_search_status(client, RequestType.movie, 12), "unavailable")
|
||||
|
||||
|
||||
class LibrarySearchPresentationTests(unittest.TestCase):
|
||||
def presentation(self, search="idle", *, media_type=RequestType.movie, available=0, missing=1,
|
||||
arr_state="added", download_state="not_started", jellyfin=False):
|
||||
snapshot = Snapshot(request_id="12", title="Example", request_type=media_type,
|
||||
state=NormalizedState.added_to_arr)
|
||||
return snapshot_service._build_presentation(
|
||||
snapshot, approved=True, arr_state=arr_state,
|
||||
arr_details={"search": {"state": search}, "availability": {
|
||||
"available": available, "missing": missing, "total": available + missing,
|
||||
}}, prowlarr_state="ok",
|
||||
download={"visible": download_state != "not_started", "state": download_state, "torrents": []},
|
||||
jellyfin_found=jellyfin, jellyfin_link=None,
|
||||
)
|
||||
|
||||
def stage(self, presentation, stage_id="library"):
|
||||
return next(stage for stage in presentation["pipeline"] if stage["id"] == stage_id)
|
||||
|
||||
def test_card_uses_actual_search_state(self):
|
||||
for state, badge, style in [("idle", "Not searching", "waiting"), ("searching", "Searching", "active"),
|
||||
("queued", "Search queued", "active"), ("unavailable", "Search unknown", "attention")]:
|
||||
with self.subTest(state=state):
|
||||
presentation = self.presentation(state)
|
||||
library = self.stage(presentation)
|
||||
self.assertEqual(library["stateLabel"], badge)
|
||||
self.assertEqual(library["state"], style)
|
||||
self.assertEqual(library["searchStatus"], state)
|
||||
self.assertEqual(self.stage(presentation, "search")["summary"], library["summary"])
|
||||
self.assertEqual(library["available"], 0)
|
||||
self.assertEqual(library["missing"], 1)
|
||||
|
||||
def test_partial_tv_retains_counts_and_search_activity(self):
|
||||
for state in ["idle", "searching", "queued", "unavailable"]:
|
||||
with self.subTest(state=state):
|
||||
presentation = self.presentation(state, media_type=RequestType.tv, available=22, missing=2, jellyfin=True)
|
||||
library = self.stage(presentation)
|
||||
self.assertEqual(library["state"], "partial")
|
||||
self.assertEqual(library["searchStatus"], state)
|
||||
self.assertIn("22 of 24 episodes collected", library["summary"])
|
||||
self.assertNotIn("is still looking", presentation["status"]["meaning"])
|
||||
|
||||
def test_collected_titles_dont_look_stuck_searching(self):
|
||||
for jellyfin in [True, False]:
|
||||
library = self.stage(self.presentation("idle", arr_state="available", available=1, missing=0, jellyfin=jellyfin))
|
||||
self.assertEqual(library["state"], "complete")
|
||||
self.assertIn("no search needed", library["summary"])
|
||||
|
||||
def test_download_has_its_own_state_without_claiming_searching(self):
|
||||
library = self.stage(self.presentation("idle", download_state="downloading"))
|
||||
self.assertEqual(library["stateLabel"], "Downloading")
|
||||
self.assertIn("Not currently searching", library["summary"])
|
||||
|
||||
def test_an_old_missing_download_does_not_mark_search_complete(self):
|
||||
search = self.stage(self.presentation("idle", download_state="missing"), "search")
|
||||
self.assertEqual(search["state"], "waiting")
|
||||
|
||||
|
||||
class SearchSnapshotIntegrationTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_movie_eligibility_is_not_search_activity_and_tv_commands_are_checked(self):
|
||||
for media_type in [RequestType.movie, RequestType.tv]:
|
||||
for commands, expected in [([], "idle"), ([command("MoviesSearch", movieIds=[12]), command("EpisodeSearch", episodeIds=[109])], "searching")]:
|
||||
with self.subTest(media_type=media_type, search=expected), ExitStack() as stack:
|
||||
runtime = settings.model_copy(update={"requests_data_source": "prefer_cache"})
|
||||
item = {"id": 12, "title": "Example", "hasFile": False, "isAvailable": True, "monitored": True}
|
||||
collector = SimpleNamespace(
|
||||
get_movie_by_tmdb_id=AsyncMock(return_value=[item]),
|
||||
get_series_by_tvdb_id=AsyncMock(return_value=[item]),
|
||||
get_episodes=AsyncMock(return_value=[{"id": 109, "seriesId": 12, "seasonNumber": 5, "episodeNumber": 9, "monitored": True, "hasFile": False}]),
|
||||
get_queue=AsyncMock(return_value={"records": []}),
|
||||
get=AsyncMock(return_value=commands),
|
||||
)
|
||||
mocks = {
|
||||
"get_runtime_settings": runtime,
|
||||
"get_request_cache_payload": {"id": 12, "type": media_type.value, "status": 2,
|
||||
"media": {"title": "Example", "tmdbId": 123, "tvdbId": 456}},
|
||||
"get_request_cache_by_id": None,
|
||||
"JellyseerrClient": SimpleNamespace(configured=lambda: False),
|
||||
"JellyfinClient": SimpleNamespace(configured=lambda: False),
|
||||
"QBittorrentClient": SimpleNamespace(configured=lambda: False),
|
||||
"SonarrClient": collector, "RadarrClient": collector,
|
||||
"ProwlarrClient": SimpleNamespace(get_health=AsyncMock(return_value=[])),
|
||||
"get_request_download_evidence": {}, "_latest_repair_action": None, "save_snapshot": None,
|
||||
}
|
||||
for name, value in mocks.items():
|
||||
stack.enter_context(patch.object(snapshot_service, name, return_value=value))
|
||||
stack.enter_context(patch.object(snapshot_service, "_maybe_refresh_jellyfin", new=AsyncMock()))
|
||||
snapshot = await snapshot_service.build_snapshot("12")
|
||||
collector.get.assert_awaited_once_with("/api/v3/command", timeout_seconds=3.0)
|
||||
self.assertEqual(snapshot.state, NormalizedState.searching if expected == "searching" else NormalizedState.added_to_arr)
|
||||
library = next(stage for stage in snapshot.presentation["pipeline"] if stage["id"] == "library")
|
||||
self.assertEqual(library["searchStatus"], expected)
|
||||
@@ -0,0 +1,20 @@
|
||||
# Shared workspace layout
|
||||
|
||||
- Use `app/ui/PageHeading.tsx` for page titles. Keep the heading flat, with a short description and optional actions. Only record IDs belong in the optional eyebrow.
|
||||
- Admin pages use `AdminShell`, which supplies the same heading and settings navigation.
|
||||
- Authentication screens use `AuthLayout`; they do not render the signed-in navigation.
|
||||
- `app/workspace.css` owns page width, gutters, title sizes and shared spacing. Feature styles own the content inside those pages. Do not add new page-specific hero panels or outer width overrides.
|
||||
- Keep primary actions, secondary controls and destructive actions visually distinct. Do not fade or uppercase every span inside a button: cards also use buttons, often with nested text.
|
||||
- Keep technical IDs and pipeline labels monospace. Use sentence case for ordinary labels and descriptions.
|
||||
- Preserve the six-stage request pipeline: three columns on desktop, two on tablet, one on narrow screens. Issue reports remain a right-hand column on desktop and stack on smaller screens.
|
||||
- Desktop navigation stays at the top; mobile navigation stays at the bottom. Dialogs must remain clear of both.
|
||||
|
||||
## Browser checks
|
||||
|
||||
Build the frontend before reviewing. The scripts in `scripts/` run using Node and Playwright:
|
||||
|
||||
- `review_layout_ui.cjs`: page alignment, consistent headings, overflow, redirects, pipeline layout, invite tabs, and fixture-only recovery forms.
|
||||
- `review_account_ui.cjs`: fixture-only login and profile interaction checks.
|
||||
- `review_settings_ui.cjs`: settings state, region-only saves, secret preservation, responsive controls and issue dialog placement.
|
||||
|
||||
The layout/settings reviews accept `REVIEW_BASE`, `REVIEW_LIVE_BASE`, `REVIEW_PLAYWRIGHT`, and `REVIEW_DIR`. Provide an authorised short-lived session through `REVIEW_SESSION` as `{ "name": "cookie-name", "token": "..." }` in the process environment, never in a committed file. Live writes are blocked; submission checks use fixtures. Screenshots may contain account information and must stay outside the repository.
|
||||
@@ -1,7 +1,4 @@
|
||||
/* Top-navigation workspace and streamlined account screens. */
|
||||
.page > main:not(.auth-screen):not(.auth-card):not(.login-page) { width: calc(100% - 64px); max-width: 1440px; margin: 32px auto 0; padding: 0; border: 0 !important; background: transparent !important; }
|
||||
.page > .site-banner, .page > .user-view-banner { width: calc(100% - 64px); max-width: 1440px; margin: 16px auto 0; }
|
||||
.admin-shell.admin-shell--top-nav { display: block; width: calc(100% - 64px); max-width: 1440px; margin: 24px auto 0; }
|
||||
.admin-shell--top-nav > .admin-card { width: 100%; max-width: none; padding: 24px 0; }
|
||||
.settings-top-navigation { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 0 0 20px; border-bottom: 1px solid var(--ops-line-soft); }
|
||||
.settings-top-navigation a { color: var(--ops-muted); font-size: 13px; text-decoration: none; }
|
||||
@@ -11,10 +8,7 @@
|
||||
.admin-supplemental { margin-top: 28px; border-top: 1px solid var(--ops-line-soft); padding-top: 20px; }
|
||||
.admin-supplemental > summary { cursor: pointer; color: var(--ops-muted); font-size: 13px; margin-bottom: 18px; }
|
||||
.admin-supplemental .admin-rail-stack { display: block; max-width: 960px; }
|
||||
.page > main.account-page { max-width: 920px !important; margin-top: 42px; }
|
||||
.account-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 32px; }
|
||||
.account-eyebrow { font: 10px "JetBrains Mono", monospace; color: var(--ops-faint); }
|
||||
.account-heading h1 { font-size: clamp(30px, 4vw, 40px); line-height: 1.2; margin: 7px 0 0; color: var(--ops-text); }
|
||||
.account-identity { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.account-identity > div { display: grid; gap: 4px; min-width: 0; }
|
||||
.account-identity strong { font-size: 14px; overflow-wrap: anywhere; }
|
||||
@@ -92,9 +86,6 @@ button.account-secondary { min-height: 44px; padding: 11px 16px; border: 1px sol
|
||||
@keyframes account-appear { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@media (prefers-reduced-motion: reduce) { .account-panel { animation: none; } }
|
||||
@media (max-width: 680px) {
|
||||
.page > main:not(.auth-screen):not(.auth-card):not(.login-page), .page > .site-banner, .page > .user-view-banner, .admin-shell.admin-shell--top-nav { width: calc(100% - 32px); }
|
||||
.page > main.account-page { margin-top: 28px; }
|
||||
.account-heading { align-items: flex-start; gap: 16px; margin-bottom: 24px; }
|
||||
.account-identity strong { max-width: 130px; }
|
||||
.account-avatar { display: none; }
|
||||
.account-panel { padding: 22px 20px; }
|
||||
|
||||
@@ -1188,8 +1188,8 @@ export default function AdminInviteManagementPage() {
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
title="Invites"
|
||||
subtitle="Create access links, apply account profiles, deliver invitations, and see what needs attention."
|
||||
title="Invite policy & access"
|
||||
subtitle="Manage account defaults, profiles, and invitations across Magent."
|
||||
rail={inviteManagementRail}
|
||||
>
|
||||
<section className="admin-section">
|
||||
|
||||
@@ -109,11 +109,6 @@ export default function AdminRequestsAllPage() {
|
||||
<AdminShell
|
||||
title="All requests"
|
||||
subtitle="Paginated view of every cached request."
|
||||
actions={
|
||||
<button type="button" onClick={() => router.push('/admin')}>
|
||||
Back to settings
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<section className="admin-section">
|
||||
<div className="admin-toolbar">
|
||||
|
||||
@@ -116,14 +116,9 @@ export default function AdminSystemGuidePage() {
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
title="How it works"
|
||||
subtitle="Admin-only service wiring, control areas, and recovery flow for Magent."
|
||||
title="System guide"
|
||||
subtitle="Service connections, controls, and recovery paths."
|
||||
rail={rail}
|
||||
actions={
|
||||
<button type="button" onClick={() => router.push('/admin')}>
|
||||
Back to settings
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<section className="admin-section system-guide">
|
||||
<div className="admin-panel">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import PageHeading from '../ui/PageHeading'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
@@ -106,14 +108,9 @@ export default function ChangelogPage() {
|
||||
}, [groups, loading])
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<section className="card changelog-card">
|
||||
<div className="changelog-header">
|
||||
<h1>Changelog</h1>
|
||||
<p className="lede">Latest updates and release notes.</p>
|
||||
</div>
|
||||
{content}
|
||||
</section>
|
||||
</div>
|
||||
<main className="card changelog-page">
|
||||
<PageHeading title="Changelog" description="What’s new and improved in Magent." />
|
||||
{content}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import PageHeading from '../ui/PageHeading'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
|
||||
@@ -78,16 +80,10 @@ export default function FeedbackPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="card">
|
||||
<header className="how-hero">
|
||||
<p className="eyebrow">Send feedback</p>
|
||||
<h1>Help us improve Magent</h1>
|
||||
<p className="lede">
|
||||
Found a problem or have an idea? Send it here and we will see it right away.
|
||||
</p>
|
||||
</header>
|
||||
<main className="card feedback-page">
|
||||
<PageHeading title="Feedback" description="Share an idea or tell us what could work better." />
|
||||
|
||||
<form className="auth-form" onSubmit={submit}>
|
||||
<form className="account-panel account-form feedback-form" onSubmit={submit}>
|
||||
<label htmlFor="feedback-user">Your username</label>
|
||||
<input id="feedback-user" value={profile?.username ?? ''} readOnly />
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import BrandingLogo from '../ui/BrandingLogo'
|
||||
import AuthLayout from '../ui/AuthLayout'
|
||||
import { getApiBase } from '../lib/auth'
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
@@ -46,14 +46,8 @@ export default function ForgotPasswordPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="card auth-card">
|
||||
<BrandingLogo className="brand-logo brand-logo--login" />
|
||||
<h1>Forgot password</h1>
|
||||
<p className="lede">
|
||||
Enter the username or email you use for Jellyfin or Magent. If the account is eligible, a reset link
|
||||
will be emailed to you.
|
||||
</p>
|
||||
<form className="auth-form" onSubmit={submit}>
|
||||
<AuthLayout title="Forgot password?" description="Enter your username or email to request a reset link.">
|
||||
<form className="account-form login-form auth-flow-form" onSubmit={submit}>
|
||||
<label>
|
||||
Username or email
|
||||
<input
|
||||
@@ -63,10 +57,10 @@ export default function ForgotPasswordPage() {
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</label>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{status && <div className="status-banner">{status}</div>}
|
||||
{error && <div className="account-notice is-error" role="alert">{error}</div>}
|
||||
{status && <div className="account-notice is-status" role="status">{status}</div>}
|
||||
<div className="auth-actions">
|
||||
<button type="submit" disabled={loading}>
|
||||
<button type="submit" className="account-primary" disabled={loading}>
|
||||
{loading ? 'Sending reset link…' : 'Send reset link'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -74,6 +68,6 @@ export default function ForgotPasswordPage() {
|
||||
Back to sign in
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -512,10 +512,9 @@ button {
|
||||
}
|
||||
|
||||
button span {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.8;
|
||||
text-align: center;
|
||||
font-size: inherit;
|
||||
text-transform: inherit;
|
||||
text-align: inherit;
|
||||
}
|
||||
|
||||
.filters {
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import PageHeading from '../ui/PageHeading'
|
||||
|
||||
export default function HowItWorksPage() {
|
||||
return (
|
||||
<main className="card how-page">
|
||||
<header className="how-hero">
|
||||
<p className="eyebrow">How it works</p>
|
||||
<h1>How Magent works for users</h1>
|
||||
<p className="lede">
|
||||
Use Magent to find a request, watch it move through the pipeline, and know when it is
|
||||
ready without constantly refreshing the page.
|
||||
</p>
|
||||
</header>
|
||||
<PageHeading title="How it works" description="Request something to watch, follow its progress, and get help when you need it." />
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>What Magent is for</h2>
|
||||
|
||||
@@ -2,6 +2,7 @@ import './globals.css'
|
||||
import './ops-redesign.css'
|
||||
import './admin/config.css'
|
||||
import './account.css'
|
||||
import './workspace.css'
|
||||
import type { ReactNode } from 'react'
|
||||
import BrandingFavicon from './ui/BrandingFavicon'
|
||||
import ApplicationChrome from './ui/ApplicationChrome'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { getApiBase, setToken } from '../lib/auth'
|
||||
import MagentMark from '../ui/MagentMark'
|
||||
import AuthLayout from '../ui/AuthLayout'
|
||||
|
||||
type LoginMode = 'jellyfin' | 'local'
|
||||
type LoginOptions = { showJellyfinLogin: boolean; showLocalLogin: boolean; showForgotPassword: boolean; showSignupLink: boolean }
|
||||
@@ -77,10 +77,9 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="login-page">
|
||||
<section className="login-card" aria-labelledby="login-title">
|
||||
<div className="login-brand"><a href="/login" aria-label="Magent sign in"><MagentMark /><span>Magent</span></a><span className="login-beta">Beta</span></div>
|
||||
<header><h1 id="login-title">Welcome back.</h1><p>Sign in to your media workspace.</p></header>
|
||||
<AuthLayout title="Welcome back." description="Sign in to your media workspace." footer={
|
||||
optionsReady && options.showSignupLink && <>Have an invite? <a href="/signup">Create an account <span aria-hidden="true">↗</span></a></>
|
||||
}>
|
||||
{banner && <p className={`account-notice ${['error', 'maintenance'].includes(banner.tone) ? 'is-error' : 'is-status'}`} role="status">{banner.message}</p>}
|
||||
{optionsReady && options.showJellyfinLogin && options.showLocalLogin && <div className="login-methods" role="group" aria-label="Sign-in account">
|
||||
<button type="button" aria-pressed={selectedMode === 'jellyfin'} disabled={loading} onClick={() => { setMode('jellyfin'); setError('') }}>Grizzlyflix</button>
|
||||
@@ -102,9 +101,6 @@ export default function LoginPage() {
|
||||
<button type="submit" className="account-primary login-submit" disabled={loading}>{loading ? 'Signing in…' : 'Sign in'}<span aria-hidden="true">→</span></button>
|
||||
</form>
|
||||
)}
|
||||
{optionsReady && options.showSignupLink && <footer>Have an invite? <a href="/signup">Create an account <span aria-hidden="true">↗</span></a></footer>}
|
||||
</section>
|
||||
<p className="login-credit">Grizzlyflix · Request. Watch. Enjoy.</p>
|
||||
</main>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import PageHeading from '../ui/PageHeading'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
@@ -318,18 +320,7 @@ export default function NewRequestClient() {
|
||||
|
||||
return (
|
||||
<main className="card request-portal-page">
|
||||
<header className="request-portal-hero">
|
||||
<div>
|
||||
<span className="section-kicker">New requests</span>
|
||||
<h1>Find something worth watching.</h1>
|
||||
<p>Choose what you want, find the right title, then tailor the request before it goes to Seerr.</p>
|
||||
</div>
|
||||
<div className="request-portal-route">
|
||||
<span>Seerr</span><i aria-hidden="true" />
|
||||
<span>{mediaType === 'tv' ? 'Sonarr' : mediaType === 'movie' ? 'Radarr' : 'Collector'}</span><i aria-hidden="true" />
|
||||
<span>Grizzlyflix</span>
|
||||
</div>
|
||||
</header>
|
||||
<PageHeading title="New request" description="Find a movie or TV show and choose what you want to watch." />
|
||||
|
||||
<ol className="request-master-stepper" aria-label="New request progress">
|
||||
{['Type', 'Search', 'Select', 'Config', 'Submit'].map((label, index) => {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import Link from 'next/link'
|
||||
import PageHeading from './ui/PageHeading'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="card">
|
||||
<PageHeading title="Page not found" description="This link may have moved or no longer be available." />
|
||||
<p><Link href="/">← Back to my requests</Link></p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import PageHeading from './ui/PageHeading'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth'
|
||||
@@ -261,12 +263,7 @@ export default function HomePage() {
|
||||
|
||||
return (
|
||||
<main className="card home-page">
|
||||
<section className="home-command">
|
||||
<div className="home-command-copy">
|
||||
<span className="section-kicker">Media operations</span>
|
||||
<h1>My requests</h1>
|
||||
<p>Manage and track your media processing queue.</p>
|
||||
</div>
|
||||
<PageHeading title="My requests" description="Follow your requests from collection to ready to watch." actions={
|
||||
<form onSubmit={submit} className="home-search">
|
||||
<label htmlFor="request-search">Title, year, or request number</label>
|
||||
<div className="home-search-row">
|
||||
@@ -279,7 +276,7 @@ export default function HomePage() {
|
||||
<button type="submit">Find request</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
} />
|
||||
|
||||
{(searchError || searchResults.length > 0) && (
|
||||
<section className="home-search-results" aria-live="polite">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import PageHeading from '../ui/PageHeading'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
@@ -1463,23 +1465,11 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
|
||||
return (
|
||||
<main className={`card portal-page ${workspace === 'issue' ? 'issue-portal-page' : ''}`}>
|
||||
<div className={`user-directory-panel-header ${workspace === 'issue' ? 'issue-portal-hero' : ''}`}>
|
||||
<div>
|
||||
{workspace === 'issue' ? <span className="section-kicker">Guided support</span> : null}
|
||||
<h1>{workspace === 'request' ? 'Request portal' : 'What is going wrong?'}</h1>
|
||||
<p className="lede">
|
||||
{workspace === 'request'
|
||||
? 'Search and track content requests through the delivery pipeline.'
|
||||
: 'Choose the symptom and Magent will collect the right details, check the media server when relevant, and recommend the next action.'}
|
||||
</p>
|
||||
</div>
|
||||
{workspace === 'issue' ? (
|
||||
<div className="issue-hero-count">
|
||||
<strong>{visibleKindCount}</strong>
|
||||
<span>reported issues</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<PageHeading
|
||||
title={workspace === 'request' ? 'Request portal' : 'Issues'}
|
||||
description={workspace === 'request' ? 'Search and track your content requests.' : 'Tell us what is wrong. We’ll guide you through the fix.'}
|
||||
actions={workspace === 'issue' ? <span className="page-heading-meta">{visibleKindCount} reported {visibleKindCount === 1 ? 'issue' : 'issues'}</span> : undefined}
|
||||
/>
|
||||
|
||||
{workspace === 'request' ? (
|
||||
<section className="portal-workspace-switch">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import PageHeading from '../../ui/PageHeading'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
||||
@@ -225,14 +227,8 @@ export default function ProfileInvitesPage() {
|
||||
if (loading) return <main className="card">Loading invite workspace…</main>
|
||||
|
||||
return (
|
||||
<main className="card">
|
||||
<div className="user-directory-panel-header profile-page-header">
|
||||
<div>
|
||||
<span className="section-kicker">04 · Invites</span>
|
||||
<h1>Invite someone to Grizzlyflix</h1>
|
||||
<p className="lede">Create a secure invitation one simple decision at a time.</p>
|
||||
</div>
|
||||
</div>
|
||||
<main className="card invites-page">
|
||||
<PageHeading title="Invites" description="Invite someone to Grizzlyflix and manage the links you share." />
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{status && <div className="status-banner">{status}</div>}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import PageHeading from '../ui/PageHeading'
|
||||
|
||||
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
@@ -167,10 +169,9 @@ export default function ProfilePage() {
|
||||
|
||||
return (
|
||||
<main className="account-page">
|
||||
<header className="account-heading">
|
||||
<div><span className="account-eyebrow">YOUR ACCOUNT</span><h1>My profile</h1></div>
|
||||
{user && <div className="account-identity"><span className="account-avatar" aria-hidden="true">{user.username.slice(0, 1).toUpperCase()}</span><div><strong>{user.username}</strong><span>{user.role === 'admin' ? 'Administrator' : 'Member'}</span></div></div>}
|
||||
</header>
|
||||
<PageHeading title="My profile" description="Your contact details, security, and activity." actions={
|
||||
user && <div className="account-identity"><span className="account-avatar" aria-hidden="true">{user.username.slice(0, 1).toUpperCase()}</span><div><strong>{user.username}</strong><span>{user.role === 'admin' ? 'Administrator' : 'Member'}</span></div></div>
|
||||
} />
|
||||
|
||||
{loading ? <p className="account-empty" role="status">Loading your profile…</p> : loadError ? (
|
||||
<div className="account-empty"><p role="alert">{loadError}</p><button type="button" className="account-secondary" onClick={() => void loadProfile()}>Try again</button></div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import PageHeading from '../../ui/PageHeading'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
@@ -24,6 +26,7 @@ type PipelineStage = {
|
||||
label: string
|
||||
state: 'complete' | 'active' | 'partial' | 'attention' | 'waiting' | string
|
||||
stateLabel?: string
|
||||
searchStatus?: 'searching' | 'queued' | 'idle' | 'unavailable'
|
||||
summary: string
|
||||
available?: number
|
||||
missing?: number
|
||||
@@ -318,6 +321,11 @@ export default function RequestTimelinePage() {
|
||||
snapshot?.presentation?.repairActivity?.visible &&
|
||||
!['complete', 'attention'].includes(snapshot.presentation.repairActivity.state ?? '')
|
||||
)
|
||||
const searchIsActive = Boolean(
|
||||
snapshot?.presentation?.pipeline?.some(
|
||||
(stage) => stage.id === 'library' && ['searching', 'queued'].includes(stage.searchStatus ?? '')
|
||||
)
|
||||
)
|
||||
|
||||
const closeReleasePicker = () => {
|
||||
if (busyAction?.startsWith('grab:')) return
|
||||
@@ -402,8 +410,10 @@ export default function RequestTimelinePage() {
|
||||
useEffect(() => {
|
||||
if (!getToken() || !requestId) return
|
||||
let stopped = false
|
||||
let refreshing = false
|
||||
const refresh = async () => {
|
||||
if (document.visibilityState === 'hidden') return
|
||||
if (document.visibilityState === 'hidden' || refreshing) return
|
||||
refreshing = true
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/requests/${requestId}/snapshot`)
|
||||
if (response.status === 401) {
|
||||
@@ -416,17 +426,19 @@ export default function RequestTimelinePage() {
|
||||
if (!stopped && isSnapshotPayload(payload)) setSnapshot(payload)
|
||||
} catch (error) {
|
||||
if (!stopped) console.error(error)
|
||||
} finally {
|
||||
refreshing = false
|
||||
}
|
||||
}
|
||||
const timer = window.setInterval(
|
||||
() => void refresh(),
|
||||
awaitingMediaIndex || repairIsActive ? 5_000 : 15_000,
|
||||
awaitingMediaIndex || repairIsActive || searchIsActive ? 5_000 : 15_000,
|
||||
)
|
||||
return () => {
|
||||
stopped = true
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [awaitingMediaIndex, repairIsActive, requestId, router])
|
||||
}, [awaitingMediaIndex, repairIsActive, searchIsActive, requestId, router])
|
||||
|
||||
const liveDownloadKey = useMemo(() => {
|
||||
const downloadStage = snapshot?.presentation?.pipeline?.find((stage) => stage.id === 'download')
|
||||
@@ -502,15 +514,11 @@ export default function RequestTimelinePage() {
|
||||
if (loadError || !snapshot) {
|
||||
return (
|
||||
<main className="card request-detail-page">
|
||||
<section className="request-error-state">
|
||||
<span className="section-kicker">Request unavailable</span>
|
||||
<h1>We could not load this request</h1>
|
||||
<p>{loadError ?? 'The request API did not return a valid status.'}</p>
|
||||
<div className="request-error-actions">
|
||||
<button type="button" onClick={() => window.location.reload()}>Retry</button>
|
||||
<button type="button" className="ghost-button" onClick={() => router.push('/')}>Back to requests</button>
|
||||
</div>
|
||||
</section>
|
||||
<PageHeading title="We could not load this request" description={loadError ?? 'The request API did not return a valid status.'} />
|
||||
<div className="request-error-actions">
|
||||
<button type="button" onClick={() => window.location.reload()}>Retry</button>
|
||||
<button type="button" className="ghost-button" onClick={() => router.push('/')}>Back to requests</button>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -714,18 +722,12 @@ export default function RequestTimelinePage() {
|
||||
|
||||
return (
|
||||
<main className="card request-detail-page">
|
||||
<div className="request-header">
|
||||
<div className="request-header-main">
|
||||
{resolvedPoster && (
|
||||
<Image className="request-poster" src={resolvedPoster} alt={`${snapshot.title} poster`} width={90} height={135} sizes="90px" unoptimized />
|
||||
)}
|
||||
<div>
|
||||
<span className="section-kicker">Request #{snapshot.request_id}</span>
|
||||
<h1>{snapshot.title}</h1>
|
||||
<div className="meta">{snapshot.request_type.toUpperCase()} {snapshot.year ?? ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeading
|
||||
title={snapshot.title}
|
||||
eyebrow={`Request #${snapshot.request_id}`}
|
||||
description={[snapshot.request_type === 'tv' ? 'TV show' : 'Movie', snapshot.year].filter(Boolean).join(' · ')}
|
||||
leading={resolvedPoster && <Image className="request-poster" src={resolvedPoster} alt={`${snapshot.title} poster`} width={60} height={90} sizes="60px" unoptimized />}
|
||||
/>
|
||||
|
||||
<section className="request-overview" aria-labelledby="request-status-heading">
|
||||
<div className="request-overview-block request-overview-status">
|
||||
@@ -872,7 +874,7 @@ export default function RequestTimelinePage() {
|
||||
<span className={`request-stage-state state-${stage.state}`}>{stage.stateLabel ?? stage.state}</span>
|
||||
</div>
|
||||
<h3>{stage.label}</h3>
|
||||
<p>{stage.summary}</p>
|
||||
<p aria-live={stage.id === 'library' ? 'polite' : undefined} aria-atomic={stage.id === 'library' ? true : undefined}>{stage.summary}</p>
|
||||
|
||||
{stage.id === 'library' && Boolean(stage.total) && (
|
||||
<div className="request-availability-meter">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import BrandingLogo from '../ui/BrandingLogo'
|
||||
import AuthLayout from '../ui/AuthLayout'
|
||||
import { getApiBase } from '../lib/auth'
|
||||
|
||||
type ResetVerification = {
|
||||
@@ -100,11 +100,8 @@ function ResetPasswordPageContent() {
|
||||
verification?.auth_provider === 'jellyfin' ? 'Jellyfin, Seerr, and Magent' : 'Magent'
|
||||
|
||||
return (
|
||||
<main className="card auth-card">
|
||||
<BrandingLogo className="brand-logo brand-logo--login" />
|
||||
<h1>Reset password</h1>
|
||||
<p className="lede">Choose a new password for your account.</p>
|
||||
<form className="auth-form" onSubmit={submit}>
|
||||
<AuthLayout title="Reset password" description="Choose a new password of at least 8 characters.">
|
||||
<form className="account-form login-form auth-flow-form" onSubmit={submit}>
|
||||
{verifying && <div className="status-banner">Checking password reset link…</div>}
|
||||
{!verifying && verification && (
|
||||
<div className="status-banner">
|
||||
@@ -132,10 +129,10 @@ function ResetPasswordPageContent() {
|
||||
disabled={!verification || loading}
|
||||
/>
|
||||
</label>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{status && <div className="status-banner">{status}</div>}
|
||||
{error && <div className="account-notice is-error" role="alert">{error}</div>}
|
||||
{status && <div className="account-notice is-status" role="status">{status}</div>}
|
||||
<div className="auth-actions">
|
||||
<button type="submit" disabled={loading || verifying || !verification}>
|
||||
<button type="submit" className="account-primary" disabled={loading || verifying || !verification}>
|
||||
{loading ? 'Updating password…' : 'Reset password'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -143,13 +140,13 @@ function ResetPasswordPageContent() {
|
||||
Back to sign in
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={<main className="card auth-card">Loading password reset…</main>}>
|
||||
<Suspense fallback={<AuthLayout title="Reset password" description="Choose a new password for your account."><p role="status">Checking your reset link…</p></AuthLayout>}>
|
||||
<ResetPasswordPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Suspense, useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import BrandingLogo from '../ui/BrandingLogo'
|
||||
import AuthLayout from '../ui/AuthLayout'
|
||||
import { clearToken, getApiBase, setToken } from '../lib/auth'
|
||||
|
||||
type InviteInfo = {
|
||||
@@ -133,11 +133,8 @@ function SignupPageContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="card auth-card">
|
||||
<BrandingLogo className="brand-logo brand-logo--login" />
|
||||
<h1>Create account</h1>
|
||||
<p className="lede">Use an invite code from your admin to create your Jellyfin-backed Magent account.</p>
|
||||
<form onSubmit={submit} className="auth-form">
|
||||
<AuthLayout title="Create account" description="Your invite is the first step to Grizzlyflix.">
|
||||
<form onSubmit={submit} className="account-form login-form auth-flow-form">
|
||||
<label>
|
||||
Invite code
|
||||
<div className="invite-lookup-row">
|
||||
@@ -162,16 +159,16 @@ function SignupPageContent() {
|
||||
<div className="invite-summary-row">
|
||||
<strong>{invite.label || invite.code}</strong>
|
||||
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
|
||||
{invite.is_usable ? 'Usable' : 'Unavailable'}
|
||||
{invite.is_usable ? 'Ready' : 'Unavailable'}
|
||||
</span>
|
||||
</div>
|
||||
{invite.description && <p>{invite.description}</p>}
|
||||
<div className="admin-meta-row">
|
||||
<details className="auth-invite-details"><summary>Invite details</summary><div className="admin-meta-row">
|
||||
<span>Code: {invite.code}</span>
|
||||
<span>Expires: {formatDate(invite.expires_at)}</span>
|
||||
<span>Remaining uses: {invite.remaining_uses ?? 'Unlimited'}</span>
|
||||
<span>Profile: {invite.profile?.name || 'None'}</span>
|
||||
</div>
|
||||
</div></details>
|
||||
</div>
|
||||
)}
|
||||
<label>
|
||||
@@ -200,24 +197,24 @@ function SignupPageContent() {
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{status && <div className="status-banner">{status}</div>}
|
||||
{error && <div className="account-notice is-error" role="alert">{error}</div>}
|
||||
{status && <div className="account-notice is-status" role="status">{status}</div>}
|
||||
<div className="auth-actions">
|
||||
<button type="submit" disabled={!canSubmit}>
|
||||
{loading ? 'Creating account…' : 'Create account (Jellyfin + Magent)'}
|
||||
<button type="submit" className="account-primary" disabled={!canSubmit}>
|
||||
{loading ? 'Creating account…' : 'Create account'}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="ghost-button" disabled={loading} onClick={() => router.push('/login')}>
|
||||
Back to sign in
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SignupPage() {
|
||||
return (
|
||||
<Suspense fallback={<main className="card auth-card">Loading sign-up…</main>}>
|
||||
<Suspense fallback={<AuthLayout title="Create account" description="Your invite is the first step to Grizzlyflix."><p role="status">Loading sign-up…</p></AuthLayout>}>
|
||||
<SignupPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import SettingsNavigation from './SettingsNavigation'
|
||||
import PageHeading from './PageHeading'
|
||||
|
||||
type AdminShellProps = {
|
||||
title: string
|
||||
@@ -16,14 +17,7 @@ export default function AdminShell({ title, subtitle, actions, rail, children }:
|
||||
<div className="admin-shell admin-shell--top-nav">
|
||||
<SettingsNavigation />
|
||||
<main className="card admin-card">
|
||||
<div className="admin-header">
|
||||
<div>
|
||||
<span className="section-kicker">Configuration</span>
|
||||
<h1>{title}</h1>
|
||||
{subtitle && <p className="lede">{subtitle}</p>}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
<PageHeading title={title} description={subtitle} actions={actions} />
|
||||
{children}
|
||||
{rail && <details className="admin-supplemental"><summary>Additional information</summary>{rail}</details>}
|
||||
</main>
|
||||
|
||||
@@ -10,7 +10,7 @@ import WorkspaceNavigation from './WorkspaceNavigation'
|
||||
|
||||
export default function ApplicationChrome() {
|
||||
const pathname = usePathname()
|
||||
if (pathname === '/login') return null
|
||||
if (['/login', '/forgot-password', '/reset-password', '/signup'].includes(pathname)) return null
|
||||
return <>
|
||||
<header className="header">
|
||||
<div className="header-left"><a className="brand-link" href="/"><BrandingLogo className="brand-logo brand-logo--header" /><div className="brand-stack"><div className="brand">Magent</div><div className="tagline">GrizzlyFlix media operations</div></div></a></div>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import MagentMark from './MagentMark'
|
||||
|
||||
export default function AuthLayout({ title, description, children, footer }: {
|
||||
title: string
|
||||
description: string
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<main className="login-page">
|
||||
<section className="login-card" aria-labelledby="login-title">
|
||||
<div className="login-brand"><a href="/login" aria-label="Magent sign in"><MagentMark /><span>Magent</span></a><span className="login-beta">Beta</span></div>
|
||||
<header><h1 id="login-title">{title}</h1><p>{description}</p></header>
|
||||
{children}
|
||||
{footer && <footer>{footer}</footer>}
|
||||
</section>
|
||||
<p className="login-credit">Grizzlyflix · Request. Watch. Enjoy.</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type PageHeadingProps = {
|
||||
title: string
|
||||
description?: string
|
||||
eyebrow?: string
|
||||
leading?: ReactNode
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
/** A flat, shared page title. Panels belong to the content below it. */
|
||||
export default function PageHeading({ title, description, eyebrow, leading, actions }: PageHeadingProps) {
|
||||
return (
|
||||
<header className="page-heading">
|
||||
<div className="page-heading-main">
|
||||
{leading && <div className="page-heading-leading">{leading}</div>}
|
||||
<div className="page-heading-copy">
|
||||
{eyebrow && <span className="page-heading-eyebrow">{eyebrow}</span>}
|
||||
<h1>{title}</h1>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="page-heading-actions">{actions}</div>}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/* Shared page rhythm. Feature styles own their content, not the outer shell. */
|
||||
:root {
|
||||
--workspace-width: 1440px;
|
||||
--workspace-gutter: 32px;
|
||||
--workspace-gap: 24px;
|
||||
--ops-radius: 8px;
|
||||
--ops-radius-lg: 12px;
|
||||
}
|
||||
|
||||
.page > main:not(.login-page),
|
||||
.admin-shell.admin-shell--top-nav {
|
||||
width: calc(100% - var(--workspace-gutter) * 2);
|
||||
max-width: var(--workspace-width) !important;
|
||||
margin: 32px auto 0;
|
||||
padding: 0;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
animation: none;
|
||||
}
|
||||
.page > main:not(.login-page) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--workspace-gap);
|
||||
align-content: start;
|
||||
}
|
||||
.admin-shell.admin-shell--top-nav { display: block; }
|
||||
.admin-shell--top-nav > .admin-card { grid-template-columns: minmax(0, 1fr); gap: var(--workspace-gap); border-radius: 0 !important; animation: none; }
|
||||
.admin-card > * { min-width: 0; }
|
||||
.admin-card .admin-section, .invite-admin-stack { grid-template-columns: minmax(0, 1fr); min-width: 0; }
|
||||
.admin-section > *, .invite-admin-stack > * { min-width: 0; }
|
||||
.page > .site-banner, .page > .user-view-banner {
|
||||
width: calc(100% - var(--workspace-gutter) * 2);
|
||||
max-width: var(--workspace-width);
|
||||
margin: 16px auto 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.header { border-radius: 0 !important; }
|
||||
|
||||
/* A single, calm heading treatment across every workspace page. */
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0 0 24px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--ops-line-soft);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.page-heading-main { display: flex; align-items: center; gap: 18px; min-width: 0; }
|
||||
.page-heading-copy { display: grid; gap: 8px; min-width: 0; }
|
||||
.page-heading h1 {
|
||||
margin: 0;
|
||||
color: var(--ops-text);
|
||||
font: 700 clamp(26px, 2.5vw, 32px)/1.2 "DM Sans", "Segoe UI", sans-serif;
|
||||
text-transform: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.page-heading-copy > p { margin: 0; max-width: 64ch; color: var(--ops-muted); font-size: 14px; line-height: 1.6; }
|
||||
.page-heading-eyebrow { color: var(--ops-faint); font: 11px "JetBrains Mono", monospace; }
|
||||
.page-heading-leading { flex-shrink: 0; }
|
||||
.page-heading-leading .request-poster { display: block; width: 60px; height: 90px; margin: 0; border-radius: 8px; object-fit: cover; }
|
||||
.page-heading-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 10px; min-width: 0; flex-shrink: 0; max-width: 50%; }
|
||||
.page-heading-actions .lede { margin: 0; }
|
||||
.page-heading { grid-column: 1 / -1; }
|
||||
.page > main.issue-portal-page { grid-template-columns: minmax(0, 1fr) minmax(310px, 360px); }
|
||||
.issue-reports-column { top: 80px; height: calc(100dvh - 104px); }
|
||||
.page-heading-meta { color: var(--ops-faint); font-size: 13px; }
|
||||
.page-heading .home-search { width: min(440px, 100%); }
|
||||
.page-heading .home-search > label { font: 500 12px "DM Sans", sans-serif; text-transform: none; }
|
||||
.page-heading .home-search-row { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
|
||||
/* Keep small forms readable without moving their page title off the shared grid. */
|
||||
.account-page > .account-tabs, .account-page > .account-panel { width: 100%; max-width: 920px; margin: 0; }
|
||||
.feedback-form { width: 100%; max-width: 760px; }
|
||||
.feedback-form label:not(:first-child) { margin-top: 12px; }
|
||||
.feedback-form :is(select, textarea) { width: 100%; min-width: 0; padding: 12px; border-radius: 8px; font-size: 14px; }
|
||||
.feedback-form button[type=submit] { justify-self: start; margin-top: 12px; }
|
||||
.how-page > .how-flow, .changelog-page > .changelog-groups { width: 100%; max-width: 1120px; }
|
||||
.how-flow > h2 { margin: 0 0 16px; font-size: 20px; }
|
||||
.how-card { background: var(--ops-panel); border-color: var(--ops-line); border-radius: 12px; box-shadow: none; }
|
||||
.how-card p { margin: 0; color: var(--ops-muted); line-height: 1.7; }
|
||||
.changelog-group { padding: 24px; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); }
|
||||
.changelog-group:first-child { padding-top: 24px; border-top: 1px solid var(--ops-line); }
|
||||
.changelog-group h2 { font-size: 18px; }
|
||||
.changelog-list { margin-bottom: 0; color: var(--ops-muted); line-height: 1.7; }
|
||||
|
||||
/* Shared hierarchy: readable form labels, restrained section titles and corners. */
|
||||
:is(.request-flow-heading, .issue-flow-heading, .invite-flow-heading, .home-section-heading, .request-journey-heading) h2 { font-size: 21px; line-height: 1.3; }
|
||||
:is(.request-flow-stage, .issue-flow, .issue-reports-column, .invite-flow-step, .profile-invites-list, .account-panel) { border-radius: 12px; }
|
||||
.invites-page > .profile-invites-section { margin: 0; padding: 0; border: 0; background: transparent !important; }
|
||||
.invites-page .invite-flow-heading > div > .eyebrow { display: none; }
|
||||
.invites-page .profile-invites-list { margin-top: 12px; padding: 20px; border: 1px solid var(--ops-line); }
|
||||
.invite-flow-fields label > span:first-child, .invite-flow-field-grid label > span:first-child { font-size: 13px; text-transform: none; }
|
||||
.invite-admin-tabbar .admin-segmented { flex-wrap: wrap; width: auto; max-width: 100%; }
|
||||
.invite-admin-tabbar .admin-segmented { padding: 0; gap: 4px 18px; border: 0; border-bottom: 1px solid var(--ops-line-soft); border-radius: 0; background: transparent; }
|
||||
.invite-admin-tabbar .admin-segmented button { min-height: 44px; padding: 10px 0; border: 0; border-bottom: 2px solid transparent; border-radius: 0 !important; background: transparent !important; color: var(--ops-muted); }
|
||||
.invite-admin-tabbar .admin-segmented button[aria-selected=true] { border-bottom-color: #c7bdff; color: #dedaff; }
|
||||
.invite-operations-strip > button { justify-content: stretch; justify-items: start; border-color: var(--ops-line) !important; background: var(--ops-panel) !important; }
|
||||
.invite-operations-strip > button:hover { border-color: var(--ops-primary-2) !important; }
|
||||
.request-stage-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.request-stage-grid > .request-stage { grid-column: auto; }
|
||||
:is(.request-flow-stage, .issue-flow, .profile-invites-section, .admin-card) label:not(.setting-switch) {
|
||||
font-family: "DM Sans", "Segoe UI", sans-serif;
|
||||
font-size: 13px;
|
||||
text-transform: none;
|
||||
}
|
||||
:is(.request-flow-stage, .issue-flow, .profile-invites-section, .admin-card) label > span { text-transform: none; }
|
||||
:is(.request-flow-stage, .issue-flow, .profile-invites-section) :is(input:not([type=checkbox]):not([type=radio]), select, textarea) { border-radius: 8px; font: 14px/1.5 "DM Sans", sans-serif; }
|
||||
:is(.request-flow-stage, .issue-flow, .profile-invites-section) input:not([type=checkbox]):not([type=radio]),
|
||||
:is(.request-flow-stage, .issue-flow, .profile-invites-section) select { min-height: 44px; }
|
||||
.auth-flow-form > label { display: grid; gap: 8px; }
|
||||
.auth-flow-form > label:not(:first-child) { margin-top: 10px; }
|
||||
.auth-flow-form .auth-actions { display: grid; margin-top: 12px; }
|
||||
.auth-flow-form .invite-lookup-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
|
||||
.auth-flow-form .invite-lookup-row button { font-size: 12px; padding: 10px; }
|
||||
.auth-flow-form .invite-summary { background: var(--ops-panel-2); padding: 14px; border: 1px solid var(--ops-line); border-radius: 8px; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.auth-invite-details { font-size: 12px; color: var(--ops-muted); margin-top: 10px; }
|
||||
.auth-invite-details summary { cursor: pointer; }
|
||||
.auth-invite-details .admin-meta-row { margin-top: 10px; }
|
||||
.page main button[type=submit]:not(.ghost-button),
|
||||
.page .request-submit-bar > button,
|
||||
.page .issue-resolution-card > button {
|
||||
min-height: 42px;
|
||||
padding: 11px 18px;
|
||||
border-color: #c7bdff !important;
|
||||
border-radius: 8px;
|
||||
background: #c7bdff !important;
|
||||
color: #1c172c !important;
|
||||
font: 700 13px "DM Sans", sans-serif;
|
||||
text-transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.page main button[type=submit]:disabled { opacity: .4; }
|
||||
.page button.danger-button { border-color: #86464f !important; background: #361f25 !important; color: #ffc0c5 !important; }
|
||||
.page :is(button, input, select, textarea) { font-family: "DM Sans", "Segoe UI", sans-serif; }
|
||||
.page :focus-visible { outline: 2px solid #c7bdff; outline-offset: 3px; }
|
||||
.login-card { border-radius: 12px; }
|
||||
.login-card header p { line-height: 1.6; }
|
||||
.login-card .status-banner { font-size: 13px; line-height: 1.6; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.page > main.issue-portal-page { grid-template-columns: minmax(0, 1fr); }
|
||||
.issue-reports-column { height: auto; }
|
||||
.request-stage-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.page-heading { flex-wrap: wrap; align-items: flex-start; gap: 18px; }
|
||||
.page-heading-actions { justify-content: flex-start; max-width: 100%; }
|
||||
.home-page .page-heading-actions { width: 100%; }
|
||||
.page-heading .home-search { width: 100%; }
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
:root { --workspace-gutter: 16px; --workspace-gap: 20px; }
|
||||
.page > main:not(.login-page), .admin-shell.admin-shell--top-nav { margin-top: 24px; }
|
||||
.page-heading { padding-bottom: 20px; }
|
||||
.page-heading-copy > p { font-size: 13px; }
|
||||
.page-heading-main { gap: 14px; }
|
||||
.page-heading-leading .request-poster { width: 48px; height: 72px; }
|
||||
.page-heading-actions > .admin-inline-actions { flex-wrap: wrap; }
|
||||
.changelog-group { padding: 20px; }
|
||||
.request-stage-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.invite-operations-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page *, .page *::before, .page *::after { animation: none !important; scroll-behavior: auto !important; }
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Read-only visual contract review. Live writes are blocked; auth forms use fixtures.
|
||||
// REVIEW_SESSION is a short-lived {name, token} JSON value supplied in memory.
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
|
||||
const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3101'
|
||||
const liveBase = process.env.REVIEW_LIVE_BASE || base
|
||||
const output = process.env.REVIEW_DIR
|
||||
const session = JSON.parse(process.env.REVIEW_SESSION)
|
||||
|
||||
;(async () => {
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
const context = await browser.newContext()
|
||||
const errors = []
|
||||
const reports = []
|
||||
const blocked = []
|
||||
await context.addCookies([
|
||||
{ name: session.name, value: session.token, url: base, httpOnly: true },
|
||||
{ name: 'magent_logged_in', value: '1', url: base },
|
||||
])
|
||||
await context.route('**/api/**', async (route) => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
if (url.pathname.includes('/events/stream')) return route.fulfill({ status: 200, contentType: 'text/event-stream', body: ': review\n\n' })
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) {
|
||||
blocked.push(url.pathname)
|
||||
return route.fulfill({ status: 409, json: { detail: 'Read-only UI review' } })
|
||||
}
|
||||
try {
|
||||
const response = await route.fetch({ url: liveBase + url.pathname + url.search, headers: { ...request.headers(), cookie: session.name + '=' + session.token } })
|
||||
await route.fulfill({ response })
|
||||
} catch { await route.abort().catch(() => {}) }
|
||||
})
|
||||
const page = await context.newPage()
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
const inspect = async (path, width) => {
|
||||
await page.setViewportSize({ width, height: 1000 })
|
||||
await page.goto(base + path, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('main .page-heading h1').waitFor({ timeout: 30000 })
|
||||
await page.waitForTimeout(600)
|
||||
const result = await page.evaluate(() => {
|
||||
const heading = document.querySelector('.page-heading')
|
||||
const style = getComputedStyle(heading)
|
||||
const main = document.querySelector('main').getBoundingClientRect()
|
||||
return { title: heading.querySelector('h1').textContent, headings: document.querySelectorAll('main h1').length,
|
||||
font: getComputedStyle(heading.querySelector('h1')).fontSize, left: main.left, right: innerWidth - main.right,
|
||||
overflow: document.documentElement.scrollWidth - innerWidth, radius: style.borderRadius, background: style.backgroundColor,
|
||||
headerWidth: heading.getBoundingClientRect().width, mainWidth: main.width }
|
||||
})
|
||||
reports.push({ path, width, ...result })
|
||||
if (output) await page.screenshot({ path: output + '/layout-' + width + path.replaceAll('/', '-') + '.png', fullPage: true, animations: 'disabled' })
|
||||
if (result.overflow) console.log(await page.evaluate(() => [...document.querySelectorAll('main *')].filter((el) => el.getBoundingClientRect().right > innerWidth).slice(0, 15).map((el) => ({ tag: el.tagName, class: el.className, width: el.getBoundingClientRect().width }))))
|
||||
assert.equal(result.headings, 1, `${path}: one page title`)
|
||||
assert.equal(result.overflow, 0, `${path}: overflow at ${width}px`)
|
||||
assert.equal(result.left, width <= 680 ? 16 : 32, `${path}: left alignment at ${width}px`)
|
||||
assert.equal(result.right, result.left, `${path}: symmetric gutters`)
|
||||
assert.equal(result.headerWidth, result.mainWidth, `${path}: heading spans the content width`)
|
||||
assert.equal(result.radius, '0px', `${path}: flat page header`)
|
||||
assert.equal(result.background, 'rgba(0, 0, 0, 0)', `${path}: no header panel`)
|
||||
assert.equal(result.font, width === 1440 ? '32px' : '26px', `${path}: shared title scale`)
|
||||
console.log(`PASS ${width}px ${path}`)
|
||||
}
|
||||
// Resolve real records through existing navigation; do not invent or create test records.
|
||||
await page.goto(base)
|
||||
const recent = page.locator('.recent-card').first()
|
||||
await recent.waitFor({ timeout: 30000 })
|
||||
await recent.click()
|
||||
await page.waitForURL('**/requests/*')
|
||||
const requestPath = new URL(page.url()).pathname
|
||||
await page.goto(base + '/users')
|
||||
const user = page.locator('a[href^="/users/"]').first()
|
||||
await user.waitFor({ timeout: 30000 })
|
||||
const userPath = await user.getAttribute('href')
|
||||
const paths = ['/', '/new-requests', '/portal/issues', '/profile/invites', '/profile', requestPath,
|
||||
'/admin', '/admin/seerr', '/admin/jellyfin', '/admin/sonarr', '/admin/radarr', '/admin/bazarr', '/admin/prowlarr',
|
||||
'/admin/qbittorrent', '/admin/site', '/admin/notifications', '/admin/issue-workflow', '/admin/requests',
|
||||
'/admin/general', '/admin/cache', '/admin/artwork', '/admin/logs', '/admin/maintenance', '/admin/invites',
|
||||
'/admin/diagnostics', '/admin/system', '/admin/requests-all', '/users', userPath, '/how-it-works', '/changelog', '/feedback']
|
||||
for (const width of [1440, 390]) for (const path of process.env.REVIEW_PATHS?.split(',') || paths) await inspect(path, width)
|
||||
// Redirects must land on the same styled workspaces.
|
||||
for (const path of ['/portal', '/portal/requests', '/admin/profiles', '/admin/issues']) await inspect(path, 390)
|
||||
for (const width of [1440, 390]) {
|
||||
await page.setViewportSize({ width, height: 1000 })
|
||||
await page.goto(base + '/admin/invites')
|
||||
for (const tab of ['Invite links', 'Profiles', 'Automation', 'Delivery', 'Lineage']) {
|
||||
await page.getByRole('tab', { name: tab, exact: true }).click()
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth), 0, `Invite ${tab} at ${width}px`)
|
||||
}
|
||||
await page.goto(base + requestPath)
|
||||
await page.locator('.request-stage').first().waitFor()
|
||||
const columns = await page.locator('.request-stage').evaluateAll((stages) => new Set(stages.map((el) => Math.round(el.getBoundingClientRect().left))).size)
|
||||
assert.equal(columns, width === 1440 ? 3 : 1, `Pipeline columns at ${width}px`)
|
||||
}
|
||||
await inspect('/layout-review-page-not-found', 390)
|
||||
await context.unrouteAll({ behavior: 'ignoreErrors' })
|
||||
// Authentication layout and form contracts are isolated from all live services.
|
||||
const calls = []
|
||||
await context.route('**/api/**', (route) => {
|
||||
const req = route.request()
|
||||
const path = new URL(req.url()).pathname
|
||||
if (req.method() !== 'GET') calls.push({ path, body: req.postDataJSON() })
|
||||
const reply = (json) => route.fulfill({ status: 200, json })
|
||||
if (path.endsWith('/reset/verify')) return reply({ status: 'valid', recipient_hint: 'm***@example.com', auth_provider: 'jellyfin' })
|
||||
if (path.includes('/auth/invites/')) return reply({ invite: { code: 'review-only', label: 'Welcome', is_usable: true, enabled: true, description: 'Join Grizzlyflix.' } })
|
||||
if (path.endsWith('/password/forgot')) return reply({ message: 'If your account is eligible, a reset link has been sent.' })
|
||||
return reply({})
|
||||
})
|
||||
for (const width of [1440, 390, 320]) {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
for (const path of ['/forgot-password', '/reset-password', '/reset-password?token=review-only', '/signup', '/signup?code=review-only']) {
|
||||
await page.goto(base + path)
|
||||
await page.locator('.login-card').waitFor()
|
||||
assert.equal(await page.locator('.header').count(), 0)
|
||||
assert.equal(await page.locator('.brand-logo--login').count(), 0)
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth), 0, `Auth overflow: ${path} ${width}`)
|
||||
if (output) await page.screenshot({ path: output + '/layout-auth-' + width + path.split('?')[0].replaceAll('/', '-') + (path.includes('?') ? '-valid' : '') + '.png', fullPage: true })
|
||||
}
|
||||
}
|
||||
await page.goto(base + '/forgot-password')
|
||||
await page.getByLabel('Username or email').fill('member@example.com')
|
||||
await page.getByRole('button', { name: 'Send reset link', exact: true }).click()
|
||||
await page.getByRole('status').filter({ hasText: 'reset link has been sent' }).waitFor()
|
||||
assert.equal(calls.at(-1).body.identifier, 'member@example.com')
|
||||
await page.goto(base + '/reset-password?token=review-only')
|
||||
await page.getByLabel('New password', { exact: true }).fill('review-password')
|
||||
await page.getByLabel('Confirm new password', { exact: true }).fill('does-not-match')
|
||||
await page.getByRole('button', { name: 'Reset password', exact: true }).click()
|
||||
await page.getByRole('alert').filter({ hasText: 'Passwords do not match' }).waitFor()
|
||||
assert(!calls.some((call) => call.path.endsWith('/password/reset')))
|
||||
assert.deepEqual(errors, [])
|
||||
if (output) fs.writeFileSync(output + '/layout-report.json', JSON.stringify({ reports, blocked, errors }, null, 2))
|
||||
await context.unrouteAll({ behavior: 'ignoreErrors' })
|
||||
await browser.close()
|
||||
console.log(`PASS: ${reports.length} route/viewport checks; recovery forms tested with fixtures only`)
|
||||
})().catch((error) => { console.error(error); process.exit(1) })
|
||||
@@ -0,0 +1,101 @@
|
||||
// Isolated UI checks: all API responses are fixtures. No searches or downloads run.
|
||||
const assert = require('node:assert/strict')
|
||||
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
|
||||
const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3101'
|
||||
const output = process.env.REVIEW_DIR
|
||||
|
||||
const states = {
|
||||
queued: ['active', 'Search queued', 'Search queued — waiting for Radarr to start.'],
|
||||
searching: ['active', 'Searching', 'Radarr is searching for a matching release.'],
|
||||
idle: ['waiting', 'Not searching', 'Not currently searching for this movie.'],
|
||||
unavailable: ['attention', 'Search unknown', 'Search status unavailable — unable to check Radarr.'],
|
||||
complete: ['complete', 'complete', 'Collection complete — no search needed'],
|
||||
}
|
||||
const fixture = (mode, partial = false) => {
|
||||
const [state, stateLabel, summary] = states[mode]
|
||||
const library = {
|
||||
id: 'library', label: 'Library collection', state: partial ? 'partial' : state, stateLabel,
|
||||
searchStatus: mode === 'complete' ? 'idle' : mode,
|
||||
summary: partial ? '22 of 24 episodes collected. ' + summary : summary,
|
||||
total: partial ? 24 : 1, available: partial ? 22 : mode === 'complete' ? 1 : 0, missing: partial ? 2 : mode === 'complete' ? 0 : 1,
|
||||
}
|
||||
return {
|
||||
request_id: '12', title: 'Search status review', request_type: partial ? 'tv' : 'movie',
|
||||
state: mode === 'complete' ? 'COMPLETED' : 'ADDED_TO_ARR', timeline: [], actions: [],
|
||||
presentation: {
|
||||
status: { label: 'In the library queue', meaning: 'Tracking collection.' },
|
||||
download: { visible: false },
|
||||
nextStep: { title: 'Tracking your request', description: 'The status updates automatically.', actionIds: [] },
|
||||
pipeline: [
|
||||
{ id: 'requested', label: 'Requested', state: 'complete', summary: 'Request received' },
|
||||
{ id: 'approved', label: 'Approved', state: 'complete', summary: 'Approved for collection' },
|
||||
library,
|
||||
{ id: 'search', label: 'Release search', state, summary },
|
||||
{ id: 'download', label: 'Download', state: 'waiting', summary: 'No download attempt yet' },
|
||||
{ id: 'available', label: 'Media server', state: 'waiting', summary: 'Not yet available' },
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
try {
|
||||
const context = await browser.newContext()
|
||||
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }])
|
||||
let mode = 'queued'
|
||||
let partial = false
|
||||
let polls = 0
|
||||
const mutations = []
|
||||
const errors = []
|
||||
await context.route('**/api/**', (route) => {
|
||||
const request = route.request()
|
||||
const path = new URL(request.url()).pathname
|
||||
if (request.method() !== 'GET') mutations.push(path)
|
||||
const reply = (json) => route.fulfill({ json })
|
||||
if (path === '/api/auth/me') return reply({ username: 'Review member', role: 'user' })
|
||||
if (path.endsWith('/snapshot')) { polls++; return reply(fixture(mode, partial)) }
|
||||
if (path.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' })
|
||||
if (path.includes('/branding/')) return route.fulfill({ status: 404 })
|
||||
return reply({ navigation: { showRequests: true }, requests: [], services: [] })
|
||||
})
|
||||
const page = await context.newPage()
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
const card = page.locator('.request-stage').filter({ has: page.getByRole('heading', { name: 'Library collection', exact: true }) })
|
||||
const verify = async () => {
|
||||
await card.getByText(states[mode][1], { exact: true }).waitFor()
|
||||
assert.equal(await card.getByRole('progressbar').getAttribute('aria-valuenow'), partial ? '22' : mode === 'complete' ? '1' : '0')
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth), 0)
|
||||
await card.evaluate((element) => element.scrollIntoView({ block: 'center', behavior: 'instant' }))
|
||||
if (output) await card.screenshot({ path: output + `/search-${page.viewportSize().width}-${partial ? 'partial-' : ''}${mode}.png` })
|
||||
}
|
||||
for (const width of [1440, 390]) {
|
||||
await page.setViewportSize({ width, height: 1000 })
|
||||
for (mode of Object.keys(states)) {
|
||||
await page.goto(base + '/requests/12')
|
||||
await verify()
|
||||
}
|
||||
mode = 'idle'; partial = true
|
||||
await page.goto(base + '/requests/12')
|
||||
await verify()
|
||||
partial = false
|
||||
}
|
||||
// Confirm queued -> searching -> idle updates without a click or page reload.
|
||||
mode = 'queued'
|
||||
await page.goto(base + '/requests/12')
|
||||
await verify()
|
||||
const initialPolls = polls
|
||||
mode = 'searching'
|
||||
await card.getByText('Searching', { exact: true }).waitFor({ timeout: 8000 })
|
||||
await verify()
|
||||
mode = 'idle'
|
||||
await card.getByText('Not searching', { exact: true }).waitFor({ timeout: 8000 })
|
||||
await verify()
|
||||
assert.ok(polls >= initialPolls + 2, 'Active search must refresh automatically')
|
||||
assert.deepEqual(errors, [])
|
||||
assert.deepEqual(mutations, [])
|
||||
console.log('PASS: desktop/mobile search cards, partial collection, and queued → searching → idle automatic updates; no API mutations.')
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1 })
|
||||
Reference in New Issue
Block a user