Files
silo-server/web/src/components/ItemCard.tsx
T
QuickandGitHub 3bdfc58512 feat(settings): sync navigation and card customization by client family (#538)
* test(web): use safe auth placeholders

* feat(settings): sync navigation and card customization

* fix(settings): address customization review feedback

* fix(settings): address customization review feedback

* fix(settings): harden customization capability handling
2026-08-04 08:20:41 -04:00

327 lines
13 KiB
TypeScript

import { useImageLoaded } from "@/hooks/useImageLoaded";
import { Check, Layers } 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";
import { formatDate as formatPreferredDate } from "@/lib/datetime";
import { formatBitrate } from "@/lib/mediaFormat";
import { useUICustomization } from "@/hooks/useUICustomization";
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function formatDate(value?: string | null) {
if (!value) {
return null;
}
const date = new Date(DATE_ONLY_PATTERN.test(value) ? `${value}T00:00:00` : value);
return formatPreferredDate(date, "medium") || null;
}
function formatRuntime(minutes?: number | null) {
if (!minutes || minutes <= 0) {
return null;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
if (hours === 0) {
return `${remainingMinutes}m`;
}
return remainingMinutes === 0 ? `${hours}h` : `${hours}h ${remainingMinutes}m`;
}
function formatRating(value?: number | null, max = 10) {
return value != null ? `${value.toFixed(1)} / ${max}` : null;
}
function formatPercent(value?: number | null) {
return value != null ? `${value}%` : null;
}
function formatProgress(ratio?: number | null) {
if (ratio == null) {
return null;
}
return `${Math.round(Math.max(0, Math.min(1, ratio)) * 100)}%`;
}
// mangaCountChipLabel returns the top-right poster chip label for a manga
// browse item, or null when the item is not manga or has no counts. The server
// sends distinct volumes (manga_volume_count) and loose un-volumed chapters
// (manga_chapter_count) separately. Labels are abbreviated ("12 Vol · 3 Ch")
// so the chip fits narrow cards without occluding the cover; the detail page
// carries the spelled-out counts. Strictly manga-gated so no other card type
// renders it.
function mangaCountChipLabel(item: BrowseItem): string | null {
if (item.type !== "manga") {
return null;
}
const volumes = item.manga_volume_count ?? 0;
const chapters = item.manga_chapter_count ?? 0;
const parts: string[] = [];
if (volumes > 0) {
parts.push(`${volumes} Vol`);
}
if (chapters > 0) {
parts.push(`${chapters} Ch`);
}
return parts.length > 0 ? parts.join(" · ") : null;
}
// mangaStatusChip returns the top-left publication-status pill for a manga
// browse card (color-coded), or null when the item is not manga or has no
// status. Strictly manga-gated so no other card type renders it.
function mangaStatusChip(item: BrowseItem): { label: string; tone: string } | null {
if (item.type !== "manga") {
return null;
}
const status = item.show_status?.trim();
if (!status) {
return null;
}
const tone =
{
Ongoing: "text-emerald-200 border-emerald-400/30",
Completed: "text-sky-200 border-sky-400/30",
Hiatus: "text-amber-200 border-amber-400/30",
Cancelled: "text-red-300 border-red-400/30",
Upcoming: "text-violet-200 border-violet-400/30",
}[status] ?? "text-foreground border-white/15";
return { label: status, tone };
}
function SortMeta({ item, sortField }: { item: BrowseItem; sortField?: string }) {
const episodeLabels = buildEpisodeCardLabels(item);
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 "title":
if (episodeLabels) {
return <>{episodeLabels.episodeCode}</>;
}
return <>{defaultLabel}</>;
case "year":
return <>{item.year || defaultLabel}</>;
case "content_rating":
return <>{item.content_rating || defaultLabel}</>;
case "runtime":
return (
<>{formatRuntime(item.sort_metrics?.runtime_minutes ?? item.runtime) ?? defaultLabel}</>
);
case "rating_imdb":
return item.rating_imdb != null ? (
<>
<span className="not-uppercase">★</span> {item.rating_imdb.toFixed(1)} / 10
</>
) : (
<>{defaultLabel}</>
);
case "rating_tmdb":
return <>{formatRating(item.rating_tmdb) ?? defaultLabel}</>;
case "rating_rt_critic":
return <>{formatPercent(item.rating_rt_critic) ?? defaultLabel}</>;
case "rating_rt_audience":
return <>{formatPercent(item.rating_rt_audience) ?? defaultLabel}</>;
case "release_date":
return (
<>{formatDate(item.sort_metrics?.release_date ?? item.release_date) ?? defaultLabel}</>
);
case "last_air_date":
return <>{formatDate(item.last_air_date) ?? defaultLabel}</>;
case "resolution":
return (
<>{item.sort_metrics?.resolution || item.overlay_summary?.resolution || defaultLabel}</>
);
case "bitrate":
return <>{formatBitrate(item.sort_metrics?.bitrate_kbps ?? undefined) || defaultLabel}</>;
case "progress":
return <>{formatProgress(item.sort_metrics?.progress_ratio) ?? defaultLabel}</>;
case "date_viewed":
return <>{formatDate(item.sort_metrics?.viewed_at) ?? defaultLabel}</>;
case "plays":
return <>{item.sort_metrics?.play_count ?? defaultLabel}</>;
case "author":
return <>{item.sort_metrics?.author || defaultLabel}</>;
case "narrator":
return <>{item.sort_metrics?.narrator || defaultLabel}</>;
case "series":
return <>{item.sort_metrics?.series_name || defaultLabel}</>;
default:
if (episodeLabels) {
return <>{episodeLabels.episodeCode}</>;
}
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, onLoad } = useImageLoaded(item.poster_url);
const thumbhashUrl = item.poster_thumbhash ? decodeThumbhash(item.poster_thumbhash) : "";
const itemHref = `/item/${encodeURIComponent(item.content_id)}${
libraryId ? `?libraryId=${libraryId}` : ""
}`;
const episodeLabels = buildEpisodeCardLabels(item);
const displayTitle = episodeLabels ? episodeLabels.seriesTitle : item.title;
const mangaCountLabel = mangaCountChipLabel(item);
const mangaStatus = mangaStatusChip(item);
const { cardPresentation } = useUICustomization();
const showCaption = cardPresentation.caption !== "artwork";
const showMetadata = cardPresentation.caption === "title_metadata";
return (
<div className="media-card group/card">
<div className="relative">
<ViewTransitionLink
to={itemHref}
aria-label={displayTitle}
className="block overflow-hidden rounded-xl"
>
<div
className={`media-card-image relative ${
item.type === "audiobook" ? "aspect-square" : "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={onLoad}
/>
) : (
<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>
)}
{/* Manga cards carry purpose-built status/count chips in both top
corners; generic overlays would render underneath them. */}
{item.status === "matched" && item.type !== "manga" && overlayPrefs && (
<CardOverlays data={overlayDataFromBrowseItem(item)} prefs={overlayPrefs} />
)}
{(mangaStatus || mangaCountLabel) && (
/* One shared row so the two chips split the card width and
truncate instead of overlapping on narrow cards. */
<div className="pointer-events-none absolute inset-x-2.5 top-2.5 flex items-start justify-between gap-1.5">
{mangaStatus ? (
<span
className={`glass-chip min-w-0 truncate rounded-full border px-2.5 py-1 text-[10px] font-semibold tracking-[0.14em] uppercase ${mangaStatus.tone}`}
>
{mangaStatus.label}
</span>
) : (
<span />
)}
{mangaCountLabel && (
<span className="glass-chip text-foreground inline-flex min-w-0 items-center gap-1 rounded-full border border-white/15 px-2.5 py-1 text-[10px] font-semibold tracking-[0.14em] uppercase">
<Layers className="size-3 shrink-0" />
<span className="truncate">{mangaCountLabel}</span>
</span>
)}
</div>
)}
</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}
libraryId={libraryId}
userState={item.user_state}
variant="poster"
/>
</div>
{showCaption ? (
<ViewTransitionLink to={itemHref} className="block px-1 pt-3">
<div className="truncate text-[14px] font-semibold tracking-tight">{displayTitle}</div>
{showMetadata && episodeLabels?.episodeTitle ? (
<div className="text-muted-foreground mt-1 truncate text-[12px] font-medium">
{episodeLabels.episodeTitle}
</div>
) : null}
{showMetadata ? (
<div className="text-muted-foreground mt-1 text-[11px] font-medium tracking-[0.14em] uppercase">
<SortMeta item={item} sortField={sortField} />
</div>
) : null}
</ViewTransitionLink>
) : null}
</div>
);
}