Merge branch 'feat/plugins' of github.com:langgenius/dify into feat/plugins
commit
a387ff1c38
@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useContextSelector,
|
||||
} from 'use-context-selector'
|
||||
|
||||
export type MarketplaceContextValue = {
|
||||
intersected: boolean
|
||||
setIntersected: (intersected: boolean) => void
|
||||
}
|
||||
|
||||
export const MarketplaceContext = createContext<MarketplaceContextValue>({
|
||||
intersected: true,
|
||||
setIntersected: () => {},
|
||||
})
|
||||
|
||||
type MarketplaceContextProviderProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function useMarketplaceContext(selector: (value: MarketplaceContextValue) => any) {
|
||||
return useContextSelector(MarketplaceContext, selector)
|
||||
}
|
||||
|
||||
export const MarketplaceContextProvider = ({
|
||||
children,
|
||||
}: MarketplaceContextProviderProps) => {
|
||||
const [intersected, setIntersected] = useState(true)
|
||||
|
||||
return (
|
||||
<MarketplaceContext.Provider
|
||||
value={{
|
||||
intersected,
|
||||
setIntersected,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MarketplaceContext.Provider>
|
||||
)
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import IntersectionLine from '../intersection-line'
|
||||
import { usePluginPageContext } from '@/app/components/plugins/plugin-page/context'
|
||||
|
||||
type DescriptionWrapperProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
const DescriptionWrapper = ({
|
||||
children,
|
||||
}: DescriptionWrapperProps) => {
|
||||
const containerRef = usePluginPageContext(v => v.containerRef)
|
||||
const scrollDisabled = usePluginPageContext(v => v.scrollDisabled)
|
||||
const setScrollDisabled = usePluginPageContext(v => v.setScrollDisabled)
|
||||
|
||||
const handleScrollIntersectionChange = useCallback((isIntersecting: boolean) => {
|
||||
if (!isIntersecting && !scrollDisabled) {
|
||||
setScrollDisabled(true)
|
||||
setTimeout(() => {
|
||||
if (containerRef && containerRef.current)
|
||||
containerRef.current.scrollTop = 0
|
||||
}, 100)
|
||||
}
|
||||
}, [containerRef, scrollDisabled, setScrollDisabled])
|
||||
|
||||
return !scrollDisabled && (
|
||||
<>
|
||||
{children}
|
||||
<IntersectionLine
|
||||
containerRef={containerRef}
|
||||
intersectedCallback={handleScrollIntersectionChange}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DescriptionWrapper
|
||||
@ -1,20 +0,0 @@
|
||||
import Description from '../description'
|
||||
import DescriptionWrapper from '../description/wrapper'
|
||||
import SearchBoxWrapper from '../search-box/wrapper'
|
||||
import PluginTypeSwitch from '../plugin-type-switch'
|
||||
|
||||
const Header = () => {
|
||||
return (
|
||||
<>
|
||||
<DescriptionWrapper>
|
||||
<Description />
|
||||
</DescriptionWrapper>
|
||||
<div className='flex items-center justify-center mt-[15px] mb-4'>
|
||||
<SearchBoxWrapper />
|
||||
</div>
|
||||
<PluginTypeSwitch />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
@ -1,27 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { usePluginPageContext } from '@/app/components/plugins/plugin-page/context'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type HeaderWrapperProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
const HeaderWrapper = ({
|
||||
children,
|
||||
}: HeaderWrapperProps) => {
|
||||
const scrollDisabled = usePluginPageContext(v => v.scrollDisabled)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'py-10',
|
||||
scrollDisabled && 'absolute left-1/2 -translate-x-1/2 -top-[100px] pb-3',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HeaderWrapper
|
||||
@ -1,21 +1,30 @@
|
||||
import { useEffect } from 'react'
|
||||
import { usePluginPageContext } from '@/app/components/plugins/plugin-page/context'
|
||||
import { useMarketplaceContext } from '@/app/components/plugins/marketplace/context'
|
||||
|
||||
export const useScrollIntersection = (
|
||||
containerRef: React.RefObject<HTMLDivElement>,
|
||||
anchorRef: React.RefObject<HTMLDivElement>,
|
||||
callback: (isIntersecting: boolean) => void,
|
||||
) => {
|
||||
const containerRef = usePluginPageContext(v => v.containerRef)
|
||||
const intersected = useMarketplaceContext(v => v.intersected)
|
||||
const setIntersected = useMarketplaceContext(v => v.setIntersected)
|
||||
|
||||
useEffect(() => {
|
||||
let observer: IntersectionObserver | undefined
|
||||
if (containerRef?.current && anchorRef.current) {
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
const isIntersecting = entries[0].isIntersecting
|
||||
callback(isIntersecting)
|
||||
|
||||
if (isIntersecting && !intersected)
|
||||
setIntersected(true)
|
||||
|
||||
if (!isIntersecting && intersected)
|
||||
setIntersected(false)
|
||||
}, {
|
||||
root: containerRef.current,
|
||||
})
|
||||
observer.observe(anchorRef.current)
|
||||
}
|
||||
return () => observer?.disconnect()
|
||||
}, [containerRef, anchorRef, callback])
|
||||
}, [containerRef, anchorRef, intersected, setIntersected])
|
||||
}
|
||||
|
||||
@ -1,39 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { usePluginPageContext } from '@/app/components/plugins/plugin-page/context'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type ListWrapperProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
const ListWrapper = ({
|
||||
children,
|
||||
}: ListWrapperProps) => {
|
||||
const scrollDisabled = usePluginPageContext(v => v.scrollDisabled)
|
||||
const setScrollDisabled = usePluginPageContext(v => v.setScrollDisabled)
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
scrollDisabled && (
|
||||
<div className='h-[60px]'></div>
|
||||
)
|
||||
}
|
||||
<div
|
||||
className={cn(
|
||||
'px-12 py-2 bg-background-default-subtle',
|
||||
scrollDisabled && 'grow h-0 overflow-y-auto',
|
||||
)}
|
||||
onScroll={(e) => {
|
||||
if ((e.target as HTMLElement).scrollTop <= 0)
|
||||
setScrollDisabled(false)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ListWrapper
|
||||
@ -1,14 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import SearchBox from '.'
|
||||
import { usePluginPageContext } from '@/app/components/plugins/plugin-page/context'
|
||||
|
||||
const Wrapper = () => {
|
||||
const scrollDisabled = usePluginPageContext(v => v.scrollDisabled)
|
||||
|
||||
return (
|
||||
<SearchBox widthShouldChange={scrollDisabled} />
|
||||
)
|
||||
}
|
||||
|
||||
export default Wrapper
|
||||
@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
// import Button from '@/app/components/base/button'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
}
|
||||
|
||||
const OperationDropdown: FC<Props> = () => {
|
||||
const { t } = useTranslation()
|
||||
const [open, doSetOpen] = useState(false)
|
||||
const openRef = useRef(open)
|
||||
const setOpen = useCallback((v: boolean) => {
|
||||
doSetOpen(v)
|
||||
openRef.current = v
|
||||
}, [doSetOpen])
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(!openRef.current)
|
||||
}, [setOpen])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-end'
|
||||
offset={{
|
||||
mainAxis: 0,
|
||||
crossAxis: 0,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<ActionButton className={cn(open && 'bg-state-base-hover')}>
|
||||
<RiMoreFill className='w-4 h-4 text-components-button-secondary-accent-text' />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-50'>
|
||||
<div className='w-[112px] p-1 bg-components-panel-bg-blur rounded-xl border-[0.5px] border-components-panel-border shadow-lg'>
|
||||
<div className='px-3 py-1.5 rounded-lg text-text-secondary system-md-regular cursor-pointer hover:bg-state-base-hover'>{t('common.operation.download')}</div>
|
||||
{/* Wait marketplace */}
|
||||
{/* <div className='px-3 py-1.5 rounded-lg text-text-secondary system-md-regular cursor-pointer hover:bg-state-base-hover'>{t('common.operation.viewDetail')}</div> */}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
export default React.memo(OperationDropdown)
|
||||
@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Action from './action'
|
||||
import type { Plugin } from '@/app/components/plugins/types.ts'
|
||||
import I18n from '@/context/i18n'
|
||||
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
enum ActionType {
|
||||
install = 'install',
|
||||
download = 'download',
|
||||
// viewDetail = 'viewDetail', // wait for marketplace api
|
||||
}
|
||||
type Props = {
|
||||
payload: Plugin
|
||||
onAction: (type: ActionType) => void
|
||||
}
|
||||
|
||||
const Item: FC<Props> = ({
|
||||
payload,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { locale } = useContext(I18n)
|
||||
|
||||
return (
|
||||
<div className='flex rounded-lg py-2 pr-1 pl-3 hover:bg-state-base-hover'>
|
||||
<div
|
||||
className='shrink-0 relative w-6 h-6 border-[0.5px] border-components-panel-border-subtle rounded-md bg-center bg-no-repeat bg-contain'
|
||||
style={{ backgroundImage: `url(${payload.icon})` }}
|
||||
/>
|
||||
<div className='ml-2 w-0 grow flex'>
|
||||
<div className='w-0 grow'>
|
||||
<div className='h-4 leading-4 text-text-primary system-sm-medium truncate '>{payload.label[locale]}</div>
|
||||
<div className='h-5 leading-5 text-text-tertiary system-xs-regular truncate'>{payload.brief[locale]}</div>
|
||||
<div className='flex text-text-tertiary system-xs-regular space-x-1'>
|
||||
<div>{payload.org}</div>
|
||||
<div>·</div>
|
||||
<div>{t('plugin.install', { num: formatNumber(payload.install_count || 0) })}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Action */}
|
||||
<div className='flex items-center space-x-1 h-4 text-components-button-secondary-accent-text system-xs-medium'>
|
||||
<div className='px-1.5'>{t('plugin.installAction')}</div>
|
||||
<Action />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Item)
|
||||
@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Item from './item'
|
||||
import type { Plugin } from '@/app/components/plugins/types.ts'
|
||||
|
||||
type Props = {
|
||||
list: Plugin[]
|
||||
// onInstall: () =>
|
||||
}
|
||||
|
||||
const List: FC<Props> = ({
|
||||
list,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='pt-3 px-4 py-1 text-text-primary system-sm-medium'>
|
||||
{t('plugin.fromMarketplace')}
|
||||
</div>
|
||||
<div className='p-1'>
|
||||
{list.map((item, index) => (
|
||||
<Item
|
||||
key={index}
|
||||
payload={item}
|
||||
onAction={() => { }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(List)
|
||||
Loading…
Reference in New Issue