Files
silo-server/web/src/components/RequestPosterCard.tsx
T

389 lines
13 KiB
TypeScript

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 <MineCard request={props.request} fluid={props.fluid} />;
}
return (
<DiscoverCard
item={props.item}
isSubmitting={props.isSubmitting}
onRequest={props.onRequest}
fluid={props.fluid}
/>
);
}
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 (
<div
className={cn(
"group/req-card relative block focus-within:outline-none",
fluid ? "w-full" : POSTER_WIDTH,
)}
>
<Link
to={`/requests/${item.media_type}/${item.tmdb_id}`}
className="block focus:outline-none focus-visible:outline-none"
>
<PosterFrame
poster={poster}
title={item.title}
mediaType={item.media_type}
dim={!requestable}
>
{ribbon && (
<StatusRibbon
status={ribbon.kind}
label={ribbon.label}
reserveLibrarySpace={Boolean(item.library_content_id)}
/>
)}
</PosterFrame>
<CardMeta
title={item.title}
year={item.year}
rating={item.vote_average}
mediaType={item.media_type}
/>
</Link>
{requestable && onRequest && (
<div className="pointer-events-none absolute inset-x-0 top-0 flex aspect-[2/3] translate-y-2 items-end justify-center bg-gradient-to-t from-black/85 via-black/45 to-transparent p-3 opacity-0 transition-all duration-200 ease-out group-focus-within/req-card:translate-y-0 group-focus-within/req-card:opacity-100 group-hover/req-card:translate-y-0 group-hover/req-card:opacity-100">
<button
type="button"
disabled={Boolean(isSubmitting)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRequest();
}}
className="pointer-events-auto inline-flex items-center gap-1.5 rounded-full bg-white px-3.5 py-1.5 text-[12px] font-semibold tracking-wide text-black shadow-lg shadow-black/40 transition-all hover:scale-[1.03] active:scale-[0.97] disabled:opacity-70"
>
{isSubmitting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Sending
</>
) : (
<>
<Plus className="h-3.5 w-3.5 stroke-[2.5]" />
Request
</>
)}
</button>
</div>
)}
{item.library_content_id ? (
<LibraryCardLink contentID={item.library_content_id} title={item.title} />
) : null}
</div>
);
}
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 (
<div
className={cn(
"group/req-card relative block focus:outline-none focus-visible:outline-none",
fluid ? "w-full" : POSTER_WIDTH,
)}
>
<Link
to={`/requests/${request.media_type}/${request.tmdb_id}`}
className="block focus:outline-none focus-visible:outline-none"
>
<PosterFrame
poster={poster}
title={request.title}
mediaType={request.media_type}
dim={isFailed}
>
<StatusRibbon
status={kind}
label={label}
reserveLibrarySpace={Boolean(request.library_content_id)}
/>
{isCompleted && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-center justify-center bg-gradient-to-t from-emerald-950/90 via-emerald-900/40 to-transparent p-3">
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2.5 py-0.5 text-[11px] font-semibold tracking-wide text-emerald-200 ring-1 ring-emerald-400/30">
<Check className="h-3 w-3 stroke-[2.5]" />
Ready to watch
</span>
</div>
)}
</PosterFrame>
<CardMeta title={request.title} year={request.year} mediaType={request.media_type} />
{request.last_error ? (
<p
className="mt-1 line-clamp-2 text-[11px] leading-tight text-red-300/90"
title={request.last_error}
>
{request.last_error}
</p>
) : null}
</Link>
{request.library_content_id ? (
<LibraryCardLink contentID={request.library_content_id} title={request.title} />
) : null}
</div>
);
}
function LibraryCardLink({ contentID, title }: { contentID: string; title: string }) {
return (
<Link
to={`/item/${encodeURIComponent(contentID)}`}
aria-label={`Open ${title} in library`}
className="absolute top-2 left-2 inline-flex max-w-[calc(100%-1rem)] items-center gap-1.5 rounded-full bg-black/70 px-2 py-[3px] text-[10px] leading-none font-semibold tracking-[0.06em] text-white uppercase shadow-sm ring-1 shadow-white/15 backdrop-blur-md transition-colors hover:bg-black/85 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
>
<Library className="h-3 w-3 shrink-0" strokeWidth={2.4} aria-hidden />
<span className="truncate">Library</span>
</Link>
);
}
function PosterFrame({
poster,
title,
mediaType,
dim,
children,
}: {
poster: string | null;
title: string;
mediaType: "movie" | "series";
dim?: boolean;
children?: React.ReactNode;
}) {
return (
<div className="media-card-image relative aspect-[2/3]">
{poster ? (
<img
src={poster}
alt={title ? `${title} poster` : "Poster"}
loading="lazy"
className={cn(
"h-full w-full object-cover transition-[transform,filter] duration-300 group-hover/req-card:scale-[1.04]",
dim && "brightness-[0.85] saturate-[0.8]",
)}
/>
) : (
<PosterFallback title={title} mediaType={mediaType} dim={dim} />
)}
{/* subtle bottom vignette for legibility behind ribbons / hover overlays */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/55 to-transparent opacity-90" />
{children}
</div>
);
}
function PosterFallback({
title,
mediaType,
dim,
}: {
title: string;
mediaType: "movie" | "series";
dim?: boolean;
}) {
const hue = stringHue(title);
const Icon = mediaType === "series" ? Tv : Film;
return (
<div
className={cn(
"relative flex h-full w-full flex-col justify-end overflow-hidden p-3.5",
dim && "opacity-90",
)}
style={{
background: `linear-gradient(160deg, hsl(${hue} 30% 22%) 0%, hsl(${hue} 22% 11%) 60%, hsl(${(hue + 28) % 360} 18% 7%) 100%)`,
}}
>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<Icon className="h-28 w-28 text-white/[0.05]" strokeWidth={1.25} />
</div>
<div
className="pointer-events-none absolute inset-0"
style={{
backgroundImage: "radial-gradient(rgba(255,255,255,0.55) 1px, transparent 1px)",
backgroundSize: "9px 9px",
opacity: 0.05,
}}
/>
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/15 to-transparent" />
<div className="relative space-y-1.5">
<span className="text-[9px] font-semibold tracking-[0.22em] text-white/45 uppercase">
{mediaType === "series" ? "Series" : "Motion picture"}
</span>
<h4 className="font-display line-clamp-4 text-[15px] leading-tight font-bold tracking-tight text-balance text-white/90">
{title}
</h4>
</div>
</div>
);
}
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 (
<div className="mt-2.5 min-w-0 px-0.5">
<h3 className="text-foreground line-clamp-1 text-[13px] leading-tight font-semibold tracking-tight">
{title}
</h3>
{hasMeta && (
<div className="text-muted-foreground mt-1 flex items-center gap-1.5 text-[11px]">
{mediaType && (
<Icon className="h-3 w-3 shrink-0 opacity-60" strokeWidth={2} aria-hidden />
)}
{year ? <span className="tabular-nums">{year}</span> : null}
{(year || mediaType) && rating ? (
<span aria-hidden className="text-muted-foreground/40">
·
</span>
) : null}
{rating ? (
<span className="tabular-nums">
<span className="text-amber-300/90">★</span> {rating.toFixed(1)}
</span>
) : null}
</div>
)}
</div>
);
}
type RibbonKind = "pending" | "approved" | "queued" | "downloading" | "completed" | "blocked";
const RIBBON_STYLES: Record<RibbonKind, string> = {
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 (
<span
className={cn(
"absolute top-2 right-2 inline-flex items-center gap-1.5 rounded-full px-2 py-[3px] text-[10px] leading-none font-medium tracking-[0.06em] uppercase shadow-sm ring-1 shadow-black/40 backdrop-blur-md",
reserveLibrarySpace ? "max-w-[calc(100%-5.75rem)]" : "max-w-[calc(100%-1rem)]",
RIBBON_STYLES[kind],
)}
>
<span className="dot inline-block h-1.5 w-1.5 shrink-0 rounded-full" />
<span className="truncate">{label}</span>
</span>
);
}
function formatOutcome(outcome: MediaRequest["outcome"]): string {
switch (outcome) {
case "declined":
return "Declined";
case "cancelled":
return "Cancelled";
case "failed":
return "Failed";
default:
return "Active";
}
}