fix(ui): the Plex detail poster keeps its 2:3, and a long crew shows its first names
The hero's grid row is as tall as the crew column beside the text, and the poster, a flex child, stretched to it: a 2:3 picture drawn ~175x590 and cropped. It now aligns to the top, and a wide hero gives the movie and show poster a bigger rung (w-56). The crew, the hero's tallest part, shows its first 6 names and a 'Show N more' toggle; a list of 8 or fewer shows whole.
This commit is contained in:
@@ -701,3 +701,4 @@ of a full-width panel and nothing on the right.
|
|||||||
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
|
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
|
||||||
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
|
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
|
||||||
| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it |
|
| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it |
|
||||||
|
| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import PlexInfo from "./PlexInfo";
|
import PlexInfo from "./PlexInfo";
|
||||||
|
import { CREW_SHOWN } from "./plexDetailAside";
|
||||||
import type { PlexItemDetail } from "../lib/api";
|
import type { PlexItemDetail } from "../lib/api";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
@@ -250,6 +251,55 @@ describe("PlexInfo — the hero's second column (page)", () => {
|
|||||||
expect(document.querySelector(`[class*="${TWO_COLUMNS}"]`)).toBeNull();
|
expect(document.querySelector(`[class*="${TWO_COLUMNS}"]`)).toBeNull();
|
||||||
expect(follows(heading("Test Movie"), kw)).toBe(true);
|
expect(follows(heading("Test Movie"), kw)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// E31: the hero's grid row is as tall as its taller column — the crew, beside the text — and the poster,
|
||||||
|
// a flex child, stretched to it: a 2:3 picture drawn ~175×590 and cropped. It keeps its own ratio.
|
||||||
|
it("keeps the poster at its own height, not stretched to the row", () => {
|
||||||
|
wrap(<PlexInfo onPlayItem={noop} detail={full} variant="page" />);
|
||||||
|
const poster = document.querySelector<HTMLImageElement>(`img[src="${detail.thumb}"]`)!;
|
||||||
|
expect(poster.className).toContain("aspect-2/3");
|
||||||
|
expect(poster.className.split(/\s+/)).toContain("self-start");
|
||||||
|
});
|
||||||
|
|
||||||
|
// E31 (the owner's call): twelve names ran the column ~170px past the text. The first CREW_SHOWN show,
|
||||||
|
// the rest behind "Show N more"; a list barely longer shows whole.
|
||||||
|
it("shows the first crew names and opens the rest on demand", () => {
|
||||||
|
const crew = Array.from({ length: 12 }, (_, i) => ({ name: `Person ${i}`, job: "Producer" }));
|
||||||
|
wrap(
|
||||||
|
<PlexInfo
|
||||||
|
onPlayItem={noop}
|
||||||
|
detail={{ ...full, tmdb: { ...full.tmdb!, crew } }}
|
||||||
|
variant="page"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const aside = screen.getByTestId("detail-aside");
|
||||||
|
expect(aside.textContent).toContain(`Person ${CREW_SHOWN - 1}`);
|
||||||
|
expect(aside.textContent).not.toContain(`Person ${CREW_SHOWN}`);
|
||||||
|
const more = screen.getByRole("button", { name: "plex.info.crewMore" });
|
||||||
|
expect(more.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
fireEvent.click(more);
|
||||||
|
expect(aside.textContent).toContain("Person 11");
|
||||||
|
const less = screen.getByRole("button", { name: "plex.info.crewLess" });
|
||||||
|
expect(less.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
fireEvent.click(less);
|
||||||
|
expect(aside.textContent).not.toContain(`Person ${CREW_SHOWN}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a crew only a little longer than that whole, with no toggle", () => {
|
||||||
|
const crew = Array.from({ length: CREW_SHOWN + 2 }, (_, i) => ({
|
||||||
|
name: `Person ${i}`,
|
||||||
|
job: "Producer",
|
||||||
|
}));
|
||||||
|
wrap(
|
||||||
|
<PlexInfo
|
||||||
|
onPlayItem={noop}
|
||||||
|
detail={{ ...full, tmdb: { ...full.tmdb!, crew } }}
|
||||||
|
variant="page"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("detail-aside").textContent).toContain(`Person ${CREW_SHOWN + 1}`);
|
||||||
|
expect(screen.queryByRole("button", { name: "plex.info.crewMore" })).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// E28 review D1 + D2: a collection sibling is a Plex card like any other — its "…" menu, its watched
|
// E28 review D1 + D2: a collection sibling is a Plex card like any other — its "…" menu, its watched
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { notifyActionError } from "../lib/actionErrors";
|
|||||||
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
|
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
|
||||||
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
|
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
|
||||||
import {
|
import {
|
||||||
|
DETAIL_POSTER,
|
||||||
tmdbBackdropUrl,
|
tmdbBackdropUrl,
|
||||||
useCrossfadeBackdrop,
|
useCrossfadeBackdrop,
|
||||||
useDetailAside,
|
useDetailAside,
|
||||||
@@ -366,7 +367,7 @@ export default function PlexInfo({
|
|||||||
<img
|
<img
|
||||||
src={detail.thumb}
|
src={detail.thumb}
|
||||||
alt=""
|
alt=""
|
||||||
className={`shrink-0 rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : "w-32 sm:w-44"} aspect-2/3`}
|
className={`shrink-0 self-start rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : DETAIL_POSTER} aspect-2/3`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
|
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { PlexFilters } from "../lib/api";
|
import type { PlexFilters } from "../lib/api";
|
||||||
import { formatMoney, formatRuntime } from "../lib/format";
|
import { formatMoney, formatRuntime } from "../lib/format";
|
||||||
import type { AsideSection, DetailAsideState, DetailFacts } from "../lib/plexDetail";
|
import type { AsideSection, DetailAsideState, DetailFacts } from "../lib/plexDetail";
|
||||||
import { regionName, vocab } from "../lib/vocab";
|
import { regionName, vocab } from "../lib/vocab";
|
||||||
import { Filterable, PrefToggle } from "./plexDetailUi";
|
import { Filterable, PrefToggle } from "./plexDetailUi";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
import { Pressable } from "./ui/Pressable";
|
import { Pressable } from "./ui/Pressable";
|
||||||
|
|
||||||
// The detail pages' second column (E31, the owner's pick "A"): the TMDB facts and key crew of a movie or
|
// The detail pages' second column (E31, the owner's pick "A"): the TMDB facts and key crew of a movie or
|
||||||
@@ -180,6 +181,12 @@ export function DetailKeywords({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The crew shows its first CREW_SHOWN names and a "Show N more" that opens the rest: the column beside the
|
||||||
|
// text is the hero's tallest part, and twelve names ran it ~170px past the text (E31, the owner's call).
|
||||||
|
// A list only a little longer than that shows whole: a toggle that hides one or two names saves no room.
|
||||||
|
export const CREW_SHOWN = 6;
|
||||||
|
const CREW_FOLD_OVER = CREW_SHOWN + 2;
|
||||||
|
|
||||||
// The TMDB key crew (E5 S5) — Director / Writer / Producer / …, each opening their person page. It
|
// The TMDB key crew (E5 S5) — Director / Writer / Producer / …, each opening their person page. It
|
||||||
// complements the hero's Director line (which comes from Plex directly).
|
// complements the hero's Director line (which comes from Plex directly).
|
||||||
function CrewSection({
|
function CrewSection({
|
||||||
@@ -190,12 +197,15 @@ function CrewSection({
|
|||||||
onOpenPerson?: (name: string) => void;
|
onOpenPerson?: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [all, setAll] = useState(false);
|
||||||
|
const folds = crew.length > CREW_FOLD_OVER;
|
||||||
|
const shown = folds && !all ? crew.slice(0, CREW_SHOWN) : crew;
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
|
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
|
||||||
{/* As many name columns as the column holds: two in the hero's side column, one when narrow. */}
|
{/* As many name columns as the column holds: two in the hero's side column, one when narrow. */}
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-x-3 gap-y-1.5">
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-x-3 gap-y-1.5">
|
||||||
{crew.map((c, i) => (
|
{shown.map((c, i) => (
|
||||||
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
|
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
|
||||||
{onOpenPerson ? (
|
{onOpenPerson ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -213,6 +223,18 @@ function CrewSection({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{folds && (
|
||||||
|
<Button
|
||||||
|
variant={all ? "link-muted" : "link"}
|
||||||
|
aria-expanded={all}
|
||||||
|
onClick={() => setAll((v) => !v)}
|
||||||
|
className="mt-2 text-sm"
|
||||||
|
>
|
||||||
|
{all
|
||||||
|
? t("plex.info.crewLess")
|
||||||
|
: t("plex.info.crewMore", { count: crew.length - CREW_SHOWN })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useDismiss } from "../lib/useDismiss";
|
import { useDismiss } from "../lib/useDismiss";
|
||||||
|
import { DETAIL_POSTER } from "../lib/plexDetail";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
import { Pressable } from "./ui/Pressable";
|
import { Pressable } from "./ui/Pressable";
|
||||||
@@ -239,7 +240,7 @@ export function DetailHero({
|
|||||||
posterDisabled,
|
posterDisabled,
|
||||||
showPlayOverlay,
|
showPlayOverlay,
|
||||||
onPosterClick,
|
onPosterClick,
|
||||||
posterSizeClass = "w-32 sm:w-44",
|
posterSizeClass = DETAIL_POSTER,
|
||||||
customizeMenu,
|
customizeMenu,
|
||||||
aside,
|
aside,
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -277,6 +277,9 @@
|
|||||||
"audience": "Audience",
|
"audience": "Audience",
|
||||||
"keywords": "Keywords",
|
"keywords": "Keywords",
|
||||||
"crew": "Crew",
|
"crew": "Crew",
|
||||||
|
"crewMore_one": "Show {{count}} more",
|
||||||
|
"crewMore_other": "Show {{count}} more",
|
||||||
|
"crewLess": "Show less",
|
||||||
"filterValue": "Filter by “{{name}}”",
|
"filterValue": "Filter by “{{name}}”",
|
||||||
"facts": {
|
"facts": {
|
||||||
"title": "Details",
|
"title": "Details",
|
||||||
|
|||||||
@@ -277,6 +277,9 @@
|
|||||||
"audience": "Közönség",
|
"audience": "Közönség",
|
||||||
"keywords": "Kulcsszavak",
|
"keywords": "Kulcsszavak",
|
||||||
"crew": "Stáb",
|
"crew": "Stáb",
|
||||||
|
"crewMore_one": "Még {{count}} mutatása",
|
||||||
|
"crewMore_other": "Még {{count}} mutatása",
|
||||||
|
"crewLess": "Kevesebb",
|
||||||
"filterValue": "Szűrés erre: „{{name}}”",
|
"filterValue": "Szűrés erre: „{{name}}”",
|
||||||
"facts": {
|
"facts": {
|
||||||
"title": "Részletek",
|
"title": "Részletek",
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ export interface DetailFacts {
|
|||||||
|
|
||||||
export type AsideSection = "facts" | "keywords" | "crew";
|
export type AsideSection = "facts" | "keywords" | "crew";
|
||||||
|
|
||||||
|
// The movie and the show page's poster. It keeps its 2:3 (self-start, never stretched to the row: the
|
||||||
|
// second column beside the text runs taller than the poster), and a wide hero (the @container
|
||||||
|
// panel at 64rem+) gives it a bigger rung, so it is not a stamp beside a full text column (E31).
|
||||||
|
export const DETAIL_POSTER = "w-32 sm:w-44 @5xl:w-56";
|
||||||
|
|
||||||
// The detail pages' second column (plexDetailAside, E31): which of its three sections — the TMDB facts,
|
// The detail pages' second column (plexDetailAside, E31): which of its three sections — the TMDB facts,
|
||||||
// keywords and key crew — have something to show, which the reader keeps on (three per-user prefs,
|
// keywords and key crew — have something to show, which the reader keeps on (three per-user prefs,
|
||||||
// default ON, the same on the movie and the show page), and the toggles' flips.
|
// default ON, the same on the movie and the show page), and the toggles' flips.
|
||||||
|
|||||||
Reference in New Issue
Block a user