- {request.title}
+
+ {request.title}
+
{formatMediaType(request.media_type)}
{request.year ? {request.year} : null}
TMDB {request.tmdb_id}
- {request.requested_by_user_id ? User {request.requested_by_user_id} : null}
+ {request.requested_by_user_id ? (
+
+ {requesterLabel}
+
+ ) : null}
{request.last_error ? (
{request.last_error}
diff --git a/web/src/pages/RequestDetail.tsx b/web/src/pages/RequestDetail.tsx
new file mode 100644
index 00000000..f36a510b
--- /dev/null
+++ b/web/src/pages/RequestDetail.tsx
@@ -0,0 +1,469 @@
+import { useMemo } from "react";
+import { useNavigate, useParams } from "react-router";
+import { ArrowLeft, Check, Clock, Loader2, Plus, Star } from "lucide-react";
+import MediaCarousel from "@/components/MediaCarousel";
+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 { RequestMediaCastMember, RequestMediaDetail, RequestMediaResult } from "@/api/types";
+import { useCreateMediaRequest, useRequestMediaDetail } from "@/hooks/queries/requests";
+import { useDocumentTitle } from "@/hooks/useDocumentTitle";
+import { cn } from "@/lib/utils";
+import { getInitials } from "@/lib/text";
+import { formatRequestReason, formatRequestStatus, tmdbImageURL } from "@/lib/mediaRequests";
+
+export default function RequestDetail() {
+ const navigate = useNavigate();
+ 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 (
+
+
}
+ studioLabel={studioLabel}
+ backdropUrl={backdropUrl}
+ posterUrl={posterUrl}
+ tagline={item.tagline || undefined}
+ metadata={
}
+ scoreRow={
}
+ crewLine={
}
+ overview={item.overview}
+ actions={
+
+ createRequest.mutate({
+ media_type: item.media_type,
+ tmdb_id: item.tmdb_id,
+ title: item.title,
+ year: item.year || undefined,
+ overview: item.overview || undefined,
+ poster_path: item.poster_path || undefined,
+ backdrop_path: item.backdrop_path || undefined,
+ })
+ }
+ onBack={() => navigate(-1)}
+ />
+ }
+ />
+
+
+ {item.cast && item.cast.length > 0 && (
+
+ )}
+
+ {item.recommendations && item.recommendations.length > 0 && (
+
+ createRequest.mutate({
+ media_type: rec.media_type,
+ tmdb_id: rec.tmdb_id,
+ title: rec.title,
+ year: rec.year || undefined,
+ overview: rec.overview || undefined,
+ poster_path: rec.poster_path || undefined,
+ backdrop_path: rec.backdrop_path || 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,
+ onBack,
+}: {
+ item: RequestMediaDetail;
+ isSubmitting: boolean;
+ onRequest: () => void;
+ onBack: () => 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 RequestCastRow({ cast }: { cast: RequestMediaCastMember[] }) {
+ const sorted = useMemo(
+ () =>
+ cast
+ .slice()
+ .sort((a, b) => a.order - b.order)
+ .slice(0, 24),
+ [cast],
+ );
+ return (
+
+
+ {sorted.map((member, index) => {
+ const photo = tmdbImageURL(member.profile_path, "w185");
+ return (
+ -
+
+ {photo ? (
+

+ ) : (
+
+ {getInitials(member.name)}
+
+ )}
+
+
+
+ {member.name}
+
+ {member.character ? (
+
+ {member.character}
+
+ ) : null}
+
+
+ );
+ })}
+
+
+ );
+}
+
+function RecommendationsRow({
+ recommendations,
+ pendingTMDBID,
+ isSubmitting,
+ onRequest,
+}: {
+ recommendations: RequestMediaResult[];
+ pendingTMDBID?: number;
+ isSubmitting: boolean;
+ onRequest: (item: RequestMediaResult) => void;
+}) {
+ return (
+
+
+
+ More like this
+
+
+
+ {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) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/web/src/pages/Requests.tsx b/web/src/pages/Requests.tsx
index d3db7de1..42095945 100644
--- a/web/src/pages/Requests.tsx
+++ b/web/src/pages/Requests.tsx
@@ -82,33 +82,37 @@ export default function Requests() {
const totalMine = (mine.data ?? []).length;
return (
-
-
+
+
-
-
- Discover
-
-
- Yours
- {totalMine > 0 && (
-
- {totalMine}
-
- )}
-
-
+
+
+
+ Discover
+
+
+ Yours
+ {totalMine > 0 && (
+
+ {totalMine}
+
+ )}
+
+
+
{isSearching ? (