E5 S1: shared Carousel primitive + Plex card-art serializer

Wave 5a building blocks under the Seerr-inspired Plex enrichment (E5).

Carousel (frontend/src/components/Carousel.tsx): the one horizontal-rail
component, replacing the hand-rolled `flex overflow-x-auto` strips and the
home of the future Plex-Discover rows. Edge-fade mask, hover/focus scroll
buttons that appear only on the clipped side (useHScroll — the X-axis twin
of useScrollFade, same watch-the-children discipline), native scrollbar
hidden behind .no-scrollbar, reduced-motion-aware paging. First consumers:
CastStrip + RelatedStrip now scroll inside it (dedup + free affordances).
New i18n: common.seeAll / scrollLeft / scrollRight (en+hu).

Card-art serializer (backend/app/plex/cards.py): movie_card + show_card now
emit `art` (the wide backdrop, /api/plex/image/{rk}?variant=art) so rails and
the crossfading hero get a landscape image with no per-item fetch — gated on
art_key so an art-less item stays null and falls back to the poster. FE type
PlexCard.art?.

Tests: Carousel.dom.test (children/header/see-all, clipped-edge buttons +
paging), test_plex_cards (art present/absent by art_key). Added a
ResizeObserver stub to PlexPersonView.dom.test (it renders CastStrip, now a
Carousel consumer).

Gate: tsc x3 / eslint / prettier / knip / vitest 324 green; ruff green;
backend test lane (docker-compose.test.yml) green on the plex card tests.
This commit is contained in:
2026-08-20 08:50:47 +02:00
parent df60bc5ff5
commit fca3fa13ef
9 changed files with 328 additions and 7 deletions
+10
View File
@@ -6,6 +6,14 @@ router. Pure functions — no DB, no request state."""
from app.models import PlexItem, PlexShow, PlexState
# A card's wide backdrop, for a rail's landscape tile or a crossfading hero — but ONLY when the item
# actually has art. `thumb` is always emitted (its endpoint placeholders a missing poster), but an
# `art` URL for an item with no `art_key` would resolve to a broken/blank image on every card that
# tried to use it, so gate it on the stored key and let the client fall back to the poster.
def _art_url(rating_key: str, art_key: str | None) -> str | None:
return f"/api/plex/image/{rating_key}?variant=art" if art_key else None
def movie_card(it: PlexItem, st: PlexState | None) -> dict:
return {
"id": it.rating_key,
@@ -14,6 +22,7 @@ def movie_card(it: PlexItem, st: PlexState | None) -> dict:
"year": it.year,
"duration_seconds": it.duration_s,
"thumb": f"/api/plex/image/{it.rating_key}",
"art": _art_url(it.rating_key, it.art_key),
"playable": it.playable,
"status": st.status if st else "new",
"position_seconds": st.position_seconds if st else 0,
@@ -27,6 +36,7 @@ def show_card(sh: PlexShow, status: str = "new") -> dict:
"title": sh.title,
"year": sh.year,
"thumb": f"/api/plex/image/{sh.rating_key}",
"art": _art_url(sh.rating_key, sh.art_key),
"season_count": sh.child_count,
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
"status": status,
+31
View File
@@ -0,0 +1,31 @@
"""Plex card serializers (app.plex.cards) — pure model->dict projections, no DB needed.
E5 S1: movie/show cards now also emit `art` (the wide backdrop, for a Discover rail's landscape tile
and the crossfading hero), but ONLY when the item actually has an `art_key` — otherwise the URL would
resolve to a blank image on every card that tried to use it, so it must be null and let the client fall
back to the poster."""
from app.models import PlexItem, PlexShow
from app.plex.cards import movie_card, show_card
def test_movie_card_emits_art_when_art_key_present():
it = PlexItem(rating_key="m1", kind="movie", title="M", year=2020, art_key="/library/art/1")
card = movie_card(it, None)
assert card["thumb"] == "/api/plex/image/m1"
assert card["art"] == "/api/plex/image/m1?variant=art"
def test_movie_card_art_is_none_without_art_key():
it = PlexItem(rating_key="m2", kind="movie", title="M2")
assert movie_card(it, None)["art"] is None
def test_show_card_emits_art_when_art_key_present():
sh = PlexShow(rating_key="s1", title="S", art_key="/library/art/2")
assert show_card(sh)["art"] == "/api/plex/image/s1?variant=art"
def test_show_card_art_is_none_without_art_key():
sh = PlexShow(rating_key="s2", title="S2")
assert show_card(sh)["art"] is None
@@ -0,0 +1,93 @@
// @vitest-environment jsdom
//
// Contract of the shared horizontal rail. jsdom lays out nothing (scrollWidth/clientWidth are 0), so
// the overflow-driven scroll buttons only appear once we feed the scroller fake metrics and fire a
// scroll — which is exactly the affordance under test: a button shows ONLY on the clipped edge, and
// clicking it pages the scroller. i18n is key-passthrough, so asserted names are the i18n keys.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
});
import { Carousel } from "./Carousel";
beforeAll(() => {
// useHScroll wires a ResizeObserver (watch-the-children); jsdom ships none.
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
afterEach(cleanup);
function setMetrics(
el: HTMLElement,
m: { scrollWidth: number; clientWidth: number; scrollLeft: number },
) {
Object.defineProperty(el, "scrollWidth", { configurable: true, value: m.scrollWidth });
Object.defineProperty(el, "clientWidth", { configurable: true, value: m.clientWidth });
Object.defineProperty(el, "scrollLeft", {
configurable: true,
writable: true,
value: m.scrollLeft,
});
}
describe("Carousel", () => {
it("renders children and an optional header with a see-all action", () => {
const onSeeAll = vi.fn();
render(
<Carousel title="Recently added" onSeeAll={onSeeAll}>
<div>card-a</div>
<div>card-b</div>
</Carousel>,
);
expect(screen.getByText("card-a")).toBeTruthy();
expect(screen.getByRole("heading", { name: "Recently added" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "common.seeAll" }));
expect(onSeeAll).toHaveBeenCalledOnce();
});
it("omits the header when no title is given and names the scroll region", () => {
render(
<Carousel ariaLabel="Cast">
<div>x</div>
</Carousel>,
);
expect(screen.queryByRole("heading")).toBeNull();
expect(screen.getByRole("group", { name: "Cast" })).toBeTruthy();
});
it("shows only the clipped edge's button and pages the scroller on click", () => {
render(
<Carousel ariaLabel="rail">
<div>x</div>
</Carousel>,
);
const scroller = screen.getByRole("group", { name: "rail" });
const scrollBy = vi.fn();
scroller.scrollBy = scrollBy as unknown as typeof scroller.scrollBy;
// At the left boundary with more content to the right → only the right button.
setMetrics(scroller, { scrollWidth: 1000, clientWidth: 300, scrollLeft: 0 });
fireEvent.scroll(scroller);
expect(screen.queryByRole("button", { name: "common.scrollLeft" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "common.scrollRight" }));
expect(scrollBy).toHaveBeenCalled();
// Scrolled into the middle → both edges clipped → both buttons.
setMetrics(scroller, { scrollWidth: 1000, clientWidth: 300, scrollLeft: 350 });
fireEvent.scroll(scroller);
expect(screen.getByRole("button", { name: "common.scrollLeft" })).toBeTruthy();
expect(screen.getByRole("button", { name: "common.scrollRight" })).toBeTruthy();
});
});
+166
View File
@@ -0,0 +1,166 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
// Shared horizontal rail — the one place the app's "row of cards you scroll sideways" lives, replacing
// the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future
// Plex-Discover rows. Gives every consumer the same affordances for free: edge fade, hover/focus scroll
// buttons that appear only on the side that actually has more content, a native scrollbar hidden behind
// `.no-scrollbar`, and reduced-motion-aware paging. Purely presentational — the caller owns the cards.
// Prefer an instant jump when the viewer asked for reduced motion; smooth otherwise. Guarded for jsdom,
// where matchMedia is often absent (the button paging is still exercised — it just uses "auto" there).
function scrollBehavior(): ScrollBehavior {
const reduce =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
return reduce ? "auto" : "smooth";
}
// Horizontal scroll-affordance: tracks whether the scroller is clipped on the left / right so the rail
// can show a button + fade only where there's more to see. The X-axis twin of useScrollFade — same
// node-state callback ref (re-runs on attach) and same watch-the-children discipline (a rail whose
// cards load in later grows scrollWidth without ever firing a scroll event), and it also hands back the
// element so the buttons can scroll it.
function useHScroll(): {
ref: (node: HTMLElement | null) => void;
el: HTMLElement | null;
left: boolean;
right: boolean;
} {
const [el, setEl] = useState<HTMLElement | null>(null);
const [edge, setEdge] = useState({ left: false, right: false });
useEffect(() => {
if (!el) return;
const update = () => {
const left = el.scrollLeft > 1;
const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
setEdge((prev) => (prev.left === left && prev.right === right ? prev : { left, right }));
};
update();
el.addEventListener("scroll", update, { passive: true });
const ro = new ResizeObserver(update);
ro.observe(el);
for (const child of Array.from(el.children)) ro.observe(child);
const mo = new MutationObserver((records) => {
for (const rec of records) {
for (const node of Array.from(rec.addedNodes))
if (node instanceof Element) ro.observe(node);
for (const node of Array.from(rec.removedNodes))
if (node instanceof Element) ro.unobserve(node);
}
update();
});
mo.observe(el, { childList: true });
return () => {
el.removeEventListener("scroll", update);
ro.disconnect();
mo.disconnect();
};
}, [el]);
return { ref: setEl, el, left: edge.left, right: edge.right };
}
const FADE_PX = 28;
export function Carousel({
title,
onSeeAll,
seeAllLabel,
ariaLabel,
gap = "gap-3",
className,
children,
}: {
/** Optional built-in header (rails). Omit for a bare scroller inside a card that owns its own heading. */
title?: ReactNode;
/** When set, a "see all" affordance appears in the header (needs `title`). */
onSeeAll?: () => void;
seeAllLabel?: string;
/** Names the scroll region for AT; falls back to the string `title` when that's a plain string. */
ariaLabel?: string;
/** Tailwind gap class for the scroller (e.g. "gap-4"). */
gap?: string;
className?: string;
children: ReactNode;
}) {
const { t } = useTranslation();
const { ref, el, left, right } = useHScroll();
const page = useCallback(
(dir: -1 | 1) => {
if (!el) return;
// ~one viewport-worth, but never a hair scroll on a narrow rail (or 0 in a zero-width jsdom box).
const amount = Math.max(Math.round(el.clientWidth * 0.85), 200);
el.scrollBy({ left: dir * amount, behavior: scrollBehavior() });
},
[el],
);
// Fade only the clipped edge — no fade at a boundary, none when it all fits (matches useScrollFade).
const mask = `linear-gradient(to right, transparent, #000 ${left ? FADE_PX : 0}px, #000 calc(100% - ${
right ? FADE_PX : 0
}px), transparent)`;
const label = ariaLabel ?? (typeof title === "string" ? title : undefined);
const arrowBtn =
"absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " +
"bg-black/55 text-white backdrop-blur-xs border border-white/15 shadow-lg transition " +
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75";
return (
<div className={clsx("group/car relative", className)}>
{title != null && (
<div className="mb-3 flex items-baseline gap-2">
<h2 className="text-sm font-semibold">{title}</h2>
{onSeeAll && (
<button
onClick={onSeeAll}
className="ml-auto inline-flex items-center gap-0.5 text-xs text-muted transition hover:text-accent"
>
{seeAllLabel ?? t("common.seeAll")}
<ChevronRight className="h-3.5 w-3.5" />
</button>
)}
</div>
)}
<div className="relative">
{left && (
<button
type="button"
onClick={() => page(-1)}
aria-label={t("common.scrollLeft")}
className={clsx(arrowBtn, "left-1")}
>
<ChevronLeft className="h-5 w-5" />
</button>
)}
<div
ref={ref}
className={clsx("no-scrollbar flex overflow-x-auto pb-1", gap)}
style={{ maskImage: mask, WebkitMaskImage: mask }}
role="group"
aria-label={label}
>
{children}
</div>
{right && (
<button
type="button"
onClick={() => page(1)}
aria-label={t("common.scrollRight")}
className={clsx(arrowBtn, "right-1")}
>
<ChevronRight className="h-5 w-5" />
</button>
)}
</div>
</div>
);
}
@@ -5,10 +5,23 @@
// filter gesture only when no onOpenPerson is wired) and PlexPersonView's render (headshot/name hero,
// the Movies/Shows sections, and the "filter library by this person" action). i18n is key-passthrough
// and the api is mocked so the query resolves synchronously in the test.
import { describe, it, expect, afterEach, vi } from "vitest";
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
beforeAll(() => {
// CastStrip now scrolls inside a Carousel, whose useHScroll wires a ResizeObserver; jsdom ships none.
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
+5 -4
View File
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import { CheckCircle2, Circle, Film, Info, ListPlus, Play, Tv2 } from "lucide-react";
import { type CSSProperties } from "react";
import { formatRuntime } from "../lib/format";
import { Carousel } from "./Carousel";
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
@@ -301,7 +302,7 @@ export function CastStrip({
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
<div className="flex gap-4 overflow-x-auto pb-2">
<Carousel gap="gap-4" ariaLabel={t("plex.info.cast")}>
{cast.map((c, i) => {
const inner = (
<>
@@ -345,7 +346,7 @@ export function CastStrip({
</div>
);
})}
</div>
</Carousel>
</section>
);
}
@@ -364,7 +365,7 @@ export function RelatedStrip({
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
<div className="flex gap-3 overflow-x-auto pb-2">
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")}>
{related.map((r) => (
<button
key={r.id}
@@ -389,7 +390,7 @@ export function RelatedStrip({
</div>
</button>
))}
</div>
</Carousel>
</section>
);
}
+4 -1
View File
@@ -19,5 +19,8 @@
"demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time.",
"backToTop": "Back to top",
"retry": "Retry",
"loadError": "Couldn't load this — check your connection and try again."
"loadError": "Couldn't load this — check your connection and try again.",
"seeAll": "See all",
"scrollLeft": "Scroll left",
"scrollRight": "Scroll right"
}
+4 -1
View File
@@ -19,5 +19,8 @@
"demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják.",
"backToTop": "Vissza a tetejére",
"retry": "Újra",
"loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra."
"loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra.",
"seeAll": "Összes",
"scrollLeft": "Görgetés balra",
"scrollRight": "Görgetés jobbra"
}
+1
View File
@@ -42,6 +42,7 @@ export interface PlexCard {
year?: number | null;
duration_seconds?: number | null;
thumb: string;
art?: string | null; // wide backdrop (movies/shows) for rails + crossfading hero; null when no art_key
playable?: string; // direct | remux | transcode
status?: string; // new | watched | hidden
position_seconds?: number;