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 && (
)}
{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) => (
))}
);
}