From a3a7e51e71e4df78163de2306bfc875a61ef13f0 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 18:46:31 -0400 Subject: [PATCH] feat(web): add RequestBrowse page for studio/network/genre browse --- web/src/pages/RequestBrowse.tsx | 250 ++++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 web/src/pages/RequestBrowse.tsx diff --git a/web/src/pages/RequestBrowse.tsx b/web/src/pages/RequestBrowse.tsx new file mode 100644 index 00000000..e4da8f8f --- /dev/null +++ b/web/src/pages/RequestBrowse.tsx @@ -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 ( +
+

+ {kind === "studio" ? "Studio" : kind === "network" ? "Network" : "Genre"} not found. +

+ + Back to Requests + +
+ ); + } + + return ( +
+
+ + Back to Requests + +
+
+ +
+

{title}

+

+ {browse.isLoading + ? "Loading..." + : results.length > 0 + ? `Page ${page} of ${totalPages}` + : "No results."} +

+
+
+ +
+ + {kind === "genre" ? ( + updateMediaType(value as RequestMediaType)} + > + + Movies + Series + + + ) : null} +
+ +
+ {browse.isLoading ? ( + + ) : browse.isError ? ( +

+ Could not load this browse page. Try a different sort or media type. +

+ ) : results.length === 0 ? ( +

Nothing matched. Try a different sort.

+ ) : ( +
+ {results.map((item) => ( + submitRequest(item)} + isSubmitting={ + createRequest.isPending && createRequest.variables?.tmdb_id === item.tmdb_id + } + /> + ))} +
+ )} +
+ + {totalPages > 1 ? ( +
+ + + Page {page} of {totalPages} + + +
+ ) : null} +
+ ); +} + +function BrowseHeaderTile({ + browse, + kind, + fallback, +}: { + browse: DiscoverBrowseResponse | undefined; + kind: DiscoverBrowseKind; + fallback: string; +}) { + if (!browse) { + return
; + } + if (kind === "genre") { + return ( +
+ {browse.display_name || fallback} +
+ ); + } + return ( +
+ {browse.logo_url ? ( + {browse.display_name} + ) : ( + + {browse.display_name || fallback} + + )} +
+ ); +} + +function BrowseGridSkeleton() { + return ( +
+ {Array.from({ length: 12 }).map((_, idx) => ( + + ))} +
+ ); +} + +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(" "); +}