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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user