diff --git a/docs/DESIGN.md b/docs/DESIGN.md index fbc82c21..606120b0 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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 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 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 | diff --git a/frontend/src/components/PlexInfo.dom.test.tsx b/frontend/src/components/PlexInfo.dom.test.tsx index 90b958bf..737e7f4d 100644 --- a/frontend/src/components/PlexInfo.dom.test.tsx +++ b/frontend/src/components/PlexInfo.dom.test.tsx @@ -27,6 +27,7 @@ vi.mock("react-i18next", async (importOriginal) => { }); import PlexInfo from "./PlexInfo"; +import { CREW_SHOWN } from "./plexDetailAside"; import type { PlexItemDetail } from "../lib/api"; afterEach(cleanup); @@ -250,6 +251,55 @@ describe("PlexInfo — the hero's second column (page)", () => { expect(document.querySelector(`[class*="${TWO_COLUMNS}"]`)).toBeNull(); 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(); + const poster = document.querySelector(`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( + , + ); + 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( + , + ); + 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 diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 1895f1f6..7d9d57f6 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -9,6 +9,7 @@ import { notifyActionError } from "../lib/actionErrors"; import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi"; import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside"; import { + DETAIL_POSTER, tmdbBackdropUrl, useCrossfadeBackdrop, useDetailAside, @@ -366,7 +367,7 @@ export default function PlexInfo({
diff --git a/frontend/src/components/plexDetailAside.tsx b/frontend/src/components/plexDetailAside.tsx index 977dbc38..3c312a76 100644 --- a/frontend/src/components/plexDetailAside.tsx +++ b/frontend/src/components/plexDetailAside.tsx @@ -1,10 +1,11 @@ -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { PlexFilters } from "../lib/api"; import { formatMoney, formatRuntime } from "../lib/format"; import type { AsideSection, DetailAsideState, DetailFacts } from "../lib/plexDetail"; import { regionName, vocab } from "../lib/vocab"; import { Filterable, PrefToggle } from "./plexDetailUi"; +import { Button } from "./ui/Button"; 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 @@ -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 // complements the hero's Director line (which comes from Plex directly). function CrewSection({ @@ -190,12 +197,15 @@ function CrewSection({ onOpenPerson?: (name: string) => void; }) { 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 (

{t("plex.info.crew")}

{/* As many name columns as the column holds: two in the hero's side column, one when narrow. */}
- {crew.map((c, i) => ( + {shown.map((c, i) => (
{onOpenPerson ? ( ))}
+ {folds && ( + + )}
); } diff --git a/frontend/src/components/plexDetailUi.tsx b/frontend/src/components/plexDetailUi.tsx index bab037fc..1eb6b547 100644 --- a/frontend/src/components/plexDetailUi.tsx +++ b/frontend/src/components/plexDetailUi.tsx @@ -9,6 +9,7 @@ import { type LucideIcon, } from "lucide-react"; import { useDismiss } from "../lib/useDismiss"; +import { DETAIL_POSTER } from "../lib/plexDetail"; import { Button } from "./ui/Button"; import { IconButton } from "./ui/IconButton"; import { Pressable } from "./ui/Pressable"; @@ -239,7 +240,7 @@ export function DetailHero({ posterDisabled, showPlayOverlay, onPosterClick, - posterSizeClass = "w-32 sm:w-44", + posterSizeClass = DETAIL_POSTER, customizeMenu, aside, children, diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 6df97c52..721b4ac9 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -277,6 +277,9 @@ "audience": "Audience", "keywords": "Keywords", "crew": "Crew", + "crewMore_one": "Show {{count}} more", + "crewMore_other": "Show {{count}} more", + "crewLess": "Show less", "filterValue": "Filter by “{{name}}”", "facts": { "title": "Details", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 20165d44..4ada8f17 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -277,6 +277,9 @@ "audience": "Közönség", "keywords": "Kulcsszavak", "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}}”", "facts": { "title": "Részletek", diff --git a/frontend/src/lib/plexDetail.ts b/frontend/src/lib/plexDetail.ts index c9a894d9..3834d464 100644 --- a/frontend/src/lib/plexDetail.ts +++ b/frontend/src/lib/plexDetail.ts @@ -42,6 +42,11 @@ export interface DetailFacts { 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, // 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.