45c295457a
The requests table showed all names as "—" because Jellyseerr's /api/v1/request
list does NOT embed titles — they live on the Movie/Series records. Added
JellyseerrClient._resolve_title(media_type, tmdb_id) that fetches
/api/v1/movie/{tmdbId} (→ title) or /api/v1/tv/{tmdbId} (→ name), cached on
the client instance so subsequent polls are instant.
Also scoped the table fetch to open requests only (pending + approved) via
Jellyseerr's filter param, instead of fetching all 800+ historical requests.
open_requests() fetches pending+approved (paginated), resolves their titles
(small set → fast), and returns them sorted by date added desc.
Updated the frontend table's status filter to Open/Pending/Approved (the data
only contains open requests now).
Tests: title resolution end-to-end (movie tmdbId → title), caching across
polls, filter param used. 404/404 backend + 184/184 frontend + build green.
253 lines
6.8 KiB
TypeScript
253 lines
6.8 KiB
TypeScript
/**
|
|
* JellyseerRequestsTable — sortable/filterable table of Jellyseerr requests.
|
|
*
|
|
* Uses TanStack Table directly (the shared DataTable is deliberately
|
|
* visibility-only). Defaults: status filter = "open" (pending/approved/
|
|
* processing), sorted by date added (newest first). Client-side sort + filter
|
|
* + pagination over the backend's fetched set.
|
|
*/
|
|
import { useMemo, useState } from "react";
|
|
import {
|
|
type ColumnDef,
|
|
type SortingState,
|
|
flexRender,
|
|
getCoreRowModel,
|
|
getPaginationRowModel,
|
|
getSortedRowModel,
|
|
useReactTable,
|
|
} from "@tanstack/react-table";
|
|
import { ArrowDown, ArrowUp, ChevronsUpDown, Search } from "lucide-react";
|
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { TablePagination } from "@/components/ui/table-pagination";
|
|
import { useJellyseerRequests } from "../hooks/useJellyseer";
|
|
import type { JellyseerRequest } from "../api/jellyseerr";
|
|
|
|
const OPEN_STATUSES = new Set(["pending", "approved", "processing"]);
|
|
type StatusFilter = "open" | "pending" | "approved";
|
|
|
|
function formatDate(v?: number | string): string {
|
|
if (!v) return "—";
|
|
const n = Number(v);
|
|
const ms = n > 1e12 ? n : n * 1000; // seconds -> ms
|
|
const d = new Date(ms);
|
|
return Number.isNaN(d.getTime())
|
|
? String(v)
|
|
: d.toLocaleDateString(undefined, {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
const columns: ColumnDef<JellyseerRequest>[] = [
|
|
{
|
|
accessorKey: "name",
|
|
header: "Name",
|
|
cell: ({ row }) => (
|
|
<span className="truncate font-medium">{row.original.name ?? "—"}</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "type",
|
|
header: "Type",
|
|
cell: ({ row }) => (
|
|
<span className="capitalize">{row.original.type ?? "—"}</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "status",
|
|
header: "Status",
|
|
cell: ({ row }) => (
|
|
<Badge variant="secondary">{row.original.status ?? "—"}</Badge>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "media_status",
|
|
header: "Media",
|
|
cell: ({ row }) =>
|
|
row.original.media_status ? (
|
|
<Badge variant="outline">{row.original.media_status}</Badge>
|
|
) : (
|
|
"—"
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "created_at",
|
|
header: "Requested",
|
|
cell: ({ row }) => formatDate(row.original.created_at),
|
|
sortDescFirst: true,
|
|
},
|
|
];
|
|
|
|
export function JellyseerRequestsTable({ serviceId }: { serviceId: string }) {
|
|
const {
|
|
data: requests = [],
|
|
isLoading,
|
|
error,
|
|
} = useJellyseerRequests(serviceId);
|
|
const [sorting, setSorting] = useState<SortingState>([
|
|
{ id: "created_at", desc: true },
|
|
]);
|
|
const [search, setSearch] = useState("");
|
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("open");
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
return requests.filter((r) => {
|
|
const status = String(r.status ?? "");
|
|
if (statusFilter === "open") {
|
|
if (!OPEN_STATUSES.has(status)) return false;
|
|
} else if (status !== statusFilter) {
|
|
return false;
|
|
}
|
|
if (
|
|
q &&
|
|
!String(r.name ?? "")
|
|
.toLowerCase()
|
|
.includes(q)
|
|
)
|
|
return false;
|
|
return true;
|
|
});
|
|
}, [requests, statusFilter, search]);
|
|
|
|
/* eslint-disable react-hooks/incompatible-library -- TanStack's useReactTable
|
|
intentionally returns non-memoizable updater fns (controlled state). */
|
|
const table = useReactTable({
|
|
data: filtered,
|
|
columns,
|
|
state: { sorting },
|
|
onSortingChange: setSorting,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getSortedRowModel: getSortedRowModel(),
|
|
getPaginationRowModel: getPaginationRowModel(),
|
|
initialState: { pagination: { pageSize: 10 } },
|
|
});
|
|
|
|
if (isLoading) {
|
|
return <Skeleton className="h-48 w-full" />;
|
|
}
|
|
if (error) {
|
|
return (
|
|
<Alert variant="destructive">
|
|
<AlertDescription>{error.message}</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<div className="relative min-w-[180px] flex-1">
|
|
<Search className="absolute left-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search requests…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
<Select
|
|
value={statusFilter}
|
|
onValueChange={(v) => setStatusFilter(v as StatusFilter)}
|
|
>
|
|
<SelectTrigger className="w-[140px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="open">Open</SelectItem>
|
|
<SelectItem value="pending">Pending</SelectItem>
|
|
<SelectItem value="approved">Approved</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="overflow-hidden rounded-lg border">
|
|
<Table>
|
|
<TableHeader>
|
|
{table.getHeaderGroups().map((hg) => (
|
|
<TableRow key={hg.id} className="hover:bg-transparent">
|
|
{hg.headers.map((header) => (
|
|
<TableHead key={header.id}>
|
|
{header.isPlaceholder ? null : (
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center gap-1"
|
|
onClick={header.column.getToggleSortingHandler()}
|
|
>
|
|
{flexRender(
|
|
header.column.columnDef.header,
|
|
header.getContext(),
|
|
)}
|
|
{header.column.getIsSorted() === "asc" ? (
|
|
<ArrowUp className="size-3" />
|
|
) : header.column.getIsSorted() === "desc" ? (
|
|
<ArrowDown className="size-3" />
|
|
) : (
|
|
<ChevronsUpDown className="size-3 opacity-40" />
|
|
)}
|
|
</button>
|
|
)}
|
|
</TableHead>
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</TableHeader>
|
|
<TableBody>
|
|
{table.getRowModel().rows.length ? (
|
|
table.getRowModel().rows.map((row) => (
|
|
<TableRow key={String(row.original.id ?? row.index)}>
|
|
{row.getVisibleCells().map((cell) => (
|
|
<TableCell key={cell.id}>
|
|
{flexRender(
|
|
cell.column.columnDef.cell,
|
|
cell.getContext(),
|
|
)}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))
|
|
) : (
|
|
<TableRow className="hover:bg-transparent">
|
|
<TableCell
|
|
colSpan={columns.length}
|
|
className="h-16 text-center text-muted-foreground"
|
|
>
|
|
No requests.
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
<TablePagination
|
|
pageIndex={table.getState().pagination.pageIndex}
|
|
pageSize={table.getState().pagination.pageSize}
|
|
pageSizeOptions={[10, 20, 50]}
|
|
totalRows={table.getRowModel().rows.length}
|
|
pageCount={table.getPageCount()}
|
|
onPaginationChange={table.setPagination}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|