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.
167 lines
6.0 KiB
TypeScript
167 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
}
|