|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import React, { useEffect, useMemo, useState } from "react";
|
|
|
|
|
|
|
|
|
|
function hasIdentifier(
|
|
|
|
|
value: unknown
|
|
|
|
|
): value is {
|
|
|
|
|
id: string | number;
|
|
|
|
|
} {
|
|
|
|
|
if (typeof value !== "object" || value === null || !("id" in value)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const candidate = (value as { id: unknown }).id;
|
|
|
|
|
return typeof candidate === "string" || typeof candidate === "number";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SimpleCarouselProps<T> {
|
|
|
|
|
items: T[];
|
|
|
|
|
renderItem: (item: T, index: number, isActive: boolean) => React.ReactNode;
|
|
|
|
|
keyExtractor?: (item: T, index: number) => string | number;
|
|
|
|
|
className?: string;
|
|
|
|
|
autoPlay?: boolean;
|
|
|
|
|
interval?: number;
|
|
|
|
|
showControls?: boolean;
|
|
|
|
|
showIndicators?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function SimpleCarousel<T>({
|
|
|
|
|
items,
|
|
|
|
|
renderItem,
|
|
|
|
|
keyExtractor,
|
|
|
|
|
className = "",
|
|
|
|
|
autoPlay = true,
|
|
|
|
|
interval = 5000,
|
|
|
|
|
showControls = true,
|
|
|
|
|
showIndicators = true,
|
|
|
|
|
}: SimpleCarouselProps<T>) {
|
|
|
|
|
const slides = useMemo(() => items.filter(Boolean), [items]);
|
|
|
|
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!autoPlay || slides.length <= 1) return;
|
|
|
|
|
const timer = window.setInterval(() => {
|
|
|
|
|
setActiveIndex((prev) => (prev + 1) % slides.length);
|
|
|
|
|
}, interval);
|
|
|
|
|
return () => window.clearInterval(timer);
|
|
|
|
|
}, [autoPlay, interval, slides.length]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (activeIndex >= slides.length) {
|
|
|
|
|
setActiveIndex(Math.max(slides.length - 1, 0));
|
|
|
|
|
}
|
|
|
|
|
}, [activeIndex, slides.length]);
|
|
|
|
|
|
|
|
|
|
if (slides.length === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const goTo = (index: number) => {
|
|
|
|
|
setActiveIndex((prev) => {
|
|
|
|
|
if (index < 0) {
|
|
|
|
|
return slides.length - 1;
|
|
|
|
|
}
|
|
|
|
|
if (index >= slides.length) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
return index;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className={`relative overflow-hidden ${className}`}>
|
|
|
|
|
<div className="relative h-full w-full">
|
|
|
|
|
{slides.map((item, index) => {
|
|
|
|
|
const key =
|
|
|
|
|
keyExtractor?.(item, index) ??
|
|
|
|
|
(hasIdentifier(item) ? item.id : index);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={key}
|
|
|
|
|
className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${
|
|
|
|
|
index === activeIndex
|
|
|
|
|
? "opacity-100"
|
|
|
|
|
: "pointer-events-none opacity-0"
|
|
|
|
|
}`}
|
|
|
|
|
aria-hidden={index !== activeIndex}
|
|
|
|
|
>
|
|
|
|
|
{renderItem(item, index, index === activeIndex)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{showControls && slides.length > 1 && (
|
|
|
|
|
<>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => goTo(activeIndex - 1)}
|
|
|
|
|
className="absolute left-4 top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/80 text-[#0f1f39] shadow-lg transition hover:bg-white"
|
|
|
|
|
aria-label="Previous slide"
|
|
|
|
|
>
|
|
|
|
|
<span className="text-lg font-semibold">‹</span>
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => goTo(activeIndex + 1)}
|
|
|
|
|
className="absolute right-4 top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/80 text-[#0f1f39] shadow-lg transition hover:bg-white"
|
|
|
|
|
aria-label="Next slide"
|
|
|
|
|
>
|
|
|
|
|
<span className="text-lg font-semibold">›</span>
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{showIndicators && slides.length > 1 && (
|
|
|
|
|
<div className="absolute bottom-5 left-1/2 z-10 flex -translate-x-1/2 items-center gap-2">
|
|
|
|
|
{slides.map((_, index) => (
|
|
|
|
|
<button
|
|
|
|
|
key={index}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => goTo(index)}
|
|
|
|
|
className={`h-2.5 w-2.5 rounded-full transition ${
|
|
|
|
|
index === activeIndex
|
|
|
|
|
? "bg-[#118af4]"
|
|
|
|
|
: "bg-white/70 hover:bg-white"
|
|
|
|
|
}`}
|
|
|
|
|
aria-label={`Go to slide ${index + 1}`}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|