Files
silo-server/web/src/components/CastCarousel.tsx
T
Silo Server Migration cc09e14154 feat(tmdb): cache discover and external ID responses
- Add TTL caches with singleflight for DiscoverSection, DiscoverPage, and GetExternalIDs to reduce upstream TMDB calls
- Reuse CastCarousel with a fullBleed variant on RequestDetail and drop the bespoke RequestCastRow
2026-05-25 02:02:33 -04:00

123 lines
4.1 KiB
TypeScript

import { ChevronLeft, ChevronRight } from "lucide-react";
import ViewTransitionLink from "@/components/ViewTransitionLink";
import type { CastMember } from "@/api/types";
import { useCarouselEmbla } from "@/hooks/useCarouselEmbla";
import { buildPersonCatalogHref } from "@/pages/catalogSearchParams";
import { getInitials } from "@/lib/text";
import { cn } from "@/lib/utils";
interface CastCarouselProps {
cast: CastMember[];
limit?: number;
/**
* When true, the carousel adds MediaCarousel-style edge padding so it can sit
* at the top level of a page (outside `.page-shell`) and align with other
* full-bleed rows.
*/
fullBleed?: boolean;
}
export default function CastCarousel({ cast, limit = 20, fullBleed = false }: CastCarouselProps) {
const { emblaRef, canScrollPrev, canScrollNext, scrollPrev, scrollNext } = useCarouselEmbla();
if (cast.length === 0) return null;
const visible = cast
.slice()
.sort((a, b) => a.order - b.order)
.slice(0, limit);
return (
<div className="group/carousel relative">
{canScrollPrev && (
<button
type="button"
onClick={scrollPrev}
className={cn(
"from-background/90 absolute top-0 bottom-0 z-10 flex h-11 w-11 items-center justify-center self-center bg-gradient-to-r to-transparent opacity-0 transition-opacity duration-200 group-hover/carousel:opacity-100 focus-visible:opacity-100",
fullBleed ? "left-4 sm:left-6 lg:left-10 xl:left-12" : "left-0",
)}
aria-label="Scroll left"
>
<ChevronLeft className="text-foreground h-6 w-6" />
</button>
)}
<div
ref={emblaRef}
className={cn(
"embla__viewport overflow-hidden",
fullBleed && "pr-4 sm:pr-6 lg:pr-10 xl:pr-12",
)}
>
<ul
role="list"
className={cn(
"embla__container flex cursor-grab list-none gap-3",
fullBleed && "pl-4 sm:pl-6 lg:pl-10 xl:pl-12",
)}
>
{visible.map((member) => {
const href = member.person_id ? buildPersonCatalogHref(member.person_id) : null;
return (
<li key={`${member.name}-${member.order}`} className="embla__slide shrink-0">
<CastCard member={member} href={href} />
</li>
);
})}
</ul>
</div>
{canScrollNext && (
<button
type="button"
onClick={scrollNext}
className={cn(
"from-background/90 absolute top-0 bottom-0 z-10 flex h-11 w-11 items-center justify-center self-center bg-gradient-to-l to-transparent opacity-0 transition-opacity duration-200 group-hover/carousel:opacity-100 focus-visible:opacity-100",
fullBleed ? "right-4 sm:right-6 lg:right-10 xl:right-12" : "right-0",
)}
aria-label="Scroll right"
>
<ChevronRight className="text-foreground h-6 w-6" />
</button>
)}
</div>
);
}
function CastCard({ member, href }: { member: CastMember; href: string | null }) {
const inner = (
<>
<div className="media-card-image mb-2.5 aspect-[2/3] overflow-hidden rounded-lg">
{member.photo_url ? (
<img
src={member.photo_url}
alt={member.name}
className="h-full w-full object-cover transition-transform duration-300 group-hover/cast:scale-105"
loading="lazy"
/>
) : (
<div className="bg-surface text-muted-foreground flex h-full w-full items-center justify-center text-lg font-semibold">
{getInitials(member.name)}
</div>
)}
</div>
<div className="px-0.5">
<div className="text-foreground truncate text-[13px] font-medium">{member.name}</div>
{member.character ? (
<div className="text-muted-foreground truncate text-[11px]">{member.character}</div>
) : null}
</div>
</>
);
if (href) {
return (
<ViewTransitionLink to={href} className="group/cast block w-[110px]">
{inner}
</ViewTransitionLink>
);
}
return <div className="group/cast w-[110px]">{inner}</div>;
}