feat(plex): poster collages on the genre + collection browse tiles

The Browse-by-genre tiles and the Collections browse tiles were flat color-hash
gradients. Give each a 2×2 collage of its top films' poster art, with the color hash
kept as the fallback so a group with no matching art (a show-only or empty collection)
never renders blank.

Backend: a shared `top_art_keys` windowed query returns, per key (a genre value or a
collection rating_key), the top few items' rating_keys — best-rated then newest, in one
query. Wired into the Discover genre tiles and the collections union endpoint; the client
renders the posters from the existing /api/plex/image/{key} endpoint, so no new image
plumbing. Frontend: a shared PosterTile component (collage + scrim + label, color-hash
fallback) replaces the two ad-hoc colored-tile implementations.

Verified live on localdev: genre tiles and collection tiles render real poster collages,
with the color fallback correctly kicking in for groups without art.

Gate: tsc/lint/knip/prettier clean, 423 vitest; backend ruff clean, 11 discover tests
(incl. a new genre-art assertion).
This commit is contained in:
2026-08-23 03:10:36 +02:00
parent 1e5232f47a
commit da3cb5e694
9 changed files with 153 additions and 59 deletions
+57
View File
@@ -0,0 +1,57 @@
import { type ReactNode } from "react";
import { hueOf, hueTileGradient } from "../lib/hueTile";
// A browse tile: a 2×2 collage of the group's top poster art (served from /api/plex/image/{key}) under
// a dark scrim with the label, falling back to a color hash when the group has no art — so a tile never
// renders 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")
topRight?: ReactNode; // top-right corner content (a count, or an icon)
art?: string[]; // up to 4 poster rating_keys; fewer/none falls back to the color hash
onClick: () => void;
className?: string; // caller sets the height (e.g. "h-20" / "h-28")
}) {
const posters = (art ?? []).slice(0, 4);
return (
<button
type="button"
onClick={onClick}
title={title}
className={`glass-hover group relative flex flex-col justify-end overflow-hidden rounded-xl text-left transition ${className ?? ""}`}
style={posters.length === 0 ? { background: hueTileGradient(hueOf(title)) } : undefined}
>
{posters.length > 0 && (
<div className="absolute inset-0 grid grid-cols-2 grid-rows-2">
{Array.from({ length: 4 }).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>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent" />
{/* After the scrim in DOM order → paints above it without a z-index token. */}
{topRight != null && <span className="absolute right-2 top-2">{topRight}</span>}
<div className="relative p-3">
<div className="line-clamp-2 text-sm font-semibold text-white drop-shadow">{title}</div>
{subtitle != null && <div className="text-2xs text-white/80">{subtitle}</div>}
</div>
</button>
);
}