159 lines
5.3 KiB
TypeScript
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.
|
|
*/
|