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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }) };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user