Initial commit
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
"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.
|
||||
*/
|
||||
Reference in New Issue
Block a user