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

183 lines
6.8 KiB
TypeScript

import { useState } from "react";
import { Check } from "lucide-react";
import ViewTransitionLink from "@/components/ViewTransitionLink";
import type { BrowseItem } from "@/api/types";
import { decodeThumbhash } from "@/lib/thumbhash";
import { timeAgo } from "@/lib/timeAgo";
import MediaItemMenu from "@/components/MediaItemMenu";
import CardOverlays from "@/components/overlays/CardOverlays";
import { overlayDataFromBrowseItem, type CardOverlayPrefs } from "@/lib/overlays";
import { buildEpisodeCardLabels } from "@/lib/episodeCardLabels";
function SortMeta({ item, sortField }: { item: BrowseItem; sortField?: string }) {
const episodeLabels = buildEpisodeCardLabels(item);
if (episodeLabels) {
return <>{episodeLabels.episodeCode}</>;
}
const defaultLabel = [item.year || "", item.type === "series" ? "Series" : ""]
.filter(Boolean)
.join(" · ");
switch (sortField) {
case "added_at":
case "recently_added": {
const ago = item.added_at ? timeAgo(item.added_at) : null;
return <>{ago ?? defaultLabel}</>;
}
case "rating_imdb":
return item.rating_imdb != null ? (
<>
<span className="not-uppercase">★</span> {item.rating_imdb.toFixed(1)} / 10
</>
) : (
<>{defaultLabel}</>
);
case "release_date":
return item.release_date ? (
<>
{new Date(item.release_date).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</>
) : (
<>{defaultLabel}</>
);
case "last_air_date":
return item.last_air_date ? (
<>
{new Date(item.last_air_date).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</>
) : (
<>{defaultLabel}</>
);
default:
return <>{defaultLabel}</>;
}
}
export default function ItemCard({
item,
libraryId,
sortField,
overlayPrefs,
selectionMode = false,
selected = false,
onToggleSelect,
}: {
item: BrowseItem;
libraryId?: number;
sortField?: string;
overlayPrefs?: CardOverlayPrefs | null;
selectionMode?: boolean;
selected?: boolean;
onToggleSelect?: (item: BrowseItem) => void;
}) {
const [loaded, setLoaded] = useState(false);
const thumbhashUrl = item.poster_thumbhash ? decodeThumbhash(item.poster_thumbhash) : "";
const itemHref = `/item/${item.content_id}${libraryId ? `?libraryId=${libraryId}` : ""}`;
const episodeLabels = buildEpisodeCardLabels(item);
const displayTitle = episodeLabels ? episodeLabels.seriesTitle : item.title;
return (
<div className="media-card group/card">
<div className="relative">
<ViewTransitionLink to={itemHref} className="block overflow-hidden rounded-xl">
<div
className="media-card-image relative aspect-[2/3]"
style={
thumbhashUrl
? {
backgroundImage: `url(${thumbhashUrl})`,
backgroundSize: "cover",
backgroundPosition: "center",
}
: undefined
}
>
{item.poster_url ? (
<img
src={item.poster_url}
alt={displayTitle}
className={`h-full w-full object-cover transition-opacity duration-300 ${loaded ? "opacity-100" : "opacity-0"}`}
onLoad={() => setLoaded(true)}
/>
) : (
<div className="text-muted-foreground flex h-full w-full flex-col items-center justify-center gap-1 p-3 text-center text-sm">
<span className="line-clamp-3 font-medium">{displayTitle || "No Poster"}</span>
</div>
)}
<div className="from-background/70 pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t to-transparent opacity-90" />
{item.status === "pending" && (
<span className="glass-subtle text-foreground absolute top-2.5 left-2.5 rounded-full border border-white/15 px-2.5 py-1 text-[10px] font-semibold tracking-[0.14em] uppercase">
Scanning
</span>
)}
{item.status === "unmatched" && (
<span className="glass-subtle absolute top-2.5 left-2.5 rounded-full border border-red-500/25 px-2.5 py-1 text-[10px] font-semibold tracking-[0.14em] text-red-300 uppercase">
Unmatched
</span>
)}
{item.status === "ambiguous" && (
<span className="glass-subtle absolute top-2.5 left-2.5 rounded-full border border-amber-500/25 px-2.5 py-1 text-[10px] font-semibold tracking-[0.14em] text-amber-200 uppercase">
Ambiguous
</span>
)}
{item.status === "matched" && overlayPrefs && (
<CardOverlays data={overlayDataFromBrowseItem(item)} prefs={overlayPrefs} />
)}
</div>
</ViewTransitionLink>
{selectionMode && onToggleSelect && (
<button
type="button"
aria-label={selected ? `Deselect ${item.title}` : `Select ${item.title}`}
aria-pressed={selected}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onToggleSelect(item);
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
className="absolute top-2.5 left-2.5 z-20 inline-flex size-8 items-center justify-center rounded-full border border-white/20 bg-black/55 text-white shadow-sm backdrop-blur-sm transition-colors hover:bg-black/70"
>
<span
className={`flex size-4 items-center justify-center rounded-full border ${
selected ? "border-primary bg-primary text-primary-foreground" : "border-white/70"
}`}
>
{selected && <Check className="size-3" />}
</span>
</button>
)}
<MediaItemMenu
contentId={item.content_id}
mediaType={item.type}
userState={item.user_state}
variant="poster"
/>
</div>
<ViewTransitionLink to={itemHref} className="block px-1 pt-3">
<div className="truncate text-[14px] font-semibold tracking-tight">{displayTitle}</div>
{episodeLabels?.episodeTitle ? (
<div className="text-muted-foreground mt-1 truncate text-[12px] font-medium">
{episodeLabels.episodeTitle}
</div>
) : null}
<div className="text-muted-foreground mt-1 text-[11px] font-medium tracking-[0.14em] uppercase">
<SortMeta item={item} sortField={sortField} />
</div>
</ViewTransitionLink>
</div>
);
}