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