feat(web): add RequestBrowse page for studio/network/genre browse

This commit is contained in:
Silo Server Migration
2026-05-24 18:46:31 -04:00
parent 330fbc261c
commit a3a7e51e71
+250
View File
@@ -0,0 +1,250 @@
import { Link, useParams, useSearchParams } from "react-router";
import { ArrowLeft } from "lucide-react";
import RequestPosterCard from "@/components/RequestPosterCard";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { useCreateMediaRequest, useRequestBrowse } from "@/hooks/queries/requests";
import { requestInputFromMediaResult } from "@/lib/mediaRequests";
import { cn } from "@/lib/utils";
import type {
DiscoverBrowseKind,
DiscoverBrowseResponse,
RequestMediaResult,
RequestMediaType,
} from "@/api/types";
type BrowseSort = "popularity" | "vote_average" | "release_date";
const SORT_OPTIONS: { value: BrowseSort; label: string }[] = [
{ value: "popularity", label: "Popularity" },
{ value: "vote_average", label: "Rating" },
{ value: "release_date", label: "Release date" },
];
interface RequestBrowseProps {
kind: DiscoverBrowseKind;
}
export default function RequestBrowse({ kind }: RequestBrowseProps) {
const { slug = "" } = useParams<{ slug: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const sort = normalizeSort(searchParams.get("sort"));
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
const mediaTypeFromQuery = normalizeMediaType(searchParams.get("media_type"));
const mediaType: RequestMediaType | undefined =
kind === "studio" ? "movie" : kind === "network" ? "series" : mediaTypeFromQuery ?? "movie";
const browse = useRequestBrowse({ kind, slug, mediaType, sort, page });
const createRequest = useCreateMediaRequest();
const title = browse.data?.display_name ?? humanizeSlug(slug);
useDocumentTitle(title ? `${title} - Requests` : "Requests");
function updateSort(next: string) {
const params = new URLSearchParams(searchParams);
params.set("sort", next);
params.set("page", "1");
setSearchParams(params, { replace: true });
}
function updateMediaType(next: RequestMediaType) {
const params = new URLSearchParams(searchParams);
params.set("media_type", next);
params.set("page", "1");
setSearchParams(params, { replace: true });
}
function goToPage(next: number) {
const params = new URLSearchParams(searchParams);
params.set("page", String(next));
setSearchParams(params, { replace: false });
window.scrollTo({ top: 0, behavior: "smooth" });
}
function submitRequest(item: RequestMediaResult) {
createRequest.mutate(requestInputFromMediaResult(item));
}
const totalPages = browse.data?.total_pages ?? 0;
const results = browse.data?.results ?? [];
if (browse.isError && (browse.error as { status?: number }).status === 404) {
return (
<div className="space-y-4 py-10 text-center">
<p className="text-foreground text-lg font-semibold">
{kind === "studio" ? "Studio" : kind === "network" ? "Network" : "Genre"} not found.
</p>
<Link to="/requests" className="text-muted-foreground hover:text-foreground text-sm underline">
Back to Requests
</Link>
</div>
);
}
return (
<div className="space-y-6 py-6 sm:py-8">
<div className="space-y-4 px-4 sm:px-6 lg:px-10 xl:px-12">
<Link
to="/requests"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-sm"
>
<ArrowLeft className="h-4 w-4" /> Back to Requests
</Link>
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-4">
<BrowseHeaderTile browse={browse.data} kind={kind} fallback={title} />
<div className="min-w-0">
<h1 className="text-foreground truncate text-2xl font-semibold">{title}</h1>
<p className="text-muted-foreground text-sm">
{browse.isLoading
? "Loading..."
: results.length > 0
? `Page ${page} of ${totalPages}`
: "No results."}
</p>
</div>
</div>
<Select value={sort} onValueChange={updateSort}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Sort" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{kind === "genre" ? (
<Tabs
value={mediaType ?? "movie"}
onValueChange={(value) => updateMediaType(value as RequestMediaType)}
>
<TabsList>
<TabsTrigger value="movie">Movies</TabsTrigger>
<TabsTrigger value="series">Series</TabsTrigger>
</TabsList>
</Tabs>
) : null}
</div>
<div className="px-4 sm:px-6 lg:px-10 xl:px-12">
{browse.isLoading ? (
<BrowseGridSkeleton />
) : browse.isError ? (
<p className="text-muted-foreground text-sm">
Could not load this browse page. Try a different sort or media type.
</p>
) : results.length === 0 ? (
<p className="text-muted-foreground text-sm">Nothing matched. Try a different sort.</p>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{results.map((item) => (
<RequestPosterCard
key={`${item.media_type}-${item.tmdb_id}`}
variant="discover"
item={item}
onRequest={() => submitRequest(item)}
isSubmitting={
createRequest.isPending && createRequest.variables?.tmdb_id === item.tmdb_id
}
/>
))}
</div>
)}
</div>
{totalPages > 1 ? (
<div className="flex items-center justify-center gap-3 px-4">
<Button variant="outline" disabled={page <= 1} onClick={() => goToPage(page - 1)}>
Prev
</Button>
<span className="text-muted-foreground text-sm tabular-nums">
Page {page} of {totalPages}
</span>
<Button variant="outline" disabled={page >= totalPages} onClick={() => goToPage(page + 1)}>
Next
</Button>
</div>
) : null}
</div>
);
}
function BrowseHeaderTile({
browse,
kind,
fallback,
}: {
browse: DiscoverBrowseResponse | undefined;
kind: DiscoverBrowseKind;
fallback: string;
}) {
if (!browse) {
return <div className="bg-muted h-16 w-28 rounded-md" aria-hidden />;
}
if (kind === "genre") {
return (
<div className="bg-muted text-foreground flex h-16 w-28 items-center justify-center rounded-md px-2 text-center text-sm font-semibold">
{browse.display_name || fallback}
</div>
);
}
return (
<div
className={cn("flex h-16 w-28 items-center justify-center overflow-hidden rounded-md")}
style={{ background: browse.brand_color || "#1f2937" }}
>
{browse.logo_url ? (
<img
src={browse.logo_url}
alt={browse.display_name}
className="max-h-[60%] max-w-[80%] object-contain"
/>
) : (
<span className="px-2 text-center text-xs font-semibold text-white">
{browse.display_name || fallback}
</span>
)}
</div>
);
}
function BrowseGridSkeleton() {
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{Array.from({ length: 12 }).map((_, idx) => (
<Skeleton key={idx} className="aspect-[2/3] w-full rounded-md" />
))}
</div>
);
}
function normalizeSort(value: string | null): BrowseSort {
return SORT_OPTIONS.some((option) => option.value === value) ? (value as BrowseSort) : "popularity";
}
function normalizeMediaType(value: string | null): RequestMediaType | undefined {
return value === "movie" || value === "series" ? value : undefined;
}
function humanizeSlug(slug: string) {
return slug
.split("-")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}