dify/web/app/components/plugins/marketplace/description/index.tsx

310 lines
12 KiB
TypeScript

'use client'
import type { MotionValue } from 'motion/react'
import { motion, useMotionValue, useSpring, useTransform } from 'motion/react'
import { useEffect, useLayoutEffect, useRef } from 'react'
import { useLocale, useTranslation } from '#i18n'
import Divider from '@/app/components/base/divider'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import { SubmitRequestDropdown } from '@/app/components/plugins/plugin-page/nav-operations'
import PluginTypeSwitch from '../plugin-type-switch'
import SearchBoxWrapper from '../search-box/search-box-wrapper'
type DescriptionProps = {
isMarketplacePlatform?: boolean
marketplaceNav?: React.ReactNode
scrollContainerId?: string
}
const MAX_SCROLL = 120
const EXPANDED_PADDING_TOP = 32
const COLLAPSED_PADDING_TOP = 12
const EXPANDED_PADDING_BOTTOM = 24
const COLLAPSED_PADDING_BOTTOM = 12
const EXPANDED_TITLE_MARGIN_TOP = 32
const EXPANDED_TABS_MARGIN_TOP = 32
const Description = ({
isMarketplacePlatform = false,
marketplaceNav,
scrollContainerId = 'marketplace-container',
}: DescriptionProps) => {
const { t } = useTranslation('plugin')
const { t: tCommon } = useTranslation('common')
const locale = useLocale()
const isZhHans = locale === 'zh-Hans'
const rafRef = useRef<number | null>(null)
const lastProgressRef = useRef(0)
const headerRef = useRef<HTMLDivElement | null>(null)
const titleContentRef = useRef<HTMLDivElement | null>(null)
const hasMarketplaceNav = isMarketplacePlatform
const progress = useMotionValue(0)
const titleHeight = useMotionValue(72)
const smoothProgress = useSpring(progress, { stiffness: 260, damping: 34 })
useLayoutEffect(() => {
if (!isMarketplacePlatform) return
const node = titleContentRef.current
if (!node) return
const updateHeight = () => {
titleHeight.set(node.scrollHeight)
}
updateHeight()
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(updateHeight)
observer.observe(node)
return () => observer.disconnect()
}, [isMarketplacePlatform, titleHeight])
useEffect(() => {
if (!isMarketplacePlatform) return
const container = document.getElementById(scrollContainerId)
if (!container) return
const handleScroll = () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current)
rafRef.current = requestAnimationFrame(() => {
const scrollTop = Math.round(container.scrollTop)
const heightDelta = container.scrollHeight - container.clientHeight
const effectiveMaxScroll = Math.max(1, Math.min(MAX_SCROLL, heightDelta))
const rawProgress = Math.min(Math.max(scrollTop / effectiveMaxScroll, 0), 1)
const snappedProgress =
rawProgress >= 0.95 ? 1 : rawProgress <= 0.05 ? 0 : Math.round(rawProgress * 100) / 100
if (snappedProgress !== lastProgressRef.current) {
lastProgressRef.current = snappedProgress
progress.set(snappedProgress)
}
})
}
container.addEventListener('scroll', handleScroll, { passive: true })
handleScroll()
return () => {
container.removeEventListener('scroll', handleScroll)
if (rafRef.current) cancelAnimationFrame(rafRef.current)
}
}, [isMarketplacePlatform, progress, scrollContainerId])
useEffect(() => {
if (!isMarketplacePlatform) return
const container = document.getElementById(scrollContainerId)
const header = headerRef.current
if (!container || !header) return
const previousOverflowAnchor = container.style.overflowAnchor
container.style.overflowAnchor = 'none'
let maxHeaderHeight = 0
let lastAppliedOffset = 0
const updateOffset = () => {
const currentHeaderHeight = Math.round(header.getBoundingClientRect().height)
maxHeaderHeight = Math.max(maxHeaderHeight, currentHeaderHeight)
const collapsedHeight = Math.max(0, maxHeaderHeight - currentHeaderHeight)
const currentScrollableTop = container.scrollHeight - container.clientHeight
const baseScrollableTop = Math.max(0, currentScrollableTop - lastAppliedOffset)
const shouldCompensate = baseScrollableTop <= maxHeaderHeight
const nextOffset = shouldCompensate ? collapsedHeight : 0
if (nextOffset > 0) {
container.style.setProperty('--marketplace-header-collapse-offset', `${nextOffset}px`)
} else {
container.style.removeProperty('--marketplace-header-collapse-offset')
}
lastAppliedOffset = nextOffset
}
updateOffset()
if (typeof ResizeObserver === 'undefined') {
return () => {
container.style.removeProperty('--marketplace-header-collapse-offset')
container.style.overflowAnchor = previousOverflowAnchor
}
}
const observer = new ResizeObserver(updateOffset)
observer.observe(header)
return () => {
observer.disconnect()
container.style.removeProperty('--marketplace-header-collapse-offset')
container.style.overflowAnchor = previousOverflowAnchor
}
}, [isMarketplacePlatform, scrollContainerId])
const contentOpacity = useTransform(smoothProgress, [0, 1], [1, 0])
const contentScale = useTransform(smoothProgress, [0, 1], [1, 0.9])
const titleMaxHeight: MotionValue<number> = useTransform(
[smoothProgress, titleHeight],
(values: number[]) => {
const currentProgress = values[0] ?? 0
const currentTitleHeight = values[1] ?? 72
return currentTitleHeight * (1 - currentProgress)
},
)
const tabsMarginTop = useTransform(
smoothProgress,
[0, 1],
[EXPANDED_TABS_MARGIN_TOP, hasMarketplaceNav ? 16 : 0],
)
const titleMarginTop = useTransform(
smoothProgress,
[0, 1],
[hasMarketplaceNav ? EXPANDED_TITLE_MARGIN_TOP : 0, 0],
)
const paddingTop = useTransform(
smoothProgress,
[0, 1],
[hasMarketplaceNav ? COLLAPSED_PADDING_TOP : EXPANDED_PADDING_TOP, COLLAPSED_PADDING_TOP],
)
const paddingBottom = useTransform(
smoothProgress,
[0, 1],
[EXPANDED_PADDING_BOTTOM, COLLAPSED_PADDING_BOTTOM],
)
if (!isMarketplacePlatform) {
return (
<>
<h1 className="mb-2 shrink-0 text-center title-4xl-semi-bold text-text-primary">
{t(($) => $['marketplace.empower'])}
</h1>
<h2 className="flex shrink-0 items-center justify-center text-center body-md-regular text-text-tertiary">
{isZhHans && (
<>
<span className="mr-1">{tCommon(($) => $['operation.in'])}</span>
{t(($) => $['marketplace.difyMarketplace'])}
{t(($) => $['marketplace.discover'])}
</>
)}
{!isZhHans && <>{t(($) => $['marketplace.discover'])}</>}
<span className="relative z-1 ml-1 body-md-medium text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t(($) => $['category.models'])}
</span>
,
<span className="relative z-1 ml-1 body-md-medium text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t(($) => $['category.tools'])}
</span>
,
<span className="relative z-1 ml-1 body-md-medium text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t(($) => $['category.datasources'])}
</span>
,
<span className="relative z-1 ml-1 body-md-medium text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t(($) => $['category.triggers'])}
</span>
,
<span className="relative z-1 ml-1 body-md-medium text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t(($) => $['category.agents'])}
</span>
,
<span className="relative z-1 mr-1 ml-1 body-md-medium text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t(($) => $['category.extensions'])}
</span>
{t(($) => $['marketplace.and'])}
<span className="relative z-1 mr-1 ml-1 body-md-medium text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t(($) => $['category.bundles'])}
</span>
{!isZhHans && (
<>
<span className="mr-1">{tCommon(($) => $['operation.in'])}</span>
{t(($) => $['marketplace.difyMarketplace'])}
</>
)}
</h2>
</>
)
}
const defaultMarketplaceNav = (
<div className="relative z-20 flex w-full flex-col items-start">
<div className="flex h-[60px] w-full items-center rounded-lg border-[0.5px] border-white/50 bg-components-panel-bg-blur backdrop-blur-[6px]">
<div className="flex h-full min-w-0 flex-1 items-center px-5 py-2">
<div className="flex shrink-0 items-center gap-1.5">
<DifyLogo alt="" className="h-6 w-[52px]" />
<span className="max-w-0 overflow-hidden title-3xl-semi-bold whitespace-nowrap text-text-primary opacity-0 transition-all duration-200 md:max-w-[150px] md:opacity-100">
{tCommon(($) => $['mainNav.marketplace'])}
</span>
</div>
</div>
<SearchBoxWrapper
wrapperClassName="z-11 w-64 shrink-0"
inputClassName="h-9 w-full rounded-[10px]"
inputElementClassName="text-[14px] leading-5 font-normal"
searchIconClassName="size-4"
placeholder={tCommon(($) => $['placeholder.search'])}
showTags={false}
usedInMarketplace={false}
/>
<div className="flex h-full shrink-0 items-center justify-end gap-4 pr-3.5 pl-4">
<Divider type="vertical" className="mx-0 h-4 bg-divider-regular" />
<SubmitRequestDropdown dividerAfterFirst />
</div>
</div>
</div>
)
return (
<motion.div
ref={headerRef}
className="sticky top-0 z-20 w-full shrink-0 overflow-hidden rounded-lg px-3"
style={{
paddingTop,
paddingBottom,
}}
>
<div className="absolute inset-0 bg-[#0033ff]" />
<div
className="absolute inset-0 bg-no-repeat opacity-80 mix-blend-lighten"
style={{
backgroundImage: 'url(/marketplace/hero-bg.jpg)',
backgroundPosition: 'center top',
backgroundSize: '110% auto',
}}
/>
<div
className="pointer-events-none absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{ backgroundImage: 'url(/marketplace/hero-gradient-noise.svg)' }}
/>
{marketplaceNav ?? defaultMarketplaceNav}
<div className="relative z-10 mx-5">
<motion.div
style={{
opacity: contentOpacity,
scale: contentScale,
transformOrigin: 'left top',
maxHeight: titleMaxHeight,
overflow: 'hidden',
willChange: 'opacity, transform',
marginTop: titleMarginTop,
}}
>
<div ref={titleContentRef}>
<h1 className="mb-2 shrink-0 text-[30px] leading-9 font-semibold text-text-primary-on-surface">
{t(($) => $['marketplace.pluginsHeroTitle'])}
</h1>
<h2 className="shrink-0 body-md-medium text-text-secondary-on-surface">
{t(($) => $['marketplace.pluginsHeroSubtitle'])}
</h2>
</div>
</motion.div>
<motion.div style={{ marginTop: tabsMarginTop }}>
<PluginTypeSwitch variant="hero" />
</motion.div>
</div>
</motion.div>
)
}
export default Description