229 lines
7.9 KiB
TypeScript
229 lines
7.9 KiB
TypeScript
import { Link, useLocation } from "react-router-dom";
|
|
import { Swiper, SwiperSlide } from "swiper/react";
|
|
import { Autoplay, EffectFade } from "swiper/modules";
|
|
import type { Swiper as SwiperType } from "swiper";
|
|
import "swiper/css";
|
|
import "swiper/css/effect-fade";
|
|
import styles from "./Banner.module.css";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useStore } from "@/store";
|
|
import type { BannerConfig, NavItem, NavChild } from "@/type";
|
|
import ScrollReveal from "@/components/ScrollReveal";
|
|
|
|
const FALLBACK_GRADIENT = "linear-gradient(135deg, #1a2a4a 0%, #2d4a7c 100%)";
|
|
|
|
const VIDEO_EXT = ["mp4", "webm", "ogg"];
|
|
|
|
function mediaExtension(url: string) {
|
|
const path = url.split("?")[0] ?? "";
|
|
return path.split(".").pop()?.toLowerCase() ?? "";
|
|
}
|
|
|
|
function isVideoUrl(url: string) {
|
|
return VIDEO_EXT.includes(mediaExtension(url));
|
|
}
|
|
|
|
function BannerSlideVideo({
|
|
src,
|
|
active,
|
|
onEnded,
|
|
className,
|
|
}: {
|
|
src: string;
|
|
active: boolean;
|
|
onEnded: () => void;
|
|
className: string;
|
|
}) {
|
|
const ref = useRef<HTMLVideoElement>(null);
|
|
useEffect(() => {
|
|
const v = ref.current;
|
|
if (!v) return;
|
|
if (active) {
|
|
v.currentTime = 0;
|
|
void v.play().catch(() => { });
|
|
} else {
|
|
v.pause();
|
|
}
|
|
}, [active, src]);
|
|
return (
|
|
<video ref={ref} src={src} className={className} muted playsInline onEnded={onEnded} />
|
|
);
|
|
}
|
|
|
|
export type { BannerConfig } from "@/type";
|
|
|
|
type Props = {
|
|
title: string;
|
|
subtitle?: string;
|
|
desc?: string;
|
|
content?: string;
|
|
largedesc?: string;
|
|
titleSize?: "large" | "medium" | string;
|
|
backgroundImage: string | string[];
|
|
icon?: string;
|
|
};
|
|
|
|
export default function Banner({
|
|
title,
|
|
subtitle,
|
|
desc,
|
|
content,
|
|
largedesc,
|
|
titleSize = "large",
|
|
backgroundImage,
|
|
icon
|
|
}: Props) {
|
|
const appConfig = useStore((s) => s.appConfig);
|
|
const navItems = appConfig?.navItems ?? [];
|
|
|
|
const location = useLocation();
|
|
const images = Array.isArray(backgroundImage) ? backgroundImage : [backgroundImage];
|
|
const isCarousel = images.length > 1;
|
|
const descText = desc ?? content;
|
|
|
|
|
|
const notShowBreadcrumbPaths = ["/"];
|
|
const breadcrumbItems = useMemo(() => {
|
|
const segments = location.pathname.split("/").filter((s) => s !== "");
|
|
if (segments.length === 0) {
|
|
return [{ label: "首页", to: "/" }];
|
|
}
|
|
const paths: string[] = [];
|
|
for (let i = 0; i < segments.length; i++) {
|
|
paths.push((paths[i - 1] ?? "") + "/" + segments[i]);
|
|
}
|
|
const getLabelByPath = (path: string): string => {
|
|
if (path === "/") return navItems.find((n: NavItem) => n.path === "/")?.label ?? "首页";
|
|
const top = navItems.find((n: NavItem) => n.path === path);
|
|
if (top) return top.label;
|
|
for (const item of navItems) {
|
|
const child = item.children?.find((c: NavChild) => c.path === path);
|
|
if (child) return child.label;
|
|
}
|
|
const last = path.split("/").pop() ?? path;
|
|
return title;
|
|
};
|
|
const items = paths.map((path) => ({
|
|
label: getLabelByPath(path),
|
|
to: path,
|
|
}));
|
|
items.unshift({
|
|
label: navItems.find((n: NavItem) => n.path === "/")?.label ?? "首页",
|
|
to: "/",
|
|
});
|
|
return items;
|
|
}, [location.pathname, navItems]);
|
|
|
|
const heroContent = (
|
|
<div className={styles.heroContent} style={{ gap: "1.875rem" }}>
|
|
<ScrollReveal preset="slideUp">
|
|
{icon ? <img src={icon} alt="" className={styles.heroIcon} />
|
|
:
|
|
<h1 className={`${styles.heroTitle} ${titleSize === "medium" ? styles.heroTitleMedium : ""}`}>{title}</h1>
|
|
}
|
|
</ScrollReveal>
|
|
{subtitle && <ScrollReveal preset="slideUp" delay={0.2}>
|
|
<h2 className={styles.heroSubtitle}>{subtitle}</h2>
|
|
</ScrollReveal>}
|
|
{descText && <ScrollReveal preset="slideUp" delay={0.2}>
|
|
<p className={styles.heroDesc}>{descText}</p>
|
|
</ScrollReveal>}
|
|
{largedesc && <ScrollReveal preset="slideUp" delay={0.2}>
|
|
<p className={styles.heroLargeDesc}>{largedesc}</p>
|
|
</ScrollReveal>}
|
|
<ScrollReveal preset="fadeIn" delay={0.2}>
|
|
<div className={styles.breadcrumb}>
|
|
{!notShowBreadcrumbPaths.includes(location.pathname) &&
|
|
(breadcrumbItems ?? []).map((item, i) => (
|
|
<span key={i}>
|
|
{i > 0 && <span>{" > "}</span>}
|
|
{item.to ? <Link to={item.to}>{item.label}</Link> : <span>{item.label}</span>}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</ScrollReveal>
|
|
</div>
|
|
);
|
|
|
|
const swiperRef = useRef<SwiperType | null>(null);
|
|
const [carouselRealIndex, setCarouselRealIndex] = useState(0);
|
|
|
|
const syncAutoplayForMedia = useCallback((swiper: SwiperType, urls: string[]) => {
|
|
const idx = swiper.realIndex;
|
|
const url = urls[idx];
|
|
if (url && isVideoUrl(url)) {
|
|
swiper.autoplay?.stop();
|
|
} else {
|
|
swiper.autoplay?.start();
|
|
}
|
|
}, []);
|
|
|
|
const handleVideoEnded = useCallback(() => {
|
|
swiperRef.current?.slideNext();
|
|
}, []);
|
|
|
|
return (
|
|
<section
|
|
className={styles.hero}
|
|
style={
|
|
isCarousel
|
|
? undefined
|
|
: { backgroundImage: `url(${images[0]}), ${FALLBACK_GRADIENT}` }
|
|
}
|
|
>
|
|
{isCarousel && (
|
|
<Swiper
|
|
className={styles.bgSwiper}
|
|
modules={[Autoplay, EffectFade]}
|
|
effect="slide"
|
|
autoplay={{ delay: 5000, disableOnInteraction: true }}
|
|
allowTouchMove={false}
|
|
slidesPerView={1}
|
|
loop={true}
|
|
onSwiper={(swiper: SwiperType) => {
|
|
swiperRef.current = swiper;
|
|
setCarouselRealIndex(swiper.realIndex);
|
|
syncAutoplayForMedia(swiper, images);
|
|
}}
|
|
onSlideChange={(swiper: SwiperType) => {
|
|
setCarouselRealIndex(swiper.realIndex);
|
|
syncAutoplayForMedia(swiper, images);
|
|
}}
|
|
>
|
|
{images.map((img, i) => (
|
|
<SwiperSlide key={i}>
|
|
{isVideoUrl(img) ? (
|
|
<BannerSlideVideo
|
|
src={img}
|
|
active={carouselRealIndex === i}
|
|
onEnded={handleVideoEnded}
|
|
className={styles.bgVideo}
|
|
/>
|
|
) : (
|
|
<div
|
|
className={styles.bgSlide}
|
|
style={{
|
|
backgroundImage: `url(${img}), ${FALLBACK_GRADIENT}`,
|
|
}}
|
|
/>
|
|
)}
|
|
</SwiperSlide>
|
|
))}
|
|
</Swiper>
|
|
)}
|
|
{!isCarousel && isVideoUrl(images[0]) && (
|
|
<video
|
|
src={images[0]}
|
|
className={styles.bgVideo}
|
|
autoPlay
|
|
muted
|
|
loop
|
|
playsInline
|
|
/>
|
|
)}
|
|
<div className={styles.heroOverlay} />
|
|
{heroContent}
|
|
</section>
|
|
);
|
|
}
|