Files
Vault/components/VideoCarousel.tsx
T
twotalesanimation 81ad7e4ea9 Initial commit
2026-06-11 10:46:09 +02:00

159 lines
5.3 KiB
TypeScript

"use client";
import React, { useRef, useState, useEffect } from "react";
import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ChevronLeft, ChevronRight } from "lucide-react";
export type VideoItem = {
id: string | number;
title: string;
duration: string; // formatted like "4:12"
thumbnailUrl: string;
};
type Props = {
categoryTitle: string;
videos: VideoItem[];
visibleCount?: number; // defaults to 4
};
export default function VideoCarousel({
categoryTitle,
videos,
visibleCount = 4,
}: Props) {
const scrollerRef = useRef<HTMLDivElement | null>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
const update = () => {
setCanScrollLeft(el.scrollLeft > 0);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};
update();
el.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
return () => {
el.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
};
}, [videos]);
const scrollByPage = (direction: "left" | "right") => {
const el = scrollerRef.current;
if (!el) return;
const amount = el.clientWidth; // scroll by visible area so next "page" shows
el.scrollBy({ left: direction === "left" ? -amount : amount, behavior: "smooth" });
};
// keyboard navigation
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "ArrowLeft") scrollByPage("left");
if (e.key === "ArrowRight") scrollByPage("right");
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<section className="w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">{categoryTitle}</h2>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
aria-label="Scroll left"
onClick={() => scrollByPage("left")}
disabled={!canScrollLeft}
>
<ChevronLeft size={18} />
</Button>
<Button
variant="outline"
size="icon"
aria-label="Scroll right"
onClick={() => scrollByPage("right")}
disabled={!canScrollRight}
>
<ChevronRight size={18} />
</Button>
</div>
</div>
<div className="relative">
<div
ref={scrollerRef}
role="list"
aria-label={`${categoryTitle} videos`}
className="flex gap-4 overflow-x-auto scroll-smooth py-2 pb-4 -mx-2 px-2"
style={{ scrollbarGutter: "stable" }}
>
{videos.map((v) => (
<article
key={v.id}
role="listitem"
className="shrink-0 w-[22%] min-w-[220px] max-w-[260px]"
>
<Card className="@container/card overflow-hidden">
<div className="relative">
<img
src={v.thumbnailUrl}
alt={v.title}
className="w-full h-40 object-cover block"
loading="lazy"
/>
<Badge className="absolute right-2 bottom-2">{v.duration}</Badge>
</div>
<CardHeader>
<CardTitle className="text-sm font-medium line-clamp-2" title={v.title}>
{v.title}
</CardTitle>
<CardAction>
{/* placeholder for action, e.g., menu or save */}
</CardAction>
</CardHeader>
<CardFooter className="px-3 py-2">
<div className="text-muted-foreground text-xs">{v.duration}</div>
</CardFooter>
</Card>
</article>
))}
</div>
{/* small gradients on the sides to indicate scrollability */}
<div className="pointer-events-none absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-white/90 to-transparent dark:from-slate-900/90" />
<div className="pointer-events-none absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-white/90 to-transparent dark:from-slate-900/90" />
</div>
</section>
);
}
/*
Usage example:
import VideoCarousel, { VideoItem } from "./VideoCarousel";
const videos: VideoItem[] = [
{ id: 1, title: "Fluffy cat plays with yarn", duration: "3:45", thumbnailUrl: "/thumb1.jpg" },
{ id: 2, title: "Cat napping compilation", duration: "2:12", thumbnailUrl: "/thumb2.jpg" },
// ...more
];
<VideoCarousel categoryTitle="Cute Cats" videos={videos} visibleCount={4} />
Notes:
- This file assumes shadcn/ui components exist at the given paths. Replace imports if your project structure differs.
- Card widths are responsive: they use a percent width with a min-width to keep thumbnails readable on small screens.
- The scroller uses native smooth scrolling so it works well on touch devices and with keyboards.
*/