import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, } from "react"; import { m } from "framer-motion"; import { useNavigate } from "react-router-dom"; import { assetUrl, getJSON, itemsOrEmpty, readJSONCache } from "../api"; import { EASE_OUT } from "../motion"; import { langQuery, useI18n, type Lang } from "../i18n"; const FIGMA_ASSET_BASE = "/assets/ark-library/figma"; export const officialRecommendationCoverFallbacks = [ `${FIGMA_ASSET_BASE}/official-recommendation-cover.png`, ] as const; type BannerSlide = { id: string; mobile: string; desktop: string; alt: string; linkUrl?: string; }; type BannerApiItem = { id: number | string; imageUrl: string; linkUrl?: string; sortOrder?: number; }; type BannerApiResponse = { items?: BannerApiItem[] | null; }; const AUTOPLAY_MS = 3000; const RESUME_AFTER_INTERACTION_MS = 8000; const publicMenuOpenChangeEvent = "ark:public-menu-open-change"; function bannerLangParam(lang: Lang): string { return langQuery(lang).toLowerCase(); } /** * If `linkUrl` points inside this app (a relative path, or an absolute URL on * the same origin), return the router path so we can navigate client-side — * e.g. `/browse?post=` triggers the stream's scroll-to-post without a full * reload. External URLs return null and fall back to a plain ``. */ function internalPath(linkUrl: string): string | null { try { if (linkUrl.startsWith("//")) return null; if (linkUrl.startsWith("/")) return linkUrl; const url = new URL(linkUrl, window.location.origin); if (url.origin !== window.location.origin) return null; return `${url.pathname}${url.search}${url.hash}`; } catch { return null; } } function toSlides(items: BannerApiItem[]): BannerSlide[] { return [...items] .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) .filter((item) => item.imageUrl) .map((item) => { const imageUrl = assetUrl(item.imageUrl); return { id: String(item.id), mobile: imageUrl, desktop: imageUrl, alt: "", linkUrl: item.linkUrl || undefined, }; }); } export function FigmaBanner() { const { lang } = useI18n(); const navigate = useNavigate(); const [slides, setSlides] = useState([]); const scrollerRef = useRef(null); // Set when a mouse drag just moved the carousel, so the synthetic `click` // that follows pointerup doesn't navigate the slide's link. const suppressClickRef = useRef(false); const [activeIndex, setActiveIndex] = useState(0); const [autoplayPaused, setAutoplayPaused] = useState(false); const [publicMenuOpen, setPublicMenuOpen] = useState(false); const resumeTimerRef = useRef(null); const dragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean; } | null>(null); const hasMultiple = slides.length > 1; useEffect(() => { let cancelled = false; const bannersUrl = `/api/banners?lang=${bannerLangParam(lang)}`; setActiveIndex(0); const cachedBanners = readJSONCache(bannersUrl); if (cachedBanners) setSlides(toSlides(itemsOrEmpty(cachedBanners.items))); getJSON(bannersUrl) .then((res) => { if (cancelled) return; setSlides(toSlides(itemsOrEmpty(res.items))); }) .catch(() => { if (!cancelled && !cachedBanners) setSlides([]); }); return () => { cancelled = true; }; }, [lang]); 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; // Center the slide in the viewport. On mobile (full-width slides) the // offset is 0 so this matches the previous snap-start behavior. const centerOffset = (scroller.clientWidth - target.offsetWidth) / 2; const left = Math.max(0, target.offsetLeft - centerOffset); scroller.scrollTo({ left, 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 handlePublicMenuOpenChange = (event: Event) => { setPublicMenuOpen(Boolean((event as CustomEvent).detail)); }; window.addEventListener( publicMenuOpenChangeEvent, handlePublicMenuOpenChange, ); return () => { window.removeEventListener( publicMenuOpenChangeEvent, handlePublicMenuOpenChange, ); }; }, []); useEffect(() => { const scroller = scrollerRef.current; if (!scroller) return; const handleScroll = () => { const firstChild = scroller.children[0] as HTMLElement | undefined; const slideWidth = firstChild?.offsetWidth ?? scroller.clientWidth; if (slideWidth === 0) return; const next = Math.round(scroller.scrollLeft / slideWidth); setActiveIndex((prev) => (prev === next ? prev : next)); }; handleScroll(); scroller.addEventListener("scroll", handleScroll, { passive: true }); return () => scroller.removeEventListener("scroll", handleScroll); }, [slides.length]); useEffect(() => { if (!hasMultiple || autoplayPaused || publicMenuOpen) 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, publicMenuOpen, 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, }; // Don't capture the pointer yet: capturing on press makes the browser // dispatch the following `click` to the scroller instead of the slide's // , swallowing the link. Capture only once a real drag begins (below). 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.setPointerCapture(event.pointerId); 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) { // Swallow the click that the browser fires right after a drag-release. suppressClickRef.current = true; window.setTimeout(() => { suppressClickRef.current = false; }, 0); const firstChild = scroller.children[0] as HTMLElement | undefined; const slideWidth = firstChild?.offsetWidth || scroller.clientWidth || 1; const nearest = Math.round(scroller.scrollLeft / slideWidth); const clamped = Math.max(0, Math.min(slides.length - 1, nearest)); scroller.style.scrollSnapType = ""; goTo(clamped, "smooth"); } }; const handleSlideClick = ( event: ReactMouseEvent, linkUrl: string, ) => { if (suppressClickRef.current) { suppressClickRef.current = false; event.preventDefault(); return; } // Let modified clicks (new tab / window) and external links use default // browser behavior; route same-app links through the SPA so the stream's // `?post=` scroll-to-post runs without a full page reload. if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { return; } const path = internalPath(linkUrl); if (!path) return; event.preventDefault(); navigate(path); }; if (slides.length === 0) return null; // Cap the dot indicator at 10 so a long banner list never overflows the phone // width. With more slides we show a 10-dot window that follows the active // slide; each dot still maps to its real slide index. const maxDots = 10; const dotWindowStart = slides.length <= maxDots ? 0 : Math.min( Math.max(activeIndex - Math.floor(maxDots / 2), 0), slides.length - maxDots, ); const pagination = hasMultiple ? (
{slides .slice(dotWindowStart, dotWindowStart + maxDots) .map((slide, offset) => { const index = dotWindowStart + offset; const active = index === activeIndex; return (
) : null; return (
{slides.map((slide, index) => { const isActive = index === activeIndex; const image = ( {slide.alt} ); const animatedImage = ( {image} ); return (
{slide.linkUrl ? ( handleSlideClick(event, slide.linkUrl!)} > {animatedImage} ) : ( animatedImage )}
); })}
{hasMultiple ? ( <>
{pagination}
{pagination}
) : null}
); }