메인 베너너
This commit is contained in:
@@ -1,30 +1,145 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
type Banner = { id: string; title: string; imageUrl: string; linkUrl?: string | null };
|
||||
|
||||
export function HeroBanner() {
|
||||
const [banners, setBanners] = useState<Banner[]>([]);
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [progress, setProgress] = useState(0); // 0..1
|
||||
const rotationMs = 5000;
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
const startedAtRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/banners").then((r) => r.json()).then((d) => setBanners(d.banners ?? []));
|
||||
}, []);
|
||||
|
||||
const numSlides = banners.length;
|
||||
const canAutoPlay = numSlides > 1 && !isHovered;
|
||||
|
||||
const startTicker = useCallback(() => {
|
||||
if (!canAutoPlay) return;
|
||||
startedAtRef.current = performance.now();
|
||||
const tick = () => {
|
||||
const now = performance.now();
|
||||
const elapsed = now - startedAtRef.current;
|
||||
const nextProgress = Math.min(1, elapsed / rotationMs);
|
||||
setProgress(nextProgress);
|
||||
if (nextProgress >= 1) {
|
||||
setActiveIndex((i) => (i + 1) % Math.max(1, numSlides));
|
||||
startedAtRef.current = performance.now();
|
||||
setProgress(0);
|
||||
}
|
||||
rafIdRef.current = window.requestAnimationFrame(tick);
|
||||
};
|
||||
rafIdRef.current = window.requestAnimationFrame(tick);
|
||||
}, [canAutoPlay, numSlides, rotationMs]);
|
||||
|
||||
const stopTicker = useCallback(() => {
|
||||
if (rafIdRef.current) {
|
||||
window.cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if ((banners?.length ?? 0) < 2) return;
|
||||
const t = setInterval(() => setIdx((i) => (i + 1) % banners.length), 3000);
|
||||
return () => clearInterval(t);
|
||||
}, [banners]);
|
||||
const slide = banners[idx];
|
||||
if (!slide) return null;
|
||||
const content = (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<img src={slide.imageUrl} alt={slide.title} style={{ width: 72, height: 48, objectFit: "cover", borderRadius: 6 }} />
|
||||
<h1 style={{ margin: 0 }}>{slide.title}</h1>
|
||||
</div>
|
||||
);
|
||||
stopTicker();
|
||||
setProgress(0);
|
||||
startTicker();
|
||||
return () => stopTicker();
|
||||
}, [startTicker, stopTicker, activeIndex, isHovered, numSlides]);
|
||||
|
||||
const goTo = useCallback((index: number) => {
|
||||
if (numSlides === 0) return;
|
||||
const next = (index + numSlides) % numSlides;
|
||||
setActiveIndex(next);
|
||||
setProgress(0);
|
||||
startedAtRef.current = performance.now();
|
||||
}, [numSlides]);
|
||||
|
||||
const goPrev = useCallback(() => goTo(activeIndex - 1), [activeIndex, goTo]);
|
||||
const goNext = useCallback(() => goTo(activeIndex + 1), [activeIndex, goTo]);
|
||||
|
||||
const translatePercent = useMemo(() => (numSlides > 0 ? -(activeIndex * 100) : 0), [activeIndex, numSlides]);
|
||||
|
||||
if (numSlides === 0) return null;
|
||||
|
||||
return (
|
||||
<section style={{ padding: 16, background: "#f5f5f5", borderRadius: 12, marginBottom: 16 }}>
|
||||
{slide.linkUrl ? <a href={slide.linkUrl}>{content}</a> : content}
|
||||
<section
|
||||
className="relative w-full overflow-hidden rounded-xl bg-neutral-900 text-white"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
aria-roledescription="carousel"
|
||||
>
|
||||
<div className="relative h-56 sm:h-72 md:h-[420px]">
|
||||
<div
|
||||
className="flex h-full w-full transition-transform duration-500 ease-out"
|
||||
style={{ transform: `translate3d(${translatePercent}%, 0, 0)`, width: `${numSlides * 100}%` }}
|
||||
>
|
||||
{banners.map((banner) => (
|
||||
<div key={banner.id} className="relative h-full w-full shrink-0">
|
||||
{banner.linkUrl ? (
|
||||
<a href={banner.linkUrl} className="block h-full w-full focus:outline-none focus:ring-2 focus:ring-white/60" aria-label={banner.title}>
|
||||
<Image src={banner.imageUrl} alt={banner.title} fill priority className="object-cover" />
|
||||
</a>
|
||||
) : (
|
||||
<Image src={banner.imageUrl} alt={banner.title} fill priority className="object-cover" />
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/60 via-black/10 to-transparent" />
|
||||
<div className="absolute bottom-3 left-4 right-4 md:bottom-5 md:left-6 md:right-6">
|
||||
<h2 className="line-clamp-2 text-lg font-semibold md:text-2xl">{banner.title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
{numSlides > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous slide"
|
||||
onClick={goPrev}
|
||||
className="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/40 p-2 text-white backdrop-blur transition hover:bg-black/60 focus:outline-none focus:ring-2 focus:ring-white/60"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5"><path d="M15.75 19.5 8.25 12l7.5-7.5" /></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next slide"
|
||||
onClick={goNext}
|
||||
className="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/40 p-2 text-white backdrop-blur transition hover:bg-black/60 focus:outline-none focus:ring-2 focus:ring-white/60"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5"><path d="M8.25 4.5 15.75 12l-7.5 7.5" /></svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar
|
||||
{numSlides > 1 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 z-10 h-1 bg-white/20">
|
||||
<div className="h-full bg-white transition-[width] duration-100" style={{ width: `${progress * 100}%` }} />
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
{/* Pagination */}
|
||||
{numSlides > 1 && (
|
||||
<div className="absolute bottom-3 right-3 z-10 flex gap-2">
|
||||
{banners.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
aria-current={activeIndex === i ? "true" : undefined}
|
||||
onClick={() => goTo(i)}
|
||||
className={`h-2 w-2 rounded-full transition ${activeIndex === i ? "bg-white" : "bg-white/40 hover:bg-white/70"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user