Files
Arkie-Library-Frontend/src/components/FigmaBanner.tsx

370 lines
11 KiB
TypeScript
Raw Normal View History

import {
useCallback,
useEffect,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import { useNavigate } from "react-router-dom";
2026-05-28 23:09:18 +08:00
import { assetUrl, getJSON, itemsOrEmpty, readJSONCache } from "../api";
2026-05-28 16:49:30 +08:00
import { langQuery, useI18n, type Lang } from "../i18n";
2026-05-16 00:18:22 +08:00
const FIGMA_ASSET_BASE = "/assets/ark-library/figma";
export const officialRecommendationCoverFallbacks = [
2026-05-28 15:55:37 +08:00
`${FIGMA_ASSET_BASE}/official-recommendation-cover.png`,
2026-05-16 00:18:22 +08:00
] as const;
type BannerSlide = {
id: string;
mobile: string;
desktop: string;
alt: string;
2026-05-28 16:49:30 +08:00
linkUrl?: string;
};
2026-05-28 16:49:30 +08:00
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;
2026-05-28 22:41:23 +08:00
const publicMenuOpenChangeEvent = "ark:public-menu-open-change";
2026-05-28 16:49:30 +08:00
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=<id>` triggers the stream's scroll-to-post without a full
* reload. External URLs return null and fall back to a plain `<a href>`.
*/
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;
}
}
2026-05-28 16:49:30 +08:00
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,
};
});
}
2026-05-16 00:18:22 +08:00
export function FigmaBanner() {
2026-05-28 16:49:30 +08:00
const { lang } = useI18n();
const navigate = useNavigate();
2026-05-28 16:49:30 +08:00
const [slides, setSlides] = useState<BannerSlide[]>([]);
const scrollerRef = useRef<HTMLDivElement>(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);
2026-05-28 22:41:23 +08:00
const [publicMenuOpen, setPublicMenuOpen] = useState(false);
const resumeTimerRef = useRef<number | null>(null);
const dragStateRef = useRef<{
pointerId: number;
startX: number;
startScrollLeft: number;
moved: boolean;
} | null>(null);
const hasMultiple = slides.length > 1;
2026-05-28 16:49:30 +08:00
useEffect(() => {
let cancelled = false;
2026-05-28 23:09:18 +08:00
const bannersUrl = `/api/banners?lang=${bannerLangParam(lang)}`;
2026-05-28 16:49:30 +08:00
setActiveIndex(0);
2026-05-28 23:09:18 +08:00
const cachedBanners = readJSONCache<BannerApiResponse>(bannersUrl);
if (cachedBanners) setSlides(toSlides(itemsOrEmpty(cachedBanners.items)));
getJSON<BannerApiResponse>(bannersUrl)
2026-05-28 16:49:30 +08:00
.then((res) => {
if (cancelled) return;
setSlides(toSlides(itemsOrEmpty(res.items)));
})
.catch(() => {
2026-05-28 23:09:18 +08:00
if (!cancelled && !cachedBanners) setSlides([]);
2026-05-28 16:49:30 +08:00
});
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;
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);
}
},
[],
);
2026-05-28 22:41:23 +08:00
useEffect(() => {
const handlePublicMenuOpenChange = (event: Event) => {
setPublicMenuOpen(Boolean((event as CustomEvent<boolean>).detail));
};
window.addEventListener(
publicMenuOpenChangeEvent,
handlePublicMenuOpenChange,
);
return () => {
window.removeEventListener(
publicMenuOpenChangeEvent,
handlePublicMenuOpenChange,
);
};
}, []);
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(() => {
2026-05-28 22:41:23 +08:00
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);
2026-05-28 22:41:23 +08:00
}, [hasMultiple, autoplayPaused, publicMenuOpen, slides.length, goTo]);
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
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
// <a>, swallowing the link. Capture only once a real drag begins (below).
pauseAutoplay();
};
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 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");
}
};
const handleSlideClick = (
event: ReactMouseEvent<HTMLAnchorElement>,
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=<id>` 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);
};
2026-05-28 16:49:30 +08:00
if (slides.length === 0) return null;
2026-05-28 15:31:45 +08:00
const pagination = hasMultiple ? (
<div
className="flex items-center justify-center gap-1.5 md:gap-2"
role="tablist"
aria-label="Banner pagination"
>
{slides.map((slide, index) => {
const active = index === activeIndex;
return (
<button
key={slide.id}
type="button"
role="tab"
aria-selected={active}
aria-label={`Go to slide ${index + 1}`}
onClick={() => {
pauseAutoplay();
setActiveIndex(index);
goTo(index, "smooth");
}}
className={`h-1.5 rounded-full transition-all ${
active
? "w-6 bg-ark-gold"
: "w-1.5 bg-[#7C7C7C] hover:bg-white/50"
}`}
/>
);
})}
</div>
) : null;
2026-05-16 00:18:22 +08:00
return (
<div className="relative md:mx-auto md:max-w-[680px] lg:max-w-[800px]">
<div
ref={scrollerRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onTouchStart={pauseAutoplay}
className="flex cursor-grab snap-x snap-mandatory overflow-x-auto overflow-y-hidden scroll-smooth select-none touch-pan-x [-ms-overflow-style:none] [scrollbar-width:none] active:cursor-grabbing [&::-webkit-scrollbar]:hidden"
role="region"
aria-roledescription="carousel"
aria-label="ARK Library banner"
>
2026-05-28 16:49:30 +08:00
{slides.map((slide, index) => {
const image = (
2026-05-28 18:38:26 +08:00
<picture className="block aspect-video w-full overflow-hidden bg-black md:rounded-xl">
<source media="(max-width: 767px)" srcSet={slide.mobile} />
<img
src={slide.desktop}
alt={slide.alt}
2026-05-28 18:38:26 +08:00
className="pointer-events-none h-full w-full object-cover"
width={1280}
2026-05-28 18:38:26 +08:00
height={720}
loading={index === 0 ? "eager" : "lazy"}
decoding="async"
draggable={false}
/>
</picture>
2026-05-28 16:49:30 +08:00
);
return (
<div
key={slide.id}
className="relative w-full shrink-0 snap-start"
role="group"
aria-roledescription="slide"
aria-label={`${index + 1} / ${slides.length}`}
>
{slide.linkUrl ? (
<a
href={slide.linkUrl}
className="block"
rel="noreferrer"
onClick={(event) => handleSlideClick(event, slide.linkUrl!)}
>
2026-05-28 16:49:30 +08:00
{image}
</a>
) : (
image
)}
</div>
);
})}
</div>
{hasMultiple ? (
2026-05-28 15:31:45 +08:00
<>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex h-[30px] items-center justify-center bg-gradient-to-b from-[#14131900] to-[#141319] md:hidden">
<div className="pointer-events-auto">{pagination}</div>
</div>
<div className="mt-3 hidden md:block">{pagination}</div>
</>
) : null}
</div>
2026-05-16 00:18:22 +08:00
);
}