diff --git a/public/assets/ark-library/figma/banner-mobile-1.png b/public/assets/ark-library/figma/banner-mobile-1.png new file mode 100644 index 0000000..7896dc4 Binary files /dev/null and b/public/assets/ark-library/figma/banner-mobile-1.png differ diff --git a/src/components/FigmaBanner.tsx b/src/components/FigmaBanner.tsx index 4af3b59..072ff54 100644 --- a/src/components/FigmaBanner.tsx +++ b/src/components/FigmaBanner.tsx @@ -1,3 +1,11 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type PointerEvent as ReactPointerEvent, +} from "react"; + const FIGMA_ASSET_BASE = "/assets/ark-library/figma"; export const officialRecommendationCoverFallbacks = [ @@ -8,30 +16,217 @@ export const officialRecommendationCoverFallbacks = [ `${FIGMA_ASSET_BASE}/official-recommendation-5.png`, ] as const; +type BannerSlide = { + id: string; + mobile: string; + desktop: string; + alt: string; +}; + +const BANNER_SLIDES: BannerSlide[] = [ + { + id: "ark-banner-1", + mobile: `${FIGMA_ASSET_BASE}/banner-mobile-1.png`, + desktop: `${FIGMA_ASSET_BASE}/banner-desktop.png`, + alt: "", + }, + { + id: "ark-banner-2", + mobile: `${FIGMA_ASSET_BASE}/banner-375.png`, + desktop: `${FIGMA_ASSET_BASE}/banner-wide.png`, + alt: "", + }, +]; + +const AUTOPLAY_MS = 5000; +const RESUME_AFTER_INTERACTION_MS = 8000; + export function FigmaBanner() { + const slides = BANNER_SLIDES; + const scrollerRef = useRef(null); + const [activeIndex, setActiveIndex] = useState(0); + const [autoplayPaused, setAutoplayPaused] = useState(false); + const resumeTimerRef = useRef(null); + const dragStateRef = useRef<{ + pointerId: number; + startX: number; + startScrollLeft: number; + moved: boolean; + } | null>(null); + const hasMultiple = slides.length > 1; + + const goTo = useCallback((index: number, behavior: ScrollBehavior) => { + const scroller = scrollerRef.current; + if (!scroller) return; + const target = scroller.children[index] as HTMLElement | undefined; + if (!target) return; + scroller.scrollTo({ left: target.offsetLeft, behavior }); + }, []); + + const pauseAutoplay = useCallback(() => { + setAutoplayPaused(true); + if (resumeTimerRef.current !== null) { + window.clearTimeout(resumeTimerRef.current); + } + resumeTimerRef.current = window.setTimeout(() => { + setAutoplayPaused(false); + resumeTimerRef.current = null; + }, RESUME_AFTER_INTERACTION_MS); + }, []); + + useEffect( + () => () => { + if (resumeTimerRef.current !== null) { + window.clearTimeout(resumeTimerRef.current); + } + }, + [], + ); + + useEffect(() => { + const scroller = scrollerRef.current; + if (!scroller) return; + + const handleScroll = () => { + const width = scroller.clientWidth; + if (width === 0) return; + const next = Math.round(scroller.scrollLeft / width); + setActiveIndex((prev) => (prev === next ? prev : next)); + }; + + handleScroll(); + scroller.addEventListener("scroll", handleScroll, { passive: true }); + return () => scroller.removeEventListener("scroll", handleScroll); + }, [slides.length]); + + useEffect(() => { + if (!hasMultiple || autoplayPaused) return; + const timer = window.setInterval(() => { + setActiveIndex((prev) => { + const next = (prev + 1) % slides.length; + goTo(next, "smooth"); + return next; + }); + }, AUTOPLAY_MS); + return () => window.clearInterval(timer); + }, [hasMultiple, autoplayPaused, slides.length, goTo]); + + const handlePointerDown = (event: ReactPointerEvent) => { + if (event.pointerType !== "mouse") return; + const scroller = scrollerRef.current; + if (!scroller) return; + dragStateRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startScrollLeft: scroller.scrollLeft, + moved: false, + }; + scroller.setPointerCapture(event.pointerId); + pauseAutoplay(); + }; + + const handlePointerMove = (event: ReactPointerEvent) => { + const drag = dragStateRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + const scroller = scrollerRef.current; + if (!scroller) return; + const dx = event.clientX - drag.startX; + if (!drag.moved && Math.abs(dx) > 4) { + drag.moved = true; + scroller.style.scrollSnapType = "none"; + } + if (drag.moved) { + scroller.scrollLeft = drag.startScrollLeft - dx; + } + }; + + const endDrag = (event: ReactPointerEvent) => { + const drag = dragStateRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + const scroller = scrollerRef.current; + dragStateRef.current = null; + if (!scroller) return; + if (scroller.hasPointerCapture(event.pointerId)) { + scroller.releasePointerCapture(event.pointerId); + } + if (drag.moved) { + const width = scroller.clientWidth || 1; + const nearest = Math.round(scroller.scrollLeft / width); + const clamped = Math.max(0, Math.min(slides.length - 1, nearest)); + scroller.style.scrollSnapType = ""; + goTo(clamped, "smooth"); + } + }; + return ( - - - - - - +
+
+ {slides.map((slide, index) => ( +
+ + + {slide.alt} + +
+ ))} +
+ + {hasMultiple ? ( +
+ {slides.map((slide, index) => { + const active = index === activeIndex; + return ( +
+ ) : null} +
); }