Compare commits

...
2 Commits
Author SHA1 Message Date
Assclaw 1851fa9753 Show actual collector search activity in request pipeline
Magent CI/CD / verify (push) Successful in 10m55s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 2m4s
2026-09-06 21:14:42 +12:00
Assclaw dec1dd902c Unify page layouts, compact headers and shared UI styling 2026-09-06 19:52:39 +12:00
32 changed files with 866 additions and 207 deletions
+2
View File
@@ -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"
+61
View File
@@ -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)
+50 -24
View File
@@ -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))
+158
View File
@@ -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)
+20
View File
@@ -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.
-9
View File
@@ -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; }
+2 -2
View File
@@ -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">
-5
View File
@@ -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">
+2 -7
View File
@@ -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">
+6 -9
View File
@@ -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="Whats new and improved in Magent." />
{content}
</main>
)
}
+5 -9
View File
@@ -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 />
+7 -13
View File
@@ -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>
)
}
+3 -4
View File
@@ -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 {
+3 -8
View File
@@ -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>
+1
View File
@@ -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'
+5 -9
View File
@@ -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>
)
}
+3 -12
View File
@@ -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) => {
+11
View File
@@ -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>
)
}
+4 -7
View File
@@ -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">
+7 -17
View File
@@ -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. Well 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">
+4 -8
View File
@@ -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>}
+5 -4
View File
@@ -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>
+27 -25
View File
@@ -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">
+8 -11
View File
@@ -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>
)
+12 -15
View File
@@ -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 -8
View File
@@ -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>
+1 -1
View File
@@ -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>
+21
View File
@@ -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>
)
}
+26
View File
@@ -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>
)
}
+174
View File
@@ -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; }
}
+135
View File
@@ -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) })
+101
View File
@@ -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 })