mirror of
https://github.com/langgenius/dify.git
synced 2026-07-24 04:58:32 +08:00
218 lines
7.6 KiB
TypeScript
218 lines
7.6 KiB
TypeScript
import type { ComponentProps, FocusEvent } from 'react'
|
|
import type { Banner as BannerType } from '@/models/app'
|
|
import { useAtomValue } from 'jotai'
|
|
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 CAROUSEL_OPTIONS = {
|
|
loop: true,
|
|
watchDrag: (_api, event) =>
|
|
!(event.target instanceof Element && event.target.closest('[data-carousel-control]')),
|
|
} satisfies NonNullable<ComponentProps<typeof Carousel>['opts']>
|
|
|
|
type BannerCarouselContentProps = {
|
|
banners: BannerType[]
|
|
accountId?: string
|
|
language: string
|
|
}
|
|
|
|
function BannerCarouselContent({ banners, accountId, language }: BannerCarouselContentProps) {
|
|
const { t } = useTranslation()
|
|
const { api, selectedIndex } = useCarousel()
|
|
const [isPlaying, setIsPlaying] = useState(false)
|
|
const trackedBannerKeysRef = useRef(new Set<string>())
|
|
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<HTMLDivElement>) => {
|
|
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 || !activeBanner || !trackingKey) return
|
|
if (trackedBannerKeysRef.current.has(trackingKey)) return
|
|
|
|
trackEvent('explore_banner_impression', {
|
|
banner_id: activeBanner.id,
|
|
title: activeBanner.content.title,
|
|
sort: selectedIndex + 1,
|
|
link: activeBanner.link,
|
|
page: 'explore',
|
|
language,
|
|
account_id: accountId,
|
|
event_time: Date.now(),
|
|
})
|
|
trackedBannerKeysRef.current.add(trackingKey)
|
|
}, [accountId, activeBanner, language, selectedIndex, trackingKey])
|
|
|
|
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 ? (
|
|
<div
|
|
data-carousel-control
|
|
role="group"
|
|
aria-label={t(($) => $['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}
|
|
>
|
|
<div className="flex items-center gap-0.5">
|
|
{banners.map((banner, index) => (
|
|
<IndicatorButton
|
|
key={banner.id}
|
|
index={index}
|
|
label={`${String(index + 1).padStart(2, '0')} ${banner.content.title}`}
|
|
isCurrent={index === selectedIndex}
|
|
isNextSlide={index === nextIndex}
|
|
autoplayDelay={AUTOPLAY_DELAY}
|
|
isPaused={!isPlaying}
|
|
onClick={() => selectBanner(index)}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="hidden h-px flex-1 bg-divider-regular @min-[1068px]/banner:block" />
|
|
</div>
|
|
) : null
|
|
const hasFooter = Boolean(activeBanner?.link || controls)
|
|
|
|
return (
|
|
<>
|
|
<Carousel.Content aria-live={isPlaying ? 'off' : 'polite'}>
|
|
{banners.map((banner, index) => {
|
|
const isActive = index === selectedIndex
|
|
|
|
return (
|
|
<Carousel.Item
|
|
key={banner.id}
|
|
data-banner-id={banner.id}
|
|
aria-label={banner.content.title}
|
|
aria-hidden={!isActive}
|
|
inert={!isActive}
|
|
>
|
|
<BannerItem
|
|
banner={banner}
|
|
sort={index + 1}
|
|
language={language}
|
|
accountId={accountId}
|
|
/>
|
|
</Carousel.Item>
|
|
)
|
|
})}
|
|
</Carousel.Content>
|
|
|
|
{hasFooter ? (
|
|
<div className="pointer-events-none absolute right-4 bottom-6 left-8 z-40 flex min-w-0 items-center justify-between gap-4 @min-[720px]/banner:right-64 @min-[996px]/banner:right-60 @min-[996px]/banner:flex-wrap @min-[996px]/banner:justify-start @min-[996px]/banner:gap-1">
|
|
{activeBanner?.link ? (
|
|
<div className="flex min-w-0 items-center gap-1.5 py-1 @min-[996px]/banner:max-w-170 @min-[996px]/banner:min-w-120 @min-[996px]/banner:flex-[1_0_0]">
|
|
<span className="flex size-4 items-center justify-center rounded-full bg-text-accent p-0.5">
|
|
<span
|
|
className="i-ri-arrow-right-line size-3 text-text-primary-on-surface"
|
|
aria-hidden="true"
|
|
/>
|
|
</span>
|
|
<span className="truncate system-sm-semibold-uppercase text-text-accent">
|
|
{t(($) => $['banner.viewMore'], { ns: 'explore' })}
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
{controls}
|
|
</div>
|
|
) : null}
|
|
</>
|
|
)
|
|
}
|
|
|
|
type BannerProps = {
|
|
banners: BannerType[]
|
|
}
|
|
|
|
export function Banner({ banners }: BannerProps) {
|
|
const { t } = useTranslation()
|
|
const locale = useLocale()
|
|
const userProfile = useAtomValue(userProfileAtom)
|
|
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 (
|
|
<div className="relative flex w-full flex-col items-start gap-4 px-8 pt-6 pb-4">
|
|
<div className="flex w-full flex-col gap-1">
|
|
<p className="truncate title-3xl-semi-bold text-text-primary">
|
|
{t(($) => $['banner.greeting'], { name: userProfile.name, ns: 'explore' })}
|
|
</p>
|
|
<p className="truncate body-sm-regular text-text-secondary">
|
|
{t(($) => $['banner.tagline'], { ns: 'explore' })}
|
|
</p>
|
|
</div>
|
|
|
|
{enabledBanners.length > 0 ? (
|
|
<Carousel
|
|
opts={CAROUSEL_OPTIONS}
|
|
plugins={carouselPlugins}
|
|
aria-label={carouselLabel}
|
|
className="@container/banner w-full rounded-2xl"
|
|
>
|
|
<BannerCarouselContent
|
|
banners={enabledBanners}
|
|
accountId={userProfile.id}
|
|
language={locale}
|
|
/>
|
|
</Carousel>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|