diff --git a/web/app/components/explore/banner/banner-item.tsx b/web/app/components/explore/banner/banner-item.tsx index af2533325a..b956123d9e 100644 --- a/web/app/components/explore/banner/banner-item.tsx +++ b/web/app/components/explore/banner/banner-item.tsx @@ -1,5 +1,5 @@ import type { FC } from 'react' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { RiArrowRightLine } from '@remixicon/react' import { useCarousel } from '@/app/components/base/carousel' import { IndicatorButton } from './indicator-button' @@ -25,10 +25,15 @@ type BannerItemProps = { isPaused?: boolean } +const RESPONSIVE_BREAKPOINT = 1280 +const MAX_RESPONSIVE_WIDTH = 600 + export const BannerItem: FC = ({ banner, autoplayDelay, isPaused = false }) => { const { t } = useTranslation() const { api, selectedIndex } = useCarousel() const [resetKey, setResetKey] = useState(0) + const textAreaRef = useRef(null) + const [maxWidth, setMaxWidth] = useState(undefined) const slideInfo = useMemo(() => { const slides = api?.slideNodes() ?? [] @@ -37,27 +42,59 @@ export const BannerItem: FC = ({ banner, autoplayDelay, isPause return { slides, totalSlides, nextIndex } }, [api, selectedIndex]) + const responsiveStyle = useMemo( + () => (maxWidth !== undefined ? { maxWidth: `${maxWidth}px` } : undefined), + [maxWidth], + ) + + const incrementResetKey = useCallback(() => setResetKey(prev => prev + 1), []) + + // Update max width based on text area width when screen < 1280px + useEffect(() => { + const updateMaxWidth = () => { + if (window.innerWidth < RESPONSIVE_BREAKPOINT && textAreaRef.current) { + const textAreaWidth = textAreaRef.current.offsetWidth + setMaxWidth(Math.min(textAreaWidth, MAX_RESPONSIVE_WIDTH)) + } + else { + setMaxWidth(undefined) + } + } + + updateMaxWidth() + + const resizeObserver = new ResizeObserver(updateMaxWidth) + if (textAreaRef.current) + resizeObserver.observe(textAreaRef.current) + + window.addEventListener('resize', updateMaxWidth) + + return () => { + resizeObserver.disconnect() + window.removeEventListener('resize', updateMaxWidth) + } + }, []) + // Reset progress when slide changes useEffect(() => { - setResetKey(prev => prev + 1) - }, [selectedIndex]) + incrementResetKey() + }, [selectedIndex, incrementResetKey]) const handleClick = useCallback(() => { - setResetKey(prev => prev + 1) + incrementResetKey() if (banner.link) window.open(banner.link, '_blank', 'noopener,noreferrer') - }, [banner.link]) + }, [banner.link, incrementResetKey]) const handleIndicatorClick = useCallback((index: number) => { - setResetKey(prev => prev + 1) + incrementResetKey() api?.scrollTo(index) - }, [api]) + }, [api, incrementResetKey]) return (
@@ -67,7 +104,11 @@ export const BannerItem: FC = ({ banner, autoplayDelay, isPause {/* Text section */}
{/* Title area */} -
+

{banner.content.category}

@@ -76,15 +117,23 @@ export const BannerItem: FC = ({ banner, autoplayDelay, isPause

{/* Description area */} -
- {banner.content.description} +
+

+ {banner.content.description} +

{/* Actions section */}
{/* View more button */} -
+
@@ -94,7 +143,10 @@ export const BannerItem: FC = ({ banner, autoplayDelay, isPause
{/* Slide navigation indicators */} -
+
{slideInfo.slides.map((_: unknown, index: number) => ( = ({ banner, autoplayDelay, isPause /> ))}
-
+ {/* Right image area */}
{banner.content.title}
diff --git a/web/app/components/explore/banner/banner.tsx b/web/app/components/explore/banner/banner.tsx index e1623e59bc..bc81e4b213 100644 --- a/web/app/components/explore/banner/banner.tsx +++ b/web/app/components/explore/banner/banner.tsx @@ -1,5 +1,5 @@ import type { FC } from 'react' -import React, { useMemo, useState } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' import { Carousel } from '@/app/components/base/carousel' import { useGetBanners } from '@/service/use-explore' import Loading from '../../base/loading' @@ -8,27 +8,55 @@ import { useI18N } from '@/context/i18n' const AUTOPLAY_DELAY = 5000 const MIN_LOADING_HEIGHT = 168 +const RESIZE_DEBOUNCE_DELAY = 50 + +const LoadingState: FC = () => ( +
+ +
+) const Banner: FC = () => { const { locale } = useI18N() const { data: banners, isLoading, isError } = useGetBanners(locale) const [isHovered, setIsHovered] = useState(false) + const [isResizing, setIsResizing] = useState(false) + const resizeTimerRef = useRef(null) const enabledBanners = useMemo( () => banners?.filter((banner: BannerData) => banner.status === 'enabled') ?? [], [banners], ) - if (isLoading) { - return ( -
- -
- ) - } + const isPaused = isHovered || isResizing + + // Handle window resize to pause animation + useEffect(() => { + const handleResize = () => { + setIsResizing(true) + + if (resizeTimerRef.current) + clearTimeout(resizeTimerRef.current) + + resizeTimerRef.current = setTimeout(() => { + setIsResizing(false) + }, RESIZE_DEBOUNCE_DELAY) + } + + window.addEventListener('resize', handleResize) + + return () => { + window.removeEventListener('resize', handleResize) + if (resizeTimerRef.current) + clearTimeout(resizeTimerRef.current) + } + }, []) + + if (isLoading) + return if (isError || enabledBanners.length === 0) return null @@ -50,7 +78,11 @@ const Banner: FC = () => { {enabledBanners.map((banner: BannerData) => ( - + ))} diff --git a/web/app/components/explore/banner/indicator-button.tsx b/web/app/components/explore/banner/indicator-button.tsx index 84e52d32d9..5214fd7826 100644 --- a/web/app/components/explore/banner/indicator-button.tsx +++ b/web/app/components/explore/banner/indicator-button.tsx @@ -25,40 +25,26 @@ export const IndicatorButton: FC = ({ onClick, }) => { const [progress, setProgress] = useState(0) - const [isPageVisible, setIsPageVisible] = useState(true) const frameIdRef = useRef(undefined) const startTimeRef = useRef(0) - // Listen to page visibility changes - useEffect(() => { - const handleVisibilityChange = () => { - setIsPageVisible(!document.hidden) - } - setIsPageVisible(!document.hidden) - document.addEventListener('visibilitychange', handleVisibilityChange) - return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange) - } - }, []) + const isActive = index === selectedIndex + const shouldAnimate = !document.hidden && !isPaused useEffect(() => { if (!isNextSlide) { setProgress(0) if (frameIdRef.current) cancelAnimationFrame(frameIdRef.current) - return } - // reset and start new animation setProgress(0) startTimeRef.current = Date.now() const animate = () => { - // Only continue animation when page is visible and not paused if (!document.hidden && !isPaused) { - const now = Date.now() - const elapsed = now - startTimeRef.current + const elapsed = Date.now() - startTimeRef.current const newProgress = Math.min((elapsed / autoplayDelay) * PROGRESS_MAX, PROGRESS_MAX) setProgress(newProgress) @@ -69,22 +55,23 @@ export const IndicatorButton: FC = ({ frameIdRef.current = requestAnimationFrame(animate) } } - if (!document.hidden && !isPaused) + + if (shouldAnimate) frameIdRef.current = requestAnimationFrame(animate) return () => { if (frameIdRef.current) cancelAnimationFrame(frameIdRef.current) } - }, [isNextSlide, autoplayDelay, resetKey, isPageVisible, isPaused]) - - const isActive = index === selectedIndex + }, [isNextSlide, autoplayDelay, resetKey, isPaused]) const handleClick = useCallback((e: React.MouseEvent) => { e.stopPropagation() onClick() }, [onClick]) + const progressDegrees = progress * DEGREES_PER_PERCENT + return (