feat: enhance explore page banner functionality with state management and animation improvements

This commit is contained in:
CodingOnStar 2025-10-14 18:53:29 +08:00
parent 67bb14d3ee
commit 00b9bbff75
3 changed files with 56 additions and 20 deletions

View File

@ -1,5 +1,5 @@
import type { FC } from 'react'
import { useCallback, useMemo } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { RiArrowRightLine } from '@remixicon/react'
import { useCarousel } from '@/app/components/base/carousel'
import { IndicatorButton } from './indicator-button'
@ -27,6 +27,7 @@ type BannerItemProps = {
export const BannerItem: FC<BannerItemProps> = ({ banner, autoplayDelay }) => {
const { t } = useTranslation()
const { api, selectedIndex } = useCarousel()
const [resetKey, setResetKey] = useState(0)
const slideInfo = useMemo(() => {
const slides = api?.slideNodes() ?? []
@ -35,22 +36,33 @@ export const BannerItem: FC<BannerItemProps> = ({ banner, autoplayDelay }) => {
return { slides, totalSlides, nextIndex }
}, [api, selectedIndex])
// Reset progress when slide changes
useEffect(() => {
setResetKey(prev => prev + 1)
}, [selectedIndex])
const handleClick = useCallback(() => {
setResetKey(prev => prev + 1)
if (banner.link)
window.open(banner.link, '_blank', 'noopener,noreferrer')
}, [banner.link])
const handleIndicatorClick = useCallback((index: number) => {
setResetKey(prev => prev + 1)
api?.scrollTo(index)
}, [api])
return (
<div
className={cn(
'relative flex w-full cursor-pointer items-start overflow-hidden rounded-2xl bg-components-panel-on-panel-item-bg shadow-md transition-shadow',
'relative flex w-full cursor-pointer overflow-hidden rounded-2xl bg-components-panel-on-panel-item-bg pr-[256px] shadow-md transition-shadow',
'hover:shadow-lg',
)}
onClick={handleClick}
>
{/* Left content area */}
<div className="min-w-0 flex-1">
<div className="flex flex-col gap-3 py-6 pl-8 pr-0">
<div className="flex h-full flex-col gap-3 py-6 pl-8 pr-0">
{/* Text section */}
<div className="flex min-h-24 flex-wrap items-end gap-1 py-1">
{/* Title area */}
@ -89,20 +101,22 @@ export const BannerItem: FC<BannerItemProps> = ({ banner, autoplayDelay }) => {
selectedIndex={selectedIndex}
isNextSlide={index === slideInfo.nextIndex}
autoplayDelay={autoplayDelay}
onClick={() => api?.scrollTo(index)}
resetKey={resetKey}
onClick={() => handleIndicatorClick(index)}
/>
))}
</div>
</div>
</div>
</div>
{/* Right image area */}
<div className="flex w-[296px] shrink-0 items-stretch self-stretch p-2">
<div className="absolute right-0 top-0 flex h-full items-center p-2">
<img
src={banner.content['img-src']}
alt={banner.content.title}
className="h-full w-full rounded-xl object-cover"
className="h-full rounded-xl object-cover"
style={{ aspectRatio: '4/3' }}
/>
</div>
</div>

View File

@ -38,7 +38,7 @@ const Banner: FC = () => {
plugins={[
Carousel.Plugin.Autoplay({
delay: AUTOPLAY_DELAY,
stopOnInteraction: true,
stopOnInteraction: false,
}),
]}
className="rounded-2xl"

View File

@ -7,6 +7,7 @@ type IndicatorButtonProps = {
selectedIndex: number
isNextSlide: boolean
autoplayDelay: number
resetKey: number
onClick: () => void
}
@ -18,11 +19,26 @@ export const IndicatorButton: FC<IndicatorButtonProps> = ({
selectedIndex,
isNextSlide,
autoplayDelay,
resetKey,
onClick,
}) => {
const [progress, setProgress] = useState(0)
const animationIdRef = useRef(0)
const [isPageVisible, setIsPageVisible] = useState(true)
const frameIdRef = useRef<number | undefined>(undefined)
const pausedTimeRef = useRef(0)
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)
}
}, [])
useEffect(() => {
if (!isNextSlide) {
@ -35,26 +51,32 @@ export const IndicatorButton: FC<IndicatorButtonProps> = ({
// reset and start new animation
setProgress(0)
animationIdRef.current += 1
const startTime = Date.now()
startTimeRef.current = Date.now()
pausedTimeRef.current = 0
const animate = () => {
const elapsed = Date.now() - startTime
const newProgress = Math.min((elapsed / autoplayDelay) * PROGRESS_MAX, PROGRESS_MAX)
setProgress(newProgress)
// Only continue animation when page is visible
if (!document.hidden) {
const now = Date.now()
const elapsed = now - startTimeRef.current - pausedTimeRef.current
const newProgress = Math.min((elapsed / autoplayDelay) * PROGRESS_MAX, PROGRESS_MAX)
setProgress(newProgress)
if (newProgress < PROGRESS_MAX)
if (newProgress < PROGRESS_MAX)
frameIdRef.current = requestAnimationFrame(animate)
}
else {
frameIdRef.current = requestAnimationFrame(animate)
}
}
frameIdRef.current = requestAnimationFrame(animate)
if (!document.hidden)
frameIdRef.current = requestAnimationFrame(animate)
return () => {
if (frameIdRef.current)
cancelAnimationFrame(frameIdRef.current)
}
}, [isNextSlide, autoplayDelay])
}, [isNextSlide, autoplayDelay, resetKey, isPageVisible])
const isActive = index === selectedIndex
@ -76,7 +98,7 @@ export const IndicatorButton: FC<IndicatorButtonProps> = ({
{/* progress border for next slide */}
{isNextSlide && !isActive && (
<span
key={animationIdRef.current}
key={resetKey}
className="absolute inset-[-1px] rounded-[7px]"
style={{
background: `conic-gradient(