Files
siftlode/frontend/src/components/PosterTile.tsx
T
peter 6d09b94bbb feat(keys): j/k and the arrows walk Home's rails and every Plex grid and rail
Second slice of the E11 keyboard layer (S4b). S4a gave the feed, the library and the channel page
`j`/`k`, the arrows and Enter; Home's shelves and the whole Plex module were still Tab-stop country —
a Plex poster is its own Tab stop, and a rail's cards sat behind every control of the cards before.

- `lib/listNav`: a list that mounts every item no longer declares a count or columns — both are
  measured off the DOM when a key is pressed, so an auto-fill grid's columns follow its width and a
  rail is one row. ↑ on a list's top row and ↓ on its bottom row go on to the neighbouring list, onto
  the item horizontally nearest the one left (owner decision D4: ←→ within a rail, ↑↓ to the rail
  above or below). The first `j` skips cards a rail has scrolled out to the left.
- `Carousel keyboardNav` registers the scroller; the scroller gets a 4px inset (against a -4px margin,
  layout unchanged) so the focus ring is not clipped, and `[data-nav-index]` a 2.5rem inline scroll
  margin so a card stepped into view clears the rail's edge fade and paging arrow.
- `ui/NavGrid`: the plain, fully mounted grid that registers itself.
- `navIndex` on PlexPosterCard / SeasonCard / EpisodeCard (the card root is the item, the poster the
  open element — the ring goes round poster and title, D2), PosterTile and Home's ShelfTile (the button
  is the item); cast, related and collection-strip buttons carry the attrs directly.
- Wired: Home's comfort and resume rails; Plex Browse titles + episodes, Discover rails + genres,
  collection view, playlists/collections browse, person known-for + filmography, show seasons, season
  episodes, cast/related strips, info-page cast + collection strips.
- `listNav.wiring.contract.test`: every `Carousel` says `keyboardNav`, every card says `navIndex`, and a
  card with `navIndex` sits inside a NavGrid / Carousel / VirtualGrid — both halves are opt-in, and
  either one missing leaves a list the keyboard silently skips.
2026-09-13 05:38:36 +02:00

66 lines
2.7 KiB
TypeScript

import { type ReactNode } from "react";
import { hueOf, hueTileGradient } from "../lib/hueTile";
import { navItemAttrs } from "../lib/listNav";
// 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,
navIndex,
}: {
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")
navIndex?: number; // position in a keyboard-walkable grid (lib/listNav); Enter is the button's own
}) {
const posters = (art ?? []).slice(0, 2);
const band = hueTileGradient(hueOf(title));
return (
<button
type="button"
onClick={onClick}
title={title}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
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>
);
}