Files
siftlode/frontend/src/components/PosterTile.tsx
T
peter 37d11b602f feat(plex): redesign browse tiles — 2-poster montage top, readable label band
The full-bleed 4-poster collage drowned the on-tile text. Split PosterTile in two: the top
half shows up to 2 montage posters, the bottom half is a solid colour-hash band that carries
the label, so the text is always readable. Genre tiles drop the count (just the label now);
collection tiles show the name on one truncated line plus a sub-line of item count · total
runtime. A group with no art falls back to an all-colour tile.

Backend: `/collections` cards gain `total_runtime_s` (a new `runtime_by_key` grouped sum) and
their montage art is capped at 2. Frontend: PosterTile reworked; a `collectionMeta` helper
builds the count·runtime sub-line via the existing formatRuntime.

Verified live: collection and genre tiles render 2 posters over a readable colour band with
the right sub-lines; colour fallback intact for art-less groups.

Gate: tsc/lint/knip/prettier clean, 423 vitest; backend ruff clean, collections tests green
(+ a new art/runtime assertion).
2026-08-23 03:52:21 +02:00

62 lines
2.5 KiB
TypeScript

import { type ReactNode } from "react";
import { hueOf, hueTileGradient } from "../lib/hueTile";
// 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.
export default function PosterTile({
title,
subtitle,
topRight,
art,
onClick,
className,
}: {
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")
}) {
const posters = (art ?? []).slice(0, 2);
const band = hueTileGradient(hueOf(title));
return (
<button
type="button"
onClick={onClick}
title={title}
className={`glass-hover group relative flex flex-col overflow-hidden rounded-xl text-left transition ${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"
aria-hidden
style={posters.length === 0 ? { background: band } : undefined}
>
{posters.length > 0 &&
[0, 1].map((i) =>
posters[i] ? (
<img
key={i}
src={`/api/plex/image/${posters[i]}`}
alt=""
loading="lazy"
className="h-full w-full object-cover"
/>
) : (
<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>
</button>
);
}