feat(design): one media card, and Home's hero and shelves as cards (E28 S3h)

- ui/MediaCard + mediaCardStyles: one frame for every video, Plex title and
  Home tile; the meta line pinned to the bottom edge, the length a badge on
  the picture, the media Play only where the click plays, the hover row at
  the picture's bottom-right (the owner's pick C).
- Shelf density: on any strip a one-line title over one meta line; mixed
  Home shelves are landscape; rails turn content-visibility off (a card's
  reserved height had become the strip's). Home slot 232px, so the Play
  and the hover row no longer touch; a landscape shelf takes the small Play.
- Home hero is a card (hover row, the whole menu, a touch "…"), three
  quarters wide and centred; channel / playlist tiles get Play, open and
  "…"; a show tile acts on its next episode, ⓘ opens the show.
- Plex preview: a ~0.9 s mouse rest opens a passive panel with the whole
  title, facts and synopsis (popover `pointerOnly`); the synopsis and title
  tooltips are gone.
- Compact list row B: the title leads, "…" at the end, toggles over the
  date on hover.
- Media-info layout B (collections, crew, cast beside the facts sidebar;
  an empty column is not drawn); filtering values are chips there and on
  the show page; "Cast" over the cast-only strips.
- Browse tiles hover like a card; prose capped by the `measure` utility.
This commit is contained in:
2026-09-27 00:54:19 +02:00
parent 6653d81a80
commit a453b77f04
30 changed files with 1882 additions and 769 deletions
+172 -183
View File
@@ -22,6 +22,8 @@ import { SURFACE } from "./ui/surfaces";
import { Badge } from "./ui/Badge";
import { IconLink } from "./ui/LinkButton";
import { PageHeading } from "./ui/Headings";
import clsx from "clsx";
import { PlexPosterCard } from "./plexCards";
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
@@ -156,6 +158,7 @@ function RatingsRow({
// A structured facts panel (E5 S5): release / runtime / cert / budget / revenue / studios / countries,
// pulling from the item's own fields + the persisted TMDB block. Rendered only when it has something.
// What filters the library is an edged chip, a plain fact stays flat text (E28 S3h, the S3d1 rule).
function FactsPanel({
detail,
onFilter,
@@ -175,9 +178,9 @@ function FactsPanel({
label: t("plex.info.facts.release"),
node: (
<Filterable
tag
title={onFilter ? t("plex.info.filterYear", { year }) : undefined}
onClick={onFilter ? () => onFilter({ yearMin: year, yearMax: year }) : undefined}
className="hover:text-accent"
>
{year}
</Filterable>
@@ -195,10 +198,7 @@ function FactsPanel({
key: "cert",
label: t("plex.info.facts.cert"),
node: (
<Filterable
onClick={onFilter ? () => onFilter({ contentRatings: [cert] }) : undefined}
className="hover:text-accent"
>
<Filterable tag onClick={onFilter ? () => onFilter({ contentRatings: [cert] }) : undefined}>
{cert}
</Filterable>
),
@@ -212,17 +212,15 @@ function FactsPanel({
key: "studios",
label: t("plex.info.facts.studios"),
node: (
<span className="flex flex-wrap gap-x-1.5 gap-y-0.5">
{studios.map((s, i) => (
<span key={s}>
<Filterable
onClick={onFilter ? () => onFilter({ studios: [s] }) : undefined}
className="hover:text-accent"
>
{s}
</Filterable>
{i < studios.length - 1 ? "," : ""}
</span>
<span className="flex flex-wrap gap-1.5">
{studios.map((s) => (
<Filterable
key={s}
tag
onClick={onFilter ? () => onFilter({ studios: [s] }) : undefined}
>
{s}
</Filterable>
))}
</span>
),
@@ -237,7 +235,7 @@ function FactsPanel({
return (
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.facts.title")}</h2>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
<dl className="grid grid-cols-[auto_1fr] items-baseline gap-x-4 gap-y-2 text-sm">
{rows.map((r) => (
<div key={r.key} className="contents">
<dt className="text-muted">{r.label}</dt>
@@ -291,9 +289,8 @@ function CrewPanel({
return (
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
{/* Two columns inside the (now narrow) side box — viewport media-queries can't see the box width,
so a fixed small column count keeps names from over-spreading. */}
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
{/* In the main column (layout B): as many name columns as its width holds. */}
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-x-3 gap-y-1.5">
{crew.map((c, i) => (
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
{onOpenPerson ? (
@@ -536,7 +533,8 @@ export default function PlexInfo({
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
{detail.year != null && (
<Filterable
className={mutedCls}
tag
onDark={overlay}
title={onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined}
onClick={
onFilter
@@ -549,7 +547,8 @@ export default function PlexInfo({
)}
{detail.content_rating && (
<Filterable
className={mutedCls}
tag
onDark={overlay}
onClick={
onFilter
? () => onFilter({ contentRatings: [detail.content_rating!] })
@@ -564,7 +563,8 @@ export default function PlexInfo({
) : null}
{detail.studio && (
<Filterable
className={mutedCls}
tag
onDark={overlay}
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
>
{detail.studio}
@@ -608,25 +608,24 @@ export default function PlexInfo({
)}
{detail.directors.length > 0 && (
<p className={`mt-2 text-sm ${mutedCls}`}>
{t("plex.info.director")}:{" "}
{detail.directors.map((d, i) => (
<span key={d}>
<Filterable
className="font-medium"
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
>
{d}
</Filterable>
{i < detail.directors.length - 1 ? ", " : ""}
</span>
<p className={`mt-2 flex flex-wrap items-center gap-1.5 text-sm ${mutedCls}`}>
<span>{t("plex.info.director")}:</span>
{detail.directors.map((d) => (
<Filterable
key={d}
tag
onDark={overlay}
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
>
{d}
</Filterable>
))}
</p>
)}
{detail.summary && (
<p
className={`mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
className={`measure mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
>
{detail.summary}
</p>
@@ -679,100 +678,18 @@ export default function PlexInfo({
</div>
{/* End hero panel. */}
{/* Enriched detail (E5 S5) — page variant only; the overlay stays lean. Details / Keywords /
Crew sit side by side (each its natural height, items-start) so no single box hogs the full
width with dead space; each is independently hideable from the customize menu. */}
{!overlay && (showFacts || showKeywords || showCrew) && (
<div className="grid items-start gap-3 sm:grid-cols-2 xl:grid-cols-3">
{showFacts && <FactsPanel detail={detail} onFilter={onFilter} />}
{showKeywords && (
<KeywordsPanel keywords={detail.tmdb?.keywords ?? []} onFilter={onFilter} />
)}
{showCrew && <CrewPanel crew={detail.tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />}
</div>
)}
{/* Cast row (toggleable), circular photos. */}
{cast.length > 0 && (
<div className={overlay ? "mt-5" : `${SURFACE.panel} p-4 sm:p-5`}>
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
{t("plex.info.cast")}
</h2>
<Carousel
ariaLabel={t("plex.info.cast")}
gap="gap-3"
keyboardNav={!!(onOpenPerson || onFilter)}
>
{cast.map((c, i) => {
const clickable = !!(onOpenPerson || onFilter);
const inner = (
<>
<div
className={`mx-auto mb-1 h-20 w-20 overflow-hidden rounded-full border ${
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
} ${clickable ? "group-hover/cast:border-accent" : ""}`}
>
{c.thumb ? (
<img src={c.thumb} alt="" className="h-full w-full object-cover" />
) : (
<div
className={`grid h-full w-full place-items-center text-lg opacity-40 ${overlay ? "text-white" : ""}`}
>
{c.name.charAt(0)}
</div>
)}
</div>
<div
className={`truncate text-xs font-medium ${overlay ? "text-white/90" : ""} ${
clickable ? "group-hover/cast:text-accent" : ""
}`}
>
{c.name}
</div>
{c.role && (
<div
className={`truncate text-2xs ${overlay ? "text-white/55" : "text-muted"}`}
>
{c.role}
</div>
)}
</>
);
return clickable ? (
<Pressable
selectable
key={i}
align="center"
{...navItemAttrs(i)}
onClick={() =>
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
}
title={
onOpenPerson
? t("plex.info.viewPerson", { name: c.name })
: t("plex.info.filterActor", { name: c.name })
}
className="group/cast w-20 shrink-0"
>
{inner}
</Pressable>
) : (
<div key={i} className="w-20 shrink-0 text-center">
{inner}
</div>
);
})}
</Carousel>
</div>
)}
{/* (Full crew now lives in the enriched Details/Keywords/Crew grid above the cast row.) */}
{/* Collection strips — the other titles in this movie's collection(s), directly playable.
Each source bucket (Collections / IMDb / TMDb / smart / …) is independently hideable. */}
{detail.collections
?.filter((col) => !hiddenSources.includes(col.source))
.map((col) => (
{/* Below the hero (E28 S3h, the owner's layout B, 2026-09-26): a main column — the collection
strips first (the owner's most used), then the crew, then the cast — beside a narrow
sidebar of facts (details, keywords), the TMDB/IMDb shape. Every section is per-user
toggleable, so the layout is built from what is ON: a column with nothing in it is not
rendered and the other takes the full width; inside a column the sections stack, so a
hidden one leaves no hole. Narrow windows stack the sidebar under the main column. The
overlay (over the running player) stays lean: its strips and its cast, stacked. */}
{(() => {
const collections = (detail.collections ?? []).filter(
(col) => !hiddenSources.includes(col.source),
);
const strips = collections.map((col) => (
<div key={col.id} className={overlay ? "mt-6" : `${SURFACE.panel} p-4 sm:p-5`}>
<div className="mb-2 flex items-center justify-between gap-3">
<h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
@@ -795,62 +712,134 @@ export default function PlexInfo({
</div>
<Carousel ariaLabel={col.title} gap="gap-3" keyboardNav={!!onPlayItem}>
{col.items.map((m, i) => (
<Pressable
selectable
key={m.id}
{...navItemAttrs(i)}
onClick={() => onPlayItem?.(m.id)}
disabled={!onPlayItem}
className="group/col w-28 shrink-0"
>
<div
className={`relative aspect-2/3 overflow-hidden rounded-lg border ${
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
}`}
>
<img
src={m.thumb}
alt=""
className="h-full w-full object-cover transition group-hover/col:scale-105"
/>
{m.status === "watched" && (
<span className="absolute right-1 top-1 rounded-sm bg-black/70 px-1 text-[0.5625rem] text-white">
✓
</span>
)}
{onPlayItem && (
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover/col:opacity-100">
<Play className="h-8 w-8 text-white" fill="currentColor" />
</div>
)}
{(m.position_seconds ?? 0) > 0 &&
m.status !== "watched" &&
m.duration_seconds ? (
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-black/40">
<div
className="h-full bg-accent"
style={{
width: `${Math.min(100, ((m.position_seconds ?? 0) / m.duration_seconds) * 100)}%`,
}}
/>
</div>
) : null}
</div>
<div
className={`mt-1 truncate text-xs font-medium ${overlay ? "text-white/90" : ""}`}
>
{m.title}
</div>
{m.year ? (
<div className={`text-2xs ${overlay ? "text-white/55" : "text-muted"}`}>
{m.year}
</div>
) : null}
</Pressable>
// The one media card, as on every other strip (E28 S3h). A grid cell, so the
// card fills the slot the carousel stretches to its tallest card.
<div key={m.id} className="grid w-37.5 shrink-0 grid-cols-1">
<PlexPosterCard
card={{ ...m, type: "movie" }}
shelf
navIndex={i}
onOpen={() => onPlayItem?.(m.id)}
/>
</div>
))}
</Carousel>
</div>
))}
));
const castRow = cast.length > 0 && (
<div className={overlay ? "mt-5" : `${SURFACE.panel} p-4 sm:p-5`}>
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
{t("plex.info.cast")}
</h2>
<Carousel
ariaLabel={t("plex.info.cast")}
gap="gap-3"
keyboardNav={!!(onOpenPerson || onFilter)}
>
{cast.map((c, i) => {
const clickable = !!(onOpenPerson || onFilter);
const inner = (
<>
<div
className={`mx-auto mb-1 h-20 w-20 overflow-hidden rounded-full border ${
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
} ${clickable ? "group-hover/cast:border-accent" : ""}`}
>
{c.thumb ? (
<img src={c.thumb} alt="" className="h-full w-full object-cover" />
) : (
<div
className={`grid h-full w-full place-items-center text-lg opacity-40 ${overlay ? "text-white" : ""}`}
>
{c.name.charAt(0)}
</div>
)}
</div>
<div
className={`truncate text-xs font-medium ${overlay ? "text-white/90" : ""} ${
clickable ? "group-hover/cast:text-accent" : ""
}`}
>
{c.name}
</div>
{c.role && (
<div
className={`truncate text-2xs ${overlay ? "text-white/55" : "text-muted"}`}
>
{c.role}
</div>
)}
</>
);
return clickable ? (
<Pressable
selectable
key={i}
align="center"
{...navItemAttrs(i)}
onClick={() =>
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
}
title={
onOpenPerson
? t("plex.info.viewPerson", { name: c.name })
: t("plex.info.filterActor", { name: c.name })
}
className="group/cast w-20 shrink-0"
>
{inner}
</Pressable>
) : (
<div key={i} className="w-20 shrink-0 text-center">
{inner}
</div>
);
})}
</Carousel>
</div>
);
if (overlay)
return (
<>
{strips}
{castRow}
</>
);
const crewBox = showCrew && hasCrew && (
<CrewPanel crew={tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />
);
const factsBox = showFacts && hasFacts && (
<FactsPanel detail={detail} onFilter={onFilter} />
);
const keywordsBox = showKeywords && hasKeywords && (
<KeywordsPanel keywords={tmdb?.keywords ?? []} onFilter={onFilter} />
);
const hasMain = strips.length > 0 || !!crewBox || !!castRow;
const hasSide = !!factsBox || !!keywordsBox;
if (!hasMain && !hasSide) return null;
return (
<div
className={clsx(
"grid items-start gap-3",
hasMain && hasSide && "lg:grid-cols-[minmax(0,1fr)_20rem]",
)}
>
{hasMain && (
<div className="flex min-w-0 flex-col gap-3">
{strips}
{crewBox}
{castRow}
</div>
)}
{hasSide && (
<div className="flex min-w-0 flex-col gap-3">
{factsBox}
{keywordsBox}
</div>
)}
</div>
);
})()}
</div>
{editorOpen && library && (
<PlexCollectionEditor