feat(jellyseer): stat widgets + rich Requests tab (slice 3/3)

Frontend for Jellyseerr request stats, reusing the generic stat abstraction.

- api/jellyseerr.ts + hooks/useJellyseer.ts: fetchJellyseerrStats +
  useJellyseerrStats (polls /api/jellyseerr/stats, no-retry; shares the backend
  cache with the widgets).
- Two reusable widgets backed by the stat/stats_overview kinds:
  - RequestStatWidget: a single selected stat (big value + label).
  - RequestsOverviewWidget: a MetricCard grid of all stats + a recent-requests
    list with status/media-status badges.
- registry.ts: Jellyfin gains `stat` (a dropdown over
  total/pending/approved/declined/processing/available — the "extract one stat
  into a widget" affordance, rendered as a Select via the existing enum UI) and
  `stats_overview` widget kinds, wired to the new components.
- RequestsTab rewritten: live stats grid (6 counts) + recent-requests list +
  a hint to pin individual stats via the Request stat widget. Reads
  jellyseerr_url from config and the now-secret jellyseerr_api_key from
  secrets_set.

Tests: RequestStatWidget + RequestsOverviewWidget rendering/error; RequestsTab
not-configured CTA, configured stats grid, and error states. 184/184 frontend
tests pass; tsc + ESLint clean.
This commit is contained in:
Developer
2026-07-12 13:59:08 +00:00
parent e25240c2f3
commit 0b039529f6
17 changed files with 444 additions and 70 deletions
+70 -21
View File
@@ -1,38 +1,42 @@
/**
* RequestsTab — Jellyseerr request-management surface on the Jellyfin page.
* RequestsTab — Jellyseerr request stats surface on the Jellyfin page.
*
* Jellyseerr was absorbed into Jellyfin config (jellyseerr_url +
* jellyseerr_api_key) in Slice 1. This tab reads those config fields. When
* configured, it shows the URL and a placeholder (no requests backend endpoint
* exists yet — building one is out of scope for this slice). When not
* configured, it shows an empty-state CTA directing the user to add the fields
* to the Jellyfin config.
* Reads the Jellyfin service's jellyseerr_url (config) + jellyseerr_api_key
* (secret). When configured, polls /api/jellyseerr/stats and renders the
* request-count grid + a recent-requests list. Individual stats can be pinned
* to dashboards via the "Request stat" widget.
*/
import type { ServiceInstance } from "../../types";
import { useJellyseerrStats } from "../../hooks/useJellyseer";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { MetricCard } from "../../components/MetricCard";
import { ExternalLink } from "lucide-react";
export function RequestsTab({ instance }: { instance: ServiceInstance }) {
const jellyseerrUrl = String(
(instance.config as Record<string, unknown>).jellyseerr_url ?? "",
).trim();
const jellyseerrApiKey = String(
(instance.config as Record<string, unknown>).jellyseerr_api_key ?? "",
).trim();
const jellyseerrApiKeySet = Boolean(
(instance.secrets_set as Record<string, boolean> | undefined)
?.jellyseerr_api_key,
);
const { data, isLoading, error } = useJellyseerrStats(instance.id);
if (!jellyseerrUrl || !jellyseerrApiKey) {
if (!jellyseerrUrl || !jellyseerrApiKeySet) {
return (
<Alert>
<AlertDescription>
Jellyseerr is not configured for this Jellyfin instance. Add
Jellyseerr is not configured for this Jellyfin instance. Add a
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">
jellyseerr_url
</code>
and
config field and a
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">
jellyseerr_api_key
</code>
to the Jellyfin config (Config tab) to enable request management.
secret to the Jellyfin service to enable request management.
</AlertDescription>
</Alert>
);
@@ -52,13 +56,58 @@ export function RequestsTab({ instance }: { instance: ServiceInstance }) {
<ExternalLink className="size-3.5" />
</a>
</div>
<Alert>
<AlertDescription>
Jellyseerr is configured. The requests view will show pending and
recently fulfilled media requests. (This surface is under
development.)
</AlertDescription>
</Alert>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error.message}</AlertDescription>
</Alert>
) : isLoading ? (
<Skeleton className="h-32 w-full" />
) : data?.detail && !(data.stats && data.stats.length > 0) ? (
<Alert>
<AlertDescription>{data.detail}</AlertDescription>
</Alert>
) : (
<>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
{(data?.stats ?? []).map((s) => (
<MetricCard key={s.key} label={s.label} value={String(s.value)} />
))}
</div>
{data?.recent && data.recent.length > 0 ? (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Recent requests
</span>
{data.recent.slice(0, 12).map((r, i) => (
<div
key={String(r.id ?? i)}
className="flex items-center justify-between gap-2 rounded border p-2 text-sm"
>
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium">{r.name ?? "—"}</span>
<span className="text-xs text-muted-foreground">
{r.type ? String(r.type) : ""}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
{r.media_status ? (
<Badge variant="outline">{r.media_status}</Badge>
) : null}
{r.status ? <Badge variant="secondary">{r.status}</Badge> : null}
</div>
</div>
))}
</div>
) : null}
<p className="text-xs text-muted-foreground">
Pin individual stats to a dashboard with the &ldquo;Request stat&rdquo;
widget.
</p>
</>
)}
</div>
);
}