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).
This commit is contained in:
2026-08-23 03:52:21 +02:00
parent da3cb5e694
commit 37d11b602f
7 changed files with 98 additions and 35 deletions
+1 -2
View File
@@ -204,9 +204,8 @@ export default function PlexDiscover({
key={g.value}
title={g.value}
art={g.art}
topRight={<span className="text-2xs text-white/80 drop-shadow">{g.count}</span>}
onClick={() => onGenre(g.value)}
className="h-20"
className="h-24"
/>
))}
</div>
+24 -20
View File
@@ -1,9 +1,10 @@
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.
// 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,
@@ -13,24 +14,29 @@ export default function PosterTile({
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
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, 4);
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 justify-end overflow-hidden rounded-xl text-left transition ${className ?? ""}`}
style={posters.length === 0 ? { background: hueTileGradient(hueOf(title)) } : undefined}
className={`glass-hover group relative flex flex-col overflow-hidden rounded-xl text-left transition ${className ?? ""}`}
>
{posters.length > 0 && (
<div className="absolute inset-0 grid grid-cols-2 grid-rows-2">
{Array.from({ length: 4 }).map((_, i) =>
{/* 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}
@@ -43,14 +49,12 @@ export default function PosterTile({
<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>
{/* 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>
);
+15 -6
View File
@@ -1,13 +1,25 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useQuery } from "@tanstack/react-query";
import { Layers, ListMusic } from "lucide-react";
import { ListMusic } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api } from "../lib/api";
import { api, type PlexCollection } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { useDebounced } from "../lib/useDebounced";
import { LoadingState, StateMessage } from "./QueryState";
import PosterTile from "./PosterTile";
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
// collections have no movie runtime).
function collectionMeta(c: PlexCollection, t: TFunction): string {
const parts: string[] = [];
if (c.child_count != null) parts.push(t("plex.browse.items", { count: c.child_count }));
const rt = formatRuntime(c.total_runtime_s);
if (rt) parts.push(rt);
return parts.join(" · ");
}
// The Playlist / Collection browse surfaces reached from the header pill (Piece 2): a grid of tiles,
// each a way INTO that area — a playlist tile opens the playlist view, a collection tile filters the
// title grid by that collection. Both reuse the existing list endpoints (plexPlaylists / plexCollections
@@ -87,10 +99,7 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title:
key={c.id}
title={c.title}
art={c.art}
subtitle={
c.child_count != null ? t("plex.browse.items", { count: c.child_count }) : undefined
}
topRight={<Layers className="h-4 w-4 text-white/80 drop-shadow" />}
subtitle={collectionMeta(c, t)}
onClick={() => onPick(c.id, c.title)}
className="h-28"
/>