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
+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>
);
}