yintai-company-home/src/components/Banner.tsx

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>
);
}