A bare <img> sent a Referer that Google's image CDNs refuse and had no retry and no fallback, so a Home shelf card showed the browser's broken-image mark. ui/RemoteImage gathers what Avatar, the channel banner and the download rows had each hand-rolled: no Referer, up to three retries past the browser's negative cache, the caller's fallback (by default the card's empty frame) instead of the broken mark, the failure remembered by URL. Every picture now goes through it; a lint rule refuses a bare <img>.
74 lines
3.2 KiB
TypeScript
74 lines
3.2 KiB
TypeScript
import { type ReactNode } from "react";
|
|
import { hueOf, hueTileGradient } from "../lib/hueTile";
|
|
import { navItemAttrs } from "../lib/listNav";
|
|
import { MEDIA_IMG } from "./ui/mediaCardStyles";
|
|
import { Pressable } from "./ui/Pressable";
|
|
import { cardImage } from "../lib/plexImage";
|
|
import RemoteImage from "./ui/RemoteImage";
|
|
|
|
// A browse tile split in two: the TOP half shows up to 2 montage posters (from /api/plex/image/{key}),
|
|
// the BOTTOM half is a solid colour-hash band that carries the label — so the text is always readable
|
|
// (a full-bleed poster collage drowned it). A group with no art falls back to an all-colour tile, never
|
|
// blank. Shared by the Discover genre tiles and the Playlist/Collection browse tiles.
|
|
//
|
|
// Hover = the media card's: the shadow lifts and the art zooms inside its half, plus the
|
|
// accent edge glass-hover gives a pressable panel. It had `glass-hover` on a tile with no border, so
|
|
// the only visible change was a shadow under a colour band — the owner read it as no hover at all.
|
|
export default function PosterTile({
|
|
title,
|
|
subtitle,
|
|
topRight,
|
|
art,
|
|
onClick,
|
|
className,
|
|
navIndex,
|
|
}: {
|
|
title: string;
|
|
subtitle?: ReactNode; // a bottom sub-line under the title (e.g. "12 items · 4h 30m")
|
|
topRight?: ReactNode; // top-right corner content on the colour band (e.g. an icon)
|
|
art?: string[]; // up to 2 poster rating_keys; fewer/none falls back to the colour hash
|
|
onClick: () => void;
|
|
className?: string; // caller sets the height (e.g. "h-20" / "h-28")
|
|
navIndex?: number; // position in a keyboard-walkable grid (lib/listNav); Enter is the button's own
|
|
}) {
|
|
const posters = (art ?? []).slice(0, 2);
|
|
const band = hueTileGradient(hueOf(title));
|
|
return (
|
|
<Pressable
|
|
selectable
|
|
onClick={onClick}
|
|
title={title}
|
|
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
|
className={`group relative flex flex-col overflow-hidden rounded-xl border border-border shadow-md transition hover:border-accent/55 hover:shadow-2xl ${className ?? ""}`}
|
|
>
|
|
{/* Top half: 2 montage posters, or the colour hash when a group has no art. */}
|
|
<div
|
|
className="grid h-1/2 min-h-0 grid-cols-2 overflow-hidden"
|
|
aria-hidden
|
|
style={posters.length === 0 ? { background: band } : undefined}
|
|
>
|
|
{posters.length > 0 &&
|
|
[0, 1].map((i) =>
|
|
posters[i] ? (
|
|
<RemoteImage
|
|
key={i}
|
|
{...cardImage(`/api/plex/image/${posters[i]}`)}
|
|
alt=""
|
|
loading="lazy"
|
|
className={MEDIA_IMG}
|
|
/>
|
|
) : (
|
|
<div key={i} style={{ background: hueTileGradient(hueOf(title + i)) }} />
|
|
),
|
|
)}
|
|
</div>
|
|
{/* Bottom half: solid colour band — the label lives here so it stays readable over any art. */}
|
|
<div className="relative flex flex-1 flex-col justify-end p-2.5" style={{ background: band }}>
|
|
{topRight != null && <span className="absolute right-2 top-2">{topRight}</span>}
|
|
<div className="truncate text-sm font-semibold text-white drop-shadow">{title}</div>
|
|
{subtitle != null && <div className="truncate text-2xs text-white/80">{subtitle}</div>}
|
|
</div>
|
|
</Pressable>
|
|
);
|
|
}
|