mirror of
https://github.com/langgenius/dify.git
synced 2026-04-27 02:36:29 +08:00
feat: implement responsive design and resize handling for explore page banner
This commit is contained in:
parent
df76527f29
commit
a25e37a96d
@ -1,5 +1,5 @@
|
|||||||
import type { FC } from 'react'
|
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 { RiArrowRightLine } from '@remixicon/react'
|
||||||
import { useCarousel } from '@/app/components/base/carousel'
|
import { useCarousel } from '@/app/components/base/carousel'
|
||||||
import { IndicatorButton } from './indicator-button'
|
import { IndicatorButton } from './indicator-button'
|
||||||
@ -25,10 +25,15 @@ type BannerItemProps = {
|
|||||||
isPaused?: boolean
|
isPaused?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RESPONSIVE_BREAKPOINT = 1280
|
||||||
|
const MAX_RESPONSIVE_WIDTH = 600
|
||||||
|
|
||||||
export const BannerItem: FC<BannerItemProps> = ({ banner, autoplayDelay, isPaused = false }) => {
|
export const BannerItem: FC<BannerItemProps> = ({ banner, autoplayDelay, isPaused = false }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { api, selectedIndex } = useCarousel()
|
const { api, selectedIndex } = useCarousel()
|
||||||
const [resetKey, setResetKey] = useState(0)
|
const [resetKey, setResetKey] = useState(0)
|
||||||
|
const textAreaRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [maxWidth, setMaxWidth] = useState<number | undefined>(undefined)
|
||||||
|
|
||||||
const slideInfo = useMemo(() => {
|
const slideInfo = useMemo(() => {
|
||||||
const slides = api?.slideNodes() ?? []
|
const slides = api?.slideNodes() ?? []
|
||||||
@ -37,27 +42,59 @@ export const BannerItem: FC<BannerItemProps> = ({ banner, autoplayDelay, isPause
|
|||||||
return { slides, totalSlides, nextIndex }
|
return { slides, totalSlides, nextIndex }
|
||||||
}, [api, selectedIndex])
|
}, [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
|
// Reset progress when slide changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setResetKey(prev => prev + 1)
|
incrementResetKey()
|
||||||
}, [selectedIndex])
|
}, [selectedIndex, incrementResetKey])
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
setResetKey(prev => prev + 1)
|
incrementResetKey()
|
||||||
if (banner.link)
|
if (banner.link)
|
||||||
window.open(banner.link, '_blank', 'noopener,noreferrer')
|
window.open(banner.link, '_blank', 'noopener,noreferrer')
|
||||||
}, [banner.link])
|
}, [banner.link, incrementResetKey])
|
||||||
|
|
||||||
const handleIndicatorClick = useCallback((index: number) => {
|
const handleIndicatorClick = useCallback((index: number) => {
|
||||||
setResetKey(prev => prev + 1)
|
incrementResetKey()
|
||||||
api?.scrollTo(index)
|
api?.scrollTo(index)
|
||||||
}, [api])
|
}, [api, incrementResetKey])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex w-full cursor-pointer overflow-hidden rounded-2xl bg-components-panel-on-panel-item-bg pr-[256px] shadow-md transition-shadow',
|
'relative flex w-full min-w-[784px] cursor-pointer overflow-hidden rounded-2xl bg-components-panel-on-panel-item-bg pr-[288px] transition-shadow hover:shadow-md',
|
||||||
'hover:shadow-lg',
|
|
||||||
)}
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
@ -67,7 +104,11 @@ export const BannerItem: FC<BannerItemProps> = ({ banner, autoplayDelay, isPause
|
|||||||
{/* Text section */}
|
{/* Text section */}
|
||||||
<div className="flex min-h-24 flex-wrap items-end gap-1 py-1">
|
<div className="flex min-h-24 flex-wrap items-end gap-1 py-1">
|
||||||
{/* Title area */}
|
{/* Title area */}
|
||||||
<div className="flex min-w-[480px] max-w-[680px] flex-[1_0_0] flex-col pr-4">
|
<div
|
||||||
|
ref={textAreaRef}
|
||||||
|
className="flex min-w-[480px] max-w-[680px] flex-[1_0_0] flex-col pr-4"
|
||||||
|
style={responsiveStyle}
|
||||||
|
>
|
||||||
<p className="title-4xl-semi-bold line-clamp-1 text-dify-logo-dify-logo-blue">
|
<p className="title-4xl-semi-bold line-clamp-1 text-dify-logo-dify-logo-blue">
|
||||||
{banner.content.category}
|
{banner.content.category}
|
||||||
</p>
|
</p>
|
||||||
@ -76,15 +117,23 @@ export const BannerItem: FC<BannerItemProps> = ({ banner, autoplayDelay, isPause
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Description area */}
|
{/* Description area */}
|
||||||
<div className="body-sm-regular line-clamp-4 min-w-60 max-w-[600px] flex-[1_0_0] self-end py-1 pr-4 text-text-tertiary">
|
<div
|
||||||
{banner.content.description}
|
className="min-w-60 max-w-[600px] flex-[1_0_0] self-end overflow-hidden py-1 pr-4"
|
||||||
|
style={responsiveStyle}
|
||||||
|
>
|
||||||
|
<p className="body-sm-regular line-clamp-4 overflow-hidden text-text-tertiary">
|
||||||
|
{banner.content.description}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions section */}
|
{/* Actions section */}
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
{/* View more button */}
|
{/* View more button */}
|
||||||
<div className="flex min-w-[480px] max-w-[680px] flex-[1_0_0] items-center gap-[6px] py-1">
|
<div
|
||||||
|
className="flex min-w-[480px] max-w-[680px] flex-[1_0_0] items-center gap-[6px] py-1"
|
||||||
|
style={responsiveStyle}
|
||||||
|
>
|
||||||
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-text-accent p-[2px]">
|
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-text-accent p-[2px]">
|
||||||
<RiArrowRightLine className="h-3 w-3 text-text-primary-on-surface" />
|
<RiArrowRightLine className="h-3 w-3 text-text-primary-on-surface" />
|
||||||
</div>
|
</div>
|
||||||
@ -94,7 +143,10 @@ export const BannerItem: FC<BannerItemProps> = ({ banner, autoplayDelay, isPause
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Slide navigation indicators */}
|
{/* Slide navigation indicators */}
|
||||||
<div className="flex min-w-60 max-w-[600px] flex-[1_0_0] items-center gap-2 py-1 pr-10">
|
<div
|
||||||
|
className="flex min-w-60 max-w-[600px] flex-[1_0_0] items-center gap-2 py-1 pr-10"
|
||||||
|
style={responsiveStyle}
|
||||||
|
>
|
||||||
{slideInfo.slides.map((_: unknown, index: number) => (
|
{slideInfo.slides.map((_: unknown, index: number) => (
|
||||||
<IndicatorButton
|
<IndicatorButton
|
||||||
key={index}
|
key={index}
|
||||||
@ -108,16 +160,16 @@ export const BannerItem: FC<BannerItemProps> = ({ banner, autoplayDelay, isPause
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right image area */}
|
{/* Right image area */}
|
||||||
<div className="absolute right-0 top-0 flex h-full items-center p-2">
|
<div className="absolute right-0 top-0 flex h-full items-center p-2">
|
||||||
<img
|
<img
|
||||||
src={banner.content['img-src']}
|
src={banner.content['img-src']}
|
||||||
alt={banner.content.title}
|
alt={banner.content.title}
|
||||||
className="aspect-[4/3] h-full rounded-xl object-cover"
|
className="aspect-[4/3] h-full max-w-[296px] rounded-xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { FC } from 'react'
|
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 { Carousel } from '@/app/components/base/carousel'
|
||||||
import { useGetBanners } from '@/service/use-explore'
|
import { useGetBanners } from '@/service/use-explore'
|
||||||
import Loading from '../../base/loading'
|
import Loading from '../../base/loading'
|
||||||
@ -8,27 +8,55 @@ import { useI18N } from '@/context/i18n'
|
|||||||
|
|
||||||
const AUTOPLAY_DELAY = 5000
|
const AUTOPLAY_DELAY = 5000
|
||||||
const MIN_LOADING_HEIGHT = 168
|
const MIN_LOADING_HEIGHT = 168
|
||||||
|
const RESIZE_DEBOUNCE_DELAY = 50
|
||||||
|
|
||||||
|
const LoadingState: FC = () => (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center rounded-2xl bg-components-panel-on-panel-item-bg shadow-md"
|
||||||
|
style={{ minHeight: MIN_LOADING_HEIGHT }}
|
||||||
|
>
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
const Banner: FC = () => {
|
const Banner: FC = () => {
|
||||||
const { locale } = useI18N()
|
const { locale } = useI18N()
|
||||||
const { data: banners, isLoading, isError } = useGetBanners(locale)
|
const { data: banners, isLoading, isError } = useGetBanners(locale)
|
||||||
const [isHovered, setIsHovered] = useState(false)
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
|
const resizeTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
const enabledBanners = useMemo(
|
const enabledBanners = useMemo(
|
||||||
() => banners?.filter((banner: BannerData) => banner.status === 'enabled') ?? [],
|
() => banners?.filter((banner: BannerData) => banner.status === 'enabled') ?? [],
|
||||||
[banners],
|
[banners],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isLoading) {
|
const isPaused = isHovered || isResizing
|
||||||
return (
|
|
||||||
<div
|
// Handle window resize to pause animation
|
||||||
className="flex items-center justify-center rounded-2xl bg-components-panel-on-panel-item-bg shadow-md"
|
useEffect(() => {
|
||||||
style={{ minHeight: MIN_LOADING_HEIGHT }}
|
const handleResize = () => {
|
||||||
>
|
setIsResizing(true)
|
||||||
<Loading />
|
|
||||||
</div>
|
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 <LoadingState />
|
||||||
|
|
||||||
if (isError || enabledBanners.length === 0)
|
if (isError || enabledBanners.length === 0)
|
||||||
return null
|
return null
|
||||||
@ -50,7 +78,11 @@ const Banner: FC = () => {
|
|||||||
<Carousel.Content>
|
<Carousel.Content>
|
||||||
{enabledBanners.map((banner: BannerData) => (
|
{enabledBanners.map((banner: BannerData) => (
|
||||||
<Carousel.Item key={banner.id}>
|
<Carousel.Item key={banner.id}>
|
||||||
<BannerItem banner={banner} autoplayDelay={AUTOPLAY_DELAY} isPaused={isHovered} />
|
<BannerItem
|
||||||
|
banner={banner}
|
||||||
|
autoplayDelay={AUTOPLAY_DELAY}
|
||||||
|
isPaused={isPaused}
|
||||||
|
/>
|
||||||
</Carousel.Item>
|
</Carousel.Item>
|
||||||
))}
|
))}
|
||||||
</Carousel.Content>
|
</Carousel.Content>
|
||||||
|
|||||||
@ -25,40 +25,26 @@ export const IndicatorButton: FC<IndicatorButtonProps> = ({
|
|||||||
onClick,
|
onClick,
|
||||||
}) => {
|
}) => {
|
||||||
const [progress, setProgress] = useState(0)
|
const [progress, setProgress] = useState(0)
|
||||||
const [isPageVisible, setIsPageVisible] = useState(true)
|
|
||||||
const frameIdRef = useRef<number | undefined>(undefined)
|
const frameIdRef = useRef<number | undefined>(undefined)
|
||||||
const startTimeRef = useRef(0)
|
const startTimeRef = useRef(0)
|
||||||
|
|
||||||
// Listen to page visibility changes
|
const isActive = index === selectedIndex
|
||||||
useEffect(() => {
|
const shouldAnimate = !document.hidden && !isPaused
|
||||||
const handleVisibilityChange = () => {
|
|
||||||
setIsPageVisible(!document.hidden)
|
|
||||||
}
|
|
||||||
setIsPageVisible(!document.hidden)
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isNextSlide) {
|
if (!isNextSlide) {
|
||||||
setProgress(0)
|
setProgress(0)
|
||||||
if (frameIdRef.current)
|
if (frameIdRef.current)
|
||||||
cancelAnimationFrame(frameIdRef.current)
|
cancelAnimationFrame(frameIdRef.current)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// reset and start new animation
|
|
||||||
setProgress(0)
|
setProgress(0)
|
||||||
startTimeRef.current = Date.now()
|
startTimeRef.current = Date.now()
|
||||||
|
|
||||||
const animate = () => {
|
const animate = () => {
|
||||||
// Only continue animation when page is visible and not paused
|
|
||||||
if (!document.hidden && !isPaused) {
|
if (!document.hidden && !isPaused) {
|
||||||
const now = Date.now()
|
const elapsed = Date.now() - startTimeRef.current
|
||||||
const elapsed = now - startTimeRef.current
|
|
||||||
const newProgress = Math.min((elapsed / autoplayDelay) * PROGRESS_MAX, PROGRESS_MAX)
|
const newProgress = Math.min((elapsed / autoplayDelay) * PROGRESS_MAX, PROGRESS_MAX)
|
||||||
setProgress(newProgress)
|
setProgress(newProgress)
|
||||||
|
|
||||||
@ -69,22 +55,23 @@ export const IndicatorButton: FC<IndicatorButtonProps> = ({
|
|||||||
frameIdRef.current = requestAnimationFrame(animate)
|
frameIdRef.current = requestAnimationFrame(animate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!document.hidden && !isPaused)
|
|
||||||
|
if (shouldAnimate)
|
||||||
frameIdRef.current = requestAnimationFrame(animate)
|
frameIdRef.current = requestAnimationFrame(animate)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (frameIdRef.current)
|
if (frameIdRef.current)
|
||||||
cancelAnimationFrame(frameIdRef.current)
|
cancelAnimationFrame(frameIdRef.current)
|
||||||
}
|
}
|
||||||
}, [isNextSlide, autoplayDelay, resetKey, isPageVisible, isPaused])
|
}, [isNextSlide, autoplayDelay, resetKey, isPaused])
|
||||||
|
|
||||||
const isActive = index === selectedIndex
|
|
||||||
|
|
||||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onClick()
|
onClick()
|
||||||
}, [onClick])
|
}, [onClick])
|
||||||
|
|
||||||
|
const progressDegrees = progress * DEGREES_PER_PERCENT
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
@ -103,8 +90,8 @@ export const IndicatorButton: FC<IndicatorButtonProps> = ({
|
|||||||
style={{
|
style={{
|
||||||
background: `conic-gradient(
|
background: `conic-gradient(
|
||||||
from 0deg,
|
from 0deg,
|
||||||
var(--color-text-primary) ${progress * DEGREES_PER_PERCENT}deg,
|
var(--color-text-primary) ${progressDegrees}deg,
|
||||||
transparent ${progress * DEGREES_PER_PERCENT}deg
|
transparent ${progressDegrees}deg
|
||||||
)`,
|
)`,
|
||||||
WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
|
WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
|
||||||
WebkitMaskComposite: 'xor',
|
WebkitMaskComposite: 'xor',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user