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
+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"
/>