import { useParams } from "react-router"; import { Check, Clock, Loader2, Plus, Star } from "lucide-react"; import CastCarousel from "@/components/CastCarousel"; import MediaCarousel from "@/components/MediaCarousel"; import PageBack from "@/components/PageBack"; import RequestPosterCard from "@/components/RequestPosterCard"; import DetailHero from "@/pages/ItemDetail/DetailHero"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import type { CastMember, RequestMediaCastMember, RequestMediaDetail, RequestMediaResult, } from "@/api/types"; import { useCreateMediaRequest, useRequestMediaDetail } from "@/hooks/queries/useRequests"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; import { cn } from "@/lib/utils"; import { formatRequestReason, formatRequestStatus, requestInputFromMediaResult, tmdbImageURL, } from "@/lib/mediaRequests"; export default function RequestDetail() { const params = useParams<{ mediaType: string; tmdbId: string }>(); const mediaType = (params.mediaType === "series" ? "series" : "movie") as "movie" | "series"; const tmdbID = Number(params.tmdbId) || 0; const detail = useRequestMediaDetail(mediaType, tmdbID); const createRequest = useCreateMediaRequest(); useDocumentTitle(detail.data?.title ?? "Request"); if (detail.isLoading) { return ; } if (detail.isError || !detail.data) { return (

Couldn't load this title.

The TMDB record may be temporarily unavailable.

); } const item = detail.data; const backdropUrl = tmdbImageURL(item.backdrop_path, "original") ?? undefined; const posterUrl = tmdbImageURL(item.poster_path, "w500") ?? undefined; const studioLabel = pickStudioLabel(item); return (
} context={} studioLabel={studioLabel} backdropUrl={backdropUrl} posterUrl={posterUrl} tagline={item.tagline || undefined} metadata={} scoreRow={} crewLine={} overview={item.overview} actions={ createRequest.mutate(requestInputFromMediaResult(item))} /> } />
{item.cast && item.cast.length > 0 && (

Cast

)} {item.recommendations && item.recommendations.length > 0 && ( createRequest.mutate(requestInputFromMediaResult(rec))} /> )}
); } function adaptRequestCast(cast: RequestMediaCastMember[]): CastMember[] { return cast.map((member) => ({ name: member.name, character: member.character ?? "", order: member.order, person_id: "", photo_url: tmdbImageURL(member.profile_path, "w185") ?? undefined, })); } function RequestContext({ mediaType }: { mediaType: "movie" | "series" }) { return ( Request ยท {mediaType === "series" ? "Series" : "Movie"} ); } function MetaPills({ item }: { item: RequestMediaDetail }) { const pills: string[] = []; if (item.year) pills.push(String(item.year)); if (item.content_rating) pills.push(item.content_rating); if (item.media_type === "movie" && item.runtime) pills.push(formatDuration(item.runtime)); if (item.media_type === "series" && item.number_of_seasons) pills.push(`${item.number_of_seasons} Season${item.number_of_seasons === 1 ? "" : "s"}`); if (item.media_type === "series" && item.status) pills.push(item.status); return (
{pills.map((pill) => ( {pill} ))} {(item.genres ?? []).slice(0, 4).map((genre) => ( {genre} ))}
); } function RequestScoreRow({ item }: { item: RequestMediaDetail }) { if (!item.vote_average) return null; return (
{item.vote_average.toFixed(1)} TMDB {item.vote_count ? ( {formatVoteCount(item.vote_count)} votes ) : null}
); } function RequestCrewLine({ item }: { item: RequestMediaDetail }) { const parts: { label: string; value: string }[] = []; if (item.director) parts.push({ label: "Director", value: item.director }); if (item.creators && item.creators.length > 0) parts.push({ label: "Created by", value: item.creators.join(", ") }); if (item.networks && item.networks.length > 0) parts.push({ label: "Network", value: item.networks.join(", ") }); if (parts.length === 0) return null; return (
{parts.map((part) => ( {part.label}: {part.value} ))}
); } function RequestActions({ item, isSubmitting, onRequest, }: { item: RequestMediaDetail; isSubmitting: boolean; onRequest: () => void; }) { const requestable = item.request.requestable; const statusLabel = item.request.status ? formatRequestStatus(item.request.status) : null; const reasonLabel = !requestable && !item.request.status ? formatRequestReason(item.request.reason) : null; const availableInLibrary = item.availability === "available" && !item.request.status; return (
{requestable ? ( ) : availableInLibrary ? ( } label="Already in your library" /> ) : statusLabel ? ( } label={statusLabel} /> ) : ( } label={reasonLabel ?? "Unavailable"} /> )} {item.imdb_id ? ( IMDb ) : null} TMDB
); } const STATUS_TONES: Record<"amber" | "sky" | "emerald" | "zinc", string> = { amber: "bg-amber-500/15 text-amber-100 ring-amber-400/40", sky: "bg-sky-500/15 text-sky-100 ring-sky-400/40", emerald: "bg-emerald-500/15 text-emerald-100 ring-emerald-400/40", zinc: "bg-zinc-700/60 text-zinc-200 ring-zinc-500/40", }; function StatusBlock({ tone, icon, label, }: { tone: "amber" | "sky" | "emerald" | "zinc"; icon: React.ReactNode; label: string; }) { return ( {icon} {label} ); } function statusToneForStatus(status: string): "amber" | "sky" | "emerald" | "zinc" { switch (status) { case "pending": return "amber"; case "approved": case "completed": return "emerald"; case "queued": case "downloading": return "sky"; default: return "zinc"; } } function RecommendationsRow({ recommendations, pendingTMDBID, isSubmitting, onRequest, }: { recommendations: RequestMediaResult[]; pendingTMDBID?: number; isSubmitting: boolean; onRequest: (item: RequestMediaResult) => void; }) { return ( {recommendations.map((item) => ( onRequest(item)} /> ))} ); } function pickStudioLabel(item: RequestMediaDetail): string | undefined { if (item.media_type === "series" && item.networks && item.networks.length > 0) { return item.networks[0]; } if (item.production_companies && item.production_companies.length > 0) { return item.production_companies[0]; } return undefined; } function formatDuration(minutes: number): string { if (minutes <= 0) return ""; const h = Math.floor(minutes / 60); const m = minutes % 60; if (h <= 0) return `${m}m`; return m === 0 ? `${h}h` : `${h}h ${m}m`; } function formatVoteCount(count: number): string { if (count >= 1000) return `${(count / 1000).toFixed(1)}k`; return String(count); } function RequestDetailSkeleton() { return (
{Array.from({ length: 8 }).map((_, i) => ( ))}
); }