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).
62 lines
2.5 KiB
TypeScript
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>
|
|
);
|
|
}
|