import { Link } from "react-router";
import { Check, Film, Library, Loader2, Plus, Tv } from "lucide-react";
import type { MediaRequest, RequestMediaResult } from "@/api/types";
import { cn } from "@/lib/utils";
import { formatRequestReason, formatRequestStatus, tmdbImageURL } from "@/lib/mediaRequests";
const POSTER_WIDTH = "w-[148px] sm:w-[164px] lg:w-[184px]";
type DiscoverProps = {
variant: "discover";
item: RequestMediaResult;
/** Called when the inline hover Request button is clicked. Omit to suppress the button. */
onRequest?: () => void;
/** Displays the spinner state on the hover Request button. Ignored when onRequest is omitted. */
isSubmitting?: boolean;
/** When true, fills the parent (use inside grids). Default: fixed carousel width. */
fluid?: boolean;
};
type MineProps = {
variant: "mine";
request: MediaRequest;
fluid?: boolean;
};
export type RequestPosterCardProps = DiscoverProps | MineProps;
export default function RequestPosterCard(props: RequestPosterCardProps) {
if (props.variant === "mine") {
return ;
}
return (
);
}
function DiscoverCard({
item,
isSubmitting,
onRequest,
fluid,
}: {
item: RequestMediaResult;
isSubmitting?: boolean;
onRequest?: () => void;
fluid?: boolean;
}) {
const poster = tmdbImageURL(item.poster_path);
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;
const ribbon: { kind: RibbonKind; label: string } | null = statusLabel
? { kind: (item.request.status as RibbonKind) ?? "pending", label: statusLabel }
: availableInLibrary
? { kind: "completed", label: "In library" }
: reasonLabel
? { kind: "blocked", label: reasonLabel }
: null;
return (
{ribbon && (
)}
{requestable && onRequest && (
)}
{item.library_content_id ? (
) : null}
);
}
function MineCard({ request, fluid }: { request: MediaRequest; fluid?: boolean }) {
const poster = tmdbImageURL(request.poster_path);
const isCompleted = request.status === "completed";
const isFailed =
request.outcome === "failed" ||
request.outcome === "declined" ||
request.outcome === "cancelled";
const kind: RibbonKind = isFailed ? "blocked" : (request.status as RibbonKind);
const label = isFailed ? formatOutcome(request.outcome) : formatRequestStatus(request.status);
return (
{isCompleted && (
Ready to watch
)}
{request.last_error ? (
{request.last_error}
) : null}
{request.library_content_id ? (
) : null}
);
}
function LibraryCardLink({ contentID, title }: { contentID: string; title: string }) {
return (
Library
);
}
function PosterFrame({
poster,
title,
mediaType,
dim,
children,
}: {
poster: string | null;
title: string;
mediaType: "movie" | "series";
dim?: boolean;
children?: React.ReactNode;
}) {
return (
{poster ? (

) : (
)}
{/* subtle bottom vignette for legibility behind ribbons / hover overlays */}
{children}
);
}
function PosterFallback({
title,
mediaType,
dim,
}: {
title: string;
mediaType: "movie" | "series";
dim?: boolean;
}) {
const hue = stringHue(title);
const Icon = mediaType === "series" ? Tv : Film;
return (
{mediaType === "series" ? "Series" : "Motion picture"}
{title}
);
}
function stringHue(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = (Math.imul(hash, 31) + input.charCodeAt(i)) | 0;
}
return Math.abs(hash) % 360;
}
function CardMeta({
title,
year,
rating,
mediaType,
}: {
title: string;
year?: number;
rating?: number;
mediaType?: "movie" | "series";
}) {
const Icon = mediaType === "series" ? Tv : Film;
const hasMeta = mediaType || year !== undefined || rating !== undefined;
return (
{title}
{hasMeta && (
{mediaType && (
)}
{year ? {year} : null}
{(year || mediaType) && rating ? (
·
) : null}
{rating ? (
★ {rating.toFixed(1)}
) : null}
)}
);
}
type RibbonKind = "pending" | "approved" | "queued" | "downloading" | "completed" | "blocked";
const RIBBON_STYLES: Record = {
pending:
"bg-amber-950/75 text-amber-100 ring-amber-400/30 [&_.dot]:bg-amber-300 [&_.dot]:animate-pulse",
approved: "bg-emerald-950/75 text-emerald-100 ring-emerald-400/30 [&_.dot]:bg-emerald-300",
queued: "bg-sky-950/75 text-sky-100 ring-sky-400/30 [&_.dot]:bg-sky-300 [&_.dot]:animate-pulse",
downloading:
"bg-sky-950/80 text-sky-100 ring-sky-400/35 [&_.dot]:bg-sky-300 [&_.dot]:animate-pulse",
completed: "bg-emerald-950/80 text-emerald-100 ring-emerald-400/30 [&_.dot]:bg-emerald-300",
blocked: "bg-zinc-900/80 text-zinc-200 ring-white/10 [&_.dot]:bg-zinc-400",
};
function StatusRibbon({
status,
label,
reserveLibrarySpace,
}: {
status: string;
label: string;
reserveLibrarySpace?: boolean;
}) {
const kind = (RIBBON_STYLES[status as RibbonKind] ? status : "blocked") as RibbonKind;
return (
{label}
);
}
function formatOutcome(outcome: MediaRequest["outcome"]): string {
switch (outcome) {
case "declined":
return "Declined";
case "cancelled":
return "Cancelled";
case "failed":
return "Failed";
default:
return "Active";
}
}