+
+
+
+ {category}
+
+
+ {title}
+
-
-
-
-
-
-
- {t(($) => $['banner.viewMore'], { ns: 'explore' })}
-
-
-
-
- {/* Slide navigation indicators */}
-
- {indicatorItems.map(({ id, index }) => (
- handleIndicatorClick(index)}
- />
- ))}
-
-
-
+
-
+
+ {banner.link && (
+
+ {title}
+
+ )}
+
)
}
diff --git a/web/app/components/explore/banner/banner.tsx b/web/app/components/explore/banner/banner.tsx
index 8cb28dfdecb..5cbfd7c36f1 100644
--- a/web/app/components/explore/banner/banner.tsx
+++ b/web/app/components/explore/banner/banner.tsx
@@ -1,149 +1,217 @@
+import type { ComponentProps, FocusEvent } from 'react'
import type { Banner as BannerType } from '@/models/app'
import { useAtomValue } from 'jotai'
-import * as React from 'react'
-import { useEffect, useMemo, useRef, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import { Carousel, useCarousel } from '@/app/components/base/carousel'
import { userProfileAtom } from '@/context/account-state'
import { useLocale } from '@/context/i18n'
import { BannerItem } from './banner-item'
+import { IndicatorButton } from './indicator-button'
const AUTOPLAY_DELAY = 5000
-const RESIZE_DEBOUNCE_DELAY = 50
+const CAROUSEL_OPTIONS = {
+ loop: true,
+ watchDrag: (_api, event) =>
+ !(event.target instanceof Element && event.target.closest('[data-carousel-control]')),
+} satisfies NonNullable
['opts']>
-type BannerImpressionTrackerProps = {
+type BannerCarouselContentProps = {
banners: BannerType[]
accountId?: string
language: string
- trackedBannerIdsRef: React.MutableRefObject>
}
-function BannerImpressionTracker({
- banners,
- accountId,
- language,
- trackedBannerIdsRef,
-}: BannerImpressionTrackerProps) {
- const { selectedIndex } = useCarousel()
+function BannerCarouselContent({ banners, accountId, language }: BannerCarouselContentProps) {
+ const { t } = useTranslation()
+ const { api, selectedIndex } = useCarousel()
+ const [isPlaying, setIsPlaying] = useState(false)
+ const trackedBannerKeysRef = useRef(new Set())
+ const shouldResumeAfterFocusRef = useRef(false)
+ const nextIndex = (selectedIndex + 1) % banners.length
+ const activeBanner = banners[selectedIndex]
+ const trackingKey = accountId && activeBanner ? `${accountId}:${activeBanner.id}` : null
+
+ const pauseRotationForFocus = () => {
+ const autoplay = api?.plugins().autoplay
+ if (!autoplay?.isPlaying()) return
+
+ shouldResumeAfterFocusRef.current = true
+ autoplay.stop()
+ }
+
+ const resumeRotationAfterFocus = (event: FocusEvent) => {
+ if (event.currentTarget.contains(event.relatedTarget)) return
+ if (!shouldResumeAfterFocusRef.current) return
+
+ shouldResumeAfterFocusRef.current = false
+ api?.plugins().autoplay?.play()
+ }
+
+ const selectBanner = (index: number) => {
+ if (!api || index === selectedIndex) return
+ api.scrollTo(index)
+ }
useEffect(() => {
- if (!accountId) return
-
- const currentBanner = banners[selectedIndex]
- if (!currentBanner || trackedBannerIdsRef.current.has(currentBanner.id)) return
+ if (!accountId || !activeBanner || !trackingKey) return
+ if (trackedBannerKeysRef.current.has(trackingKey)) return
trackEvent('explore_banner_impression', {
- banner_id: currentBanner.id,
- title: currentBanner.content.title,
+ banner_id: activeBanner.id,
+ title: activeBanner.content.title,
sort: selectedIndex + 1,
- link: currentBanner.link,
+ link: activeBanner.link,
page: 'explore',
language,
account_id: accountId,
event_time: Date.now(),
})
- trackedBannerIdsRef.current.add(currentBanner.id)
- }, [accountId, banners, language, selectedIndex, trackedBannerIdsRef])
+ trackedBannerKeysRef.current.add(trackingKey)
+ }, [accountId, activeBanner, language, selectedIndex, trackingKey])
- return null
+ useEffect(() => {
+ if (!api) return
+
+ const handleAutoplayPlay = () => setIsPlaying(true)
+ const handleAutoplayStop = () => setIsPlaying(false)
+
+ // oxlint-disable-next-line eslint-react/set-state-in-effect -- Embla owns this external playback state.
+ setIsPlaying(api.plugins().autoplay?.isPlaying() ?? false)
+ api.on('autoplay:play', handleAutoplayPlay)
+ api.on('autoplay:stop', handleAutoplayStop)
+
+ return () => {
+ api.off('autoplay:play', handleAutoplayPlay)
+ api.off('autoplay:stop', handleAutoplayStop)
+ }
+ }, [api])
+
+ const controls =
+ banners.length > 1 ? (
+ $['pagination.pageNumber'], { ns: 'common' })}
+ className="pointer-events-auto flex h-7 min-w-0 shrink-0 items-center gap-2 @min-[996px]/banner:max-w-150 @min-[996px]/banner:min-w-60 @min-[996px]/banner:flex-[1_0_0] @min-[996px]/banner:pr-10"
+ onFocusCapture={pauseRotationForFocus}
+ onBlurCapture={resumeRotationAfterFocus}
+ >
+
+ {banners.map((banner, index) => (
+ selectBanner(index)}
+ />
+ ))}
+
+
+
+ ) : null
+ const hasFooter = Boolean(activeBanner?.link || controls)
+
+ return (
+ <>
+
+ {banners.map((banner, index) => {
+ const isActive = index === selectedIndex
+
+ return (
+
+
+
+ )
+ })}
+
+
+ {hasFooter ? (
+
+ {activeBanner?.link ? (
+
+
+
+
+
+ {t(($) => $['banner.viewMore'], { ns: 'explore' })}
+
+
+ ) : null}
+ {controls}
+
+ ) : null}
+ >
+ )
}
type BannerProps = {
banners: BannerType[]
}
-function Banner({ banners }: BannerProps) {
+export function Banner({ banners }: BannerProps) {
const { t } = useTranslation()
const locale = useLocale()
const userProfile = useAtomValue(userProfileAtom)
- const accountId = userProfile.id
- const userName = userProfile.name
- const [isHovered, setIsHovered] = useState(false)
- const [isResizing, setIsResizing] = useState(false)
- const resizeTimerRef = useRef(null)
- const trackedBannerIdsRef = useRef>(new Set())
-
- const enabledBanners = useMemo(
- () => banners?.filter((banner) => banner.status === 'enabled') ?? [],
- [banners],
- )
-
- const isPaused = isHovered || isResizing
- const notShowSlider = enabledBanners.length === 0
-
- // 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)
- }
- }, [])
+ const enabledBanners = banners.filter((banner) => banner.status === 'enabled')
+ const carouselLabel = enabledBanners[0]?.content.category || enabledBanners[0]?.content.title
+ const [carouselPlugins] = useState(() => [
+ Carousel.Plugin.Fade(),
+ Carousel.Plugin.Autoplay({
+ delay: AUTOPLAY_DELAY,
+ stopOnFocusIn: true,
+ stopOnInteraction: false,
+ stopOnMouseEnter: true,
+ breakpoints: {
+ '(prefers-reduced-motion: reduce)': { active: false },
+ },
+ }),
+ ])
return (
- setIsHovered(true)}
- onMouseLeave={() => setIsHovered(false)}
- >
+
- {t(($) => $['banner.greeting'], { name: userName, ns: 'explore' })}
+ {t(($) => $['banner.greeting'], { name: userProfile.name, ns: 'explore' })}
{t(($) => $['banner.tagline'], { ns: 'explore' })}
- {!notShowSlider && (
+ {enabledBanners.length > 0 ? (
-
-
- {enabledBanners.map((banner, index) => (
-
-
-
- ))}
-
- )}
+ ) : null}
)
}
-
-export default React.memo(Banner)
diff --git a/web/app/components/explore/banner/indicator-button.module.css b/web/app/components/explore/banner/indicator-button.module.css
new file mode 100644
index 00000000000..9c324ed8746
--- /dev/null
+++ b/web/app/components/explore/banner/indicator-button.module.css
@@ -0,0 +1,33 @@
+@property --banner-progress-angle {
+ syntax: '
';
+ inherits: false;
+ initial-value: 0deg;
+}
+
+.progress {
+ --banner-progress-angle: 0deg;
+
+ position: absolute;
+ inset: 0;
+ border-radius: 7px;
+ background: conic-gradient(
+ from 0deg,
+ var(--color-text-primary) var(--banner-progress-angle),
+ transparent var(--banner-progress-angle)
+ );
+ animation-name: progress;
+ animation-timing-function: linear;
+ animation-fill-mode: forwards;
+}
+
+@keyframes progress {
+ to {
+ --banner-progress-angle: 360deg;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .progress {
+ animation: none;
+ }
+}
diff --git a/web/app/components/explore/banner/indicator-button.tsx b/web/app/components/explore/banner/indicator-button.tsx
index 2f734058a6e..0f3bffa6ead 100644
--- a/web/app/components/explore/banner/indicator-button.tsx
+++ b/web/app/components/explore/banner/indicator-button.tsx
@@ -1,108 +1,47 @@
-/* oxlint-disable eslint-react/set-state-in-effect */
-import type { FC } from 'react'
-import { cn } from '@langgenius/dify-ui/cn'
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { Button } from '@langgenius/dify-ui/button'
+import styles from './indicator-button.module.css'
type IndicatorButtonProps = {
index: number
- selectedIndex: number
+ label: string
+ isCurrent: boolean
isNextSlide: boolean
autoplayDelay: number
- resetKey: number
- isPaused?: boolean
+ isPaused: boolean
onClick: () => void
}
-const PROGRESS_MAX = 100
-const DEGREES_PER_PERCENT = 3.6
-
-export const IndicatorButton: FC = ({
+export function IndicatorButton({
index,
- selectedIndex,
+ label,
+ isCurrent,
isNextSlide,
autoplayDelay,
- resetKey,
- isPaused = false,
+ isPaused,
onClick,
-}) => {
- const [progress, setProgress] = useState(0)
- const frameIdRef = useRef(undefined)
- const startTimeRef = useRef(0)
-
- const isActive = index === selectedIndex
- const shouldAnimate = !document.hidden && !isPaused
-
- useEffect(() => {
- if (!isNextSlide) {
- setProgress(0)
- if (frameIdRef.current) cancelAnimationFrame(frameIdRef.current)
- return
- }
-
- setProgress(0)
- startTimeRef.current = Date.now()
-
- const animate = () => {
- if (!document.hidden && !isPaused) {
- const elapsed = Date.now() - startTimeRef.current
- const newProgress = Math.min((elapsed / autoplayDelay) * PROGRESS_MAX, PROGRESS_MAX)
- setProgress(newProgress)
-
- if (newProgress < PROGRESS_MAX) frameIdRef.current = requestAnimationFrame(animate)
- } else {
- frameIdRef.current = requestAnimationFrame(animate)
- }
- }
-
- if (shouldAnimate) frameIdRef.current = requestAnimationFrame(animate)
-
- return () => {
- if (frameIdRef.current) cancelAnimationFrame(frameIdRef.current)
- }
- }, [isNextSlide, autoplayDelay, resetKey, isPaused])
-
- const handleClick = useCallback(
- (e: React.MouseEvent) => {
- e.stopPropagation()
- onClick()
- },
- [onClick],
- )
-
- const progressDegrees = progress * DEGREES_PER_PERCENT
-
+}: IndicatorButtonProps) {
return (
-
+
+ {isNextSlide && !isCurrent && !isPaused ? (
+
+ ) : null}
+
+ {String(index + 1).padStart(2, '0')}
+
+
+
)
}