Files
silo-server/web/src/components/MediaCarousel.tsx
T

127 lines
4.5 KiB
TypeScript

import { Children } from "react";
import type { ReactNode } from "react";
import { Link } from "react-router";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { useCarouselEmbla } from "@/hooks/useCarouselEmbla";
interface MediaCarouselProps {
title: string;
titleHref?: string;
loading?: boolean;
children: ReactNode;
skeletonCount?: number;
skeletonAspect?: string;
onViewAll?: () => void;
/** Optional actions rendered next to the title (e.g. pin button) */
headerActions?: ReactNode;
}
export default function MediaCarousel({
title,
titleHref,
loading = false,
children,
skeletonCount = 7,
skeletonAspect = "aspect-[2/3]",
onViewAll,
headerActions,
}: MediaCarouselProps) {
const { emblaRef, canScrollPrev, canScrollNext, scrollPrev, scrollNext } = useCarouselEmbla();
const slideChildren = loading
? Array.from({ length: skeletonCount }).map((_, i) => (
<div key={i} className="w-[130px] sm:w-[150px] lg:w-[178px]">
<Skeleton className={`w-full ${skeletonAspect} rounded-lg`} />
<Skeleton className="mt-2 h-4 w-3/4 rounded" />
<Skeleton className="mt-1 h-3 w-1/2 rounded" />
</div>
))
: Children.toArray(children);
return (
<section className="section-row group/carousel relative isolate">
<div className="mb-5 flex items-end justify-between gap-4 px-4 sm:px-6 lg:px-10 xl:px-12">
<div className="flex items-center gap-2">
{titleHref ? (
<Link to={titleHref} className="group/title hover:text-primary transition-colors">
<h2 className="text-foreground text-xl font-semibold tracking-tight">
{title}
<span className="text-muted-foreground group-hover/title:text-primary ml-2 text-sm transition-colors">
View
</span>
</h2>
</Link>
) : (
<h2 className="text-foreground text-xl font-semibold tracking-tight">{title}</h2>
)}
{headerActions}
</div>
{onViewAll && (
<button
onClick={onViewAll}
className="text-muted-foreground hover:text-primary text-[12px] font-semibold tracking-[0.16em] uppercase transition-all active:scale-[0.98]"
>
Explore all
</button>
)}
</div>
<div className="relative">
{/* Left edge gradient */}
{canScrollPrev && (
<div className="from-background/80 pointer-events-none absolute top-0 bottom-0 left-0 z-[5] w-10 bg-gradient-to-r to-transparent" />
)}
{/* Left arrow */}
{canScrollPrev && (
<button
type="button"
onClick={scrollPrev}
className="from-background/80 absolute top-0 bottom-0 left-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-[--duration-fast] group-hover/carousel:opacity-100 focus-visible:opacity-100"
aria-label="Scroll left"
>
<ChevronLeft className="text-foreground h-6 w-6" />
</button>
)}
<div
ref={emblaRef}
className="embla__viewport overflow-hidden pr-4 sm:pr-6 lg:pr-10 xl:pr-12"
tabIndex={0}
aria-label="Media carousel"
onKeyDown={(e) => {
if (e.key === "ArrowLeft") {
scrollPrev();
} else if (e.key === "ArrowRight") {
scrollNext();
}
}}
>
<ul
role="list"
className="embla__container flex cursor-grab list-none gap-4 pl-4 sm:pl-6 lg:gap-5 lg:pl-10 xl:pl-12"
>
{slideChildren.map((child, index) => (
<li key={index} className="embla__slide shrink-0">
{child}
</li>
))}
</ul>
</div>
{/* Right arrow */}
{canScrollNext && (
<button
type="button"
onClick={scrollNext}
className="from-background/80 absolute top-0 right-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-[--duration-fast] group-hover/carousel:opacity-100 focus-visible:opacity-100"
aria-label="Scroll right"
>
<ChevronRight className="text-foreground h-6 w-6" />
</button>
)}
</div>
</section>
);
}