2026-05-27 11:19:06 +08:00
|
|
|
import {
|
|
|
|
|
useCallback,
|
|
|
|
|
useEffect,
|
|
|
|
|
useRef,
|
|
|
|
|
useState,
|
2026-05-30 17:52:50 +08:00
|
|
|
type MouseEvent as ReactMouseEvent,
|
2026-05-27 11:19:06 +08:00
|
|
|
type PointerEvent as ReactPointerEvent,
|
|
|
|
|
} from "react";
|
2026-05-31 18:22:03 +08:00
|
|
|
import { m } from "framer-motion";
|
2026-05-30 17:52:50 +08:00
|
|
|
import { useNavigate } from "react-router-dom";
|
2026-05-28 23:09:18 +08:00
|
|
|
import { assetUrl, getJSON, itemsOrEmpty, readJSONCache } from "../api";
|
2026-05-31 18:22:03 +08:00
|
|
|
import { EASE_OUT } from "../motion";
|
2026-05-28 16:49:30 +08:00
|
|
|
import { langQuery, useI18n, type Lang } from "../i18n";
|
2026-05-27 11:19:06 +08:00
|
|
|
|
2026-05-16 00:18:22 +08:00
|
|
|
const FIGMA_ASSET_BASE = "/assets/ark-library/figma";
|
|
|
|
|
|
2026-05-17 19:38:43 +08:00
|
|
|
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;
|
|
|
|
|
|
2026-05-27 11:19:06 +08:00
|
|
|
type BannerSlide = {
|
|
|
|
|
id: string;
|
|
|
|
|
mobile: string;
|
|
|
|
|
desktop: string;
|
|
|
|
|
alt: string;
|
2026-05-28 16:49:30 +08:00
|
|
|
linkUrl?: string;
|
2026-05-27 11:19:06 +08:00
|
|
|
};
|
|
|
|
|
|
2026-05-28 16:49:30 +08:00
|
|
|
type BannerApiItem = {
|
|
|
|
|
id: number | string;
|
|
|
|
|
imageUrl: string;
|
|
|
|
|
linkUrl?: string;
|
|
|
|
|
sortOrder?: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type BannerApiResponse = {
|
|
|
|
|
items?: BannerApiItem[] | null;
|
|
|
|
|
};
|
2026-05-27 11:19:06 +08:00
|
|
|
|
2026-05-28 09:16:32 +08:00
|
|
|
const AUTOPLAY_MS = 3000;
|
2026-05-27 11:19:06 +08:00
|
|
|
const RESUME_AFTER_INTERACTION_MS = 8000;
|
2026-05-28 22:41:23 +08:00
|
|
|
const publicMenuOpenChangeEvent = "ark:public-menu-open-change";
|
2026-05-27 11:19:06 +08:00
|
|
|
|
2026-05-28 16:49:30 +08:00
|
|
|
function bannerLangParam(lang: Lang): string {
|
|
|
|
|
return langQuery(lang).toLowerCase();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 17:52:50 +08:00
|
|
|
/**
|
|
|
|
|
* 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();
|
2026-05-30 17:52:50 +08:00
|
|
|
const navigate = useNavigate();
|
2026-05-28 16:49:30 +08:00
|
|
|
const [slides, setSlides] = useState<BannerSlide[]>([]);
|
2026-05-27 11:19:06 +08:00
|
|
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
2026-05-30 17:52:50 +08:00
|
|
|
// 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);
|
2026-05-27 11:19:06 +08:00
|
|
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
|
|
|
const [autoplayPaused, setAutoplayPaused] = useState(false);
|
2026-05-28 22:41:23 +08:00
|
|
|
const [publicMenuOpen, setPublicMenuOpen] = useState(false);
|
2026-05-27 11:19:06 +08:00
|
|
|
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]);
|
|
|
|
|
|
2026-05-27 11:19:06 +08:00
|
|
|
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;
|
2026-05-31 18:22:03 +08:00
|
|
|
// 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 });
|
2026-05-27 11:19:06 +08:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-05-27 11:19:06 +08:00
|
|
|
useEffect(() => {
|
|
|
|
|
const scroller = scrollerRef.current;
|
|
|
|
|
if (!scroller) return;
|
|
|
|
|
|
|
|
|
|
const handleScroll = () => {
|
2026-05-31 18:22:03 +08:00
|
|
|
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);
|
2026-05-27 11:19:06 +08:00
|
|
|
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;
|
2026-05-27 11:19:06 +08:00
|
|
|
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]);
|
2026-05-27 11:19:06 +08:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
};
|
2026-05-30 17:52:50 +08:00
|
|
|
// 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).
|
2026-05-27 11:19:06 +08:00
|
|
|
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;
|
2026-05-30 17:52:50 +08:00
|
|
|
scroller.setPointerCapture(event.pointerId);
|
2026-05-27 11:19:06 +08:00
|
|
|
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) {
|
2026-05-30 17:52:50 +08:00
|
|
|
// Swallow the click that the browser fires right after a drag-release.
|
|
|
|
|
suppressClickRef.current = true;
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
suppressClickRef.current = false;
|
|
|
|
|
}, 0);
|
2026-05-31 18:22:03 +08:00
|
|
|
const firstChild = scroller.children[0] as HTMLElement | undefined;
|
|
|
|
|
const slideWidth = firstChild?.offsetWidth || scroller.clientWidth || 1;
|
|
|
|
|
const nearest = Math.round(scroller.scrollLeft / slideWidth);
|
2026-05-27 11:19:06 +08:00
|
|
|
const clamped = Math.max(0, Math.min(slides.length - 1, nearest));
|
|
|
|
|
scroller.style.scrollSnapType = "";
|
|
|
|
|
goTo(clamped, "smooth");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-30 17:52:50 +08:00
|
|
|
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 (
|
2026-05-31 18:40:27 +08:00
|
|
|
<div className="relative md:mx-auto md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
2026-05-27 11:19:06 +08:00
|
|
|
<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) => {
|
2026-05-31 18:22:03 +08:00
|
|
|
const isActive = index === activeIndex;
|
2026-05-28 16:49:30 +08:00
|
|
|
const image = (
|
2026-05-28 18:38:26 +08:00
|
|
|
<picture className="block aspect-video w-full overflow-hidden bg-black md:rounded-xl">
|
2026-05-27 11:19:06 +08:00
|
|
|
<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"
|
2026-05-27 11:19:06 +08:00
|
|
|
width={1280}
|
2026-05-28 18:38:26 +08:00
|
|
|
height={720}
|
2026-05-27 11:19:06 +08:00
|
|
|
loading={index === 0 ? "eager" : "lazy"}
|
|
|
|
|
decoding="async"
|
|
|
|
|
draggable={false}
|
|
|
|
|
/>
|
|
|
|
|
</picture>
|
2026-05-28 16:49:30 +08:00
|
|
|
);
|
2026-05-31 18:22:03 +08:00
|
|
|
const animatedImage = (
|
|
|
|
|
<m.div
|
|
|
|
|
className="h-full w-full origin-center will-change-transform"
|
|
|
|
|
animate={{
|
|
|
|
|
filter: isActive ? "blur(0px)" : "blur(6px)",
|
|
|
|
|
opacity: isActive ? 1 : 0.55,
|
|
|
|
|
scale: isActive ? 1 : 0.93,
|
|
|
|
|
}}
|
|
|
|
|
transition={{
|
|
|
|
|
duration: 0.45,
|
|
|
|
|
ease: EASE_OUT as unknown as number[],
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{image}
|
|
|
|
|
</m.div>
|
|
|
|
|
);
|
2026-05-28 16:49:30 +08:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={slide.id}
|
2026-05-31 18:22:03 +08:00
|
|
|
className="relative w-full shrink-0 snap-center md:w-[78%] md:first:ml-[11%] md:last:mr-[11%] lg:w-[72%] lg:first:ml-[14%] lg:last:mr-[14%] xl:w-[60%] xl:first:ml-[20%] xl:last:mr-[20%]"
|
2026-05-28 16:49:30 +08:00
|
|
|
role="group"
|
|
|
|
|
aria-roledescription="slide"
|
|
|
|
|
aria-label={`${index + 1} / ${slides.length}`}
|
|
|
|
|
>
|
|
|
|
|
{slide.linkUrl ? (
|
2026-05-30 17:52:50 +08:00
|
|
|
<a
|
|
|
|
|
href={slide.linkUrl}
|
|
|
|
|
className="block"
|
|
|
|
|
rel="noreferrer"
|
|
|
|
|
onClick={(event) => handleSlideClick(event, slide.linkUrl!)}
|
|
|
|
|
>
|
2026-05-31 18:22:03 +08:00
|
|
|
{animatedImage}
|
2026-05-28 16:49:30 +08:00
|
|
|
</a>
|
|
|
|
|
) : (
|
2026-05-31 18:22:03 +08:00
|
|
|
animatedImage
|
2026-05-28 16:49:30 +08:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2026-05-27 11:19:06 +08:00
|
|
|
</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>
|
|
|
|
|
</>
|
2026-05-27 11:19:06 +08:00
|
|
|
) : null}
|
|
|
|
|
</div>
|
2026-05-16 00:18:22 +08:00
|
|
|
);
|
|
|
|
|
}
|