dify
This commit is contained in:
71
dify/web/app/components/explore/app-card/index.tsx
Normal file
71
dify/web/app/components/explore/app-card/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PlusIcon } from '@heroicons/react/20/solid'
|
||||
import Button from '../../base/button'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { App } from '@/models/explore'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { AppTypeIcon } from '../../app/type-selector'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
export type AppCardProps = {
|
||||
app: App
|
||||
canCreate: boolean
|
||||
onCreate: () => void
|
||||
isExplore: boolean
|
||||
}
|
||||
|
||||
const AppCard = ({
|
||||
app,
|
||||
canCreate,
|
||||
onCreate,
|
||||
isExplore,
|
||||
}: AppCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { app: appBasicInfo } = app
|
||||
return (
|
||||
<div className={cn('group relative col-span-1 flex cursor-pointer flex-col overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-2 shadow-sm transition-all duration-200 ease-in-out hover:shadow-lg')}>
|
||||
<div className='flex h-[66px] shrink-0 grow-0 items-center gap-3 px-[14px] pb-3 pt-[14px]'>
|
||||
<div className='relative shrink-0'>
|
||||
<AppIcon
|
||||
size='large'
|
||||
iconType={appBasicInfo.icon_type}
|
||||
icon={appBasicInfo.icon}
|
||||
background={appBasicInfo.icon_background}
|
||||
imageUrl={appBasicInfo.icon_url}
|
||||
/>
|
||||
<AppTypeIcon wrapperClassName='absolute -bottom-0.5 -right-0.5 w-4 h-4 shadow-sm'
|
||||
className='h-3 w-3' type={appBasicInfo.mode} />
|
||||
</div>
|
||||
<div className='w-0 grow py-[1px]'>
|
||||
<div className='flex items-center text-sm font-semibold leading-5 text-text-secondary'>
|
||||
<div className='truncate' title={appBasicInfo.name}>{appBasicInfo.name}</div>
|
||||
</div>
|
||||
<div className='flex items-center text-[10px] font-medium leading-[18px] text-text-tertiary'>
|
||||
{appBasicInfo.mode === AppModeEnum.ADVANCED_CHAT && <div className='truncate'>{t('app.types.advanced').toUpperCase()}</div>}
|
||||
{appBasicInfo.mode === AppModeEnum.CHAT && <div className='truncate'>{t('app.types.chatbot').toUpperCase()}</div>}
|
||||
{appBasicInfo.mode === AppModeEnum.AGENT_CHAT && <div className='truncate'>{t('app.types.agent').toUpperCase()}</div>}
|
||||
{appBasicInfo.mode === AppModeEnum.WORKFLOW && <div className='truncate'>{t('app.types.workflow').toUpperCase()}</div>}
|
||||
{appBasicInfo.mode === AppModeEnum.COMPLETION && <div className='truncate'>{t('app.types.completion').toUpperCase()}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="description-wrapper system-xs-regular h-[90px] px-[14px] text-text-tertiary">
|
||||
<div className='line-clamp-4 group-hover:line-clamp-2'>
|
||||
{app.description}
|
||||
</div>
|
||||
</div>
|
||||
{isExplore && canCreate && (
|
||||
<div className={cn('absolute bottom-0 left-0 right-0 hidden bg-gradient-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}>
|
||||
<div className={cn('flex h-8 w-full items-center space-x-2')}>
|
||||
<Button variant='primary' className='h-7 grow' onClick={() => onCreate()}>
|
||||
<PlusIcon className='mr-1 h-4 w-4' />
|
||||
<span className='text-xs'>{t('explore.appCard.addToWorkspace')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppCard
|
||||
221
dify/web/app/components/explore/app-list/index.tsx
Normal file
221
dify/web/app/components/explore/app-list/index.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import useSWR from 'swr'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import s from './style.module.css'
|
||||
import cn from '@/utils/classnames'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import type { App } from '@/models/explore'
|
||||
import Category from '@/app/components/explore/category'
|
||||
import AppCard from '@/app/components/explore/app-card'
|
||||
import { fetchAppDetail, fetchAppList } from '@/service/explore'
|
||||
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
|
||||
import CreateAppModal from '@/app/components/explore/create-app-modal'
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
DSLImportMode,
|
||||
} from '@/models/app'
|
||||
import { useImportDSL } from '@/hooks/use-import-dsl'
|
||||
import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-confirm-modal'
|
||||
|
||||
type AppsProps = {
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export enum PageType {
|
||||
EXPLORE = 'explore',
|
||||
CREATE = 'create',
|
||||
}
|
||||
|
||||
const Apps = ({
|
||||
onSuccess,
|
||||
}: AppsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { hasEditPermission } = useContext(ExploreContext)
|
||||
const allCategoriesEn = t('explore.apps.allCategories', { lng: 'en' })
|
||||
|
||||
const [keywords, setKeywords] = useState('')
|
||||
const [searchKeywords, setSearchKeywords] = useState('')
|
||||
|
||||
const { run: handleSearch } = useDebounceFn(() => {
|
||||
setSearchKeywords(keywords)
|
||||
}, { wait: 500 })
|
||||
|
||||
const handleKeywordsChange = (value: string) => {
|
||||
setKeywords(value)
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const [currCategory, setCurrCategory] = useTabSearchParams({
|
||||
defaultTab: allCategoriesEn,
|
||||
disableSearchParams: false,
|
||||
})
|
||||
|
||||
const {
|
||||
data: { categories, allList },
|
||||
} = useSWR(
|
||||
['/explore/apps'],
|
||||
() =>
|
||||
fetchAppList().then(({ categories, recommended_apps }) => ({
|
||||
categories,
|
||||
allList: recommended_apps.sort((a, b) => a.position - b.position),
|
||||
})),
|
||||
{
|
||||
fallbackData: {
|
||||
categories: [],
|
||||
allList: [],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const filteredList = allList.filter(item => currCategory === allCategoriesEn || item.category === currCategory)
|
||||
|
||||
const searchFilteredList = useMemo(() => {
|
||||
if (!searchKeywords || !filteredList || filteredList.length === 0)
|
||||
return filteredList
|
||||
|
||||
const lowerCaseSearchKeywords = searchKeywords.toLowerCase()
|
||||
|
||||
return filteredList.filter(item =>
|
||||
item.app && item.app.name && item.app.name.toLowerCase().includes(lowerCaseSearchKeywords),
|
||||
)
|
||||
}, [searchKeywords, filteredList])
|
||||
|
||||
const [currApp, setCurrApp] = React.useState<App | null>(null)
|
||||
const [isShowCreateModal, setIsShowCreateModal] = React.useState(false)
|
||||
|
||||
const {
|
||||
handleImportDSL,
|
||||
handleImportDSLConfirm,
|
||||
versions,
|
||||
isFetching,
|
||||
} = useImportDSL()
|
||||
const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false)
|
||||
const onCreate: CreateAppModalProps['onConfirm'] = async ({
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
description,
|
||||
}) => {
|
||||
const { export_data } = await fetchAppDetail(
|
||||
currApp?.app.id as string,
|
||||
)
|
||||
const payload = {
|
||||
mode: DSLImportMode.YAML_CONTENT,
|
||||
yaml_content: export_data,
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
description,
|
||||
}
|
||||
await handleImportDSL(payload, {
|
||||
onSuccess: () => {
|
||||
setIsShowCreateModal(false)
|
||||
},
|
||||
onPending: () => {
|
||||
setShowDSLConfirmModal(true)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const onConfirmDSL = useCallback(async () => {
|
||||
await handleImportDSLConfirm({
|
||||
onSuccess,
|
||||
})
|
||||
}, [handleImportDSLConfirm, onSuccess])
|
||||
|
||||
if (!categories || categories.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center">
|
||||
<Loading type="area" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex h-full flex-col border-l-[0.5px] border-divider-regular',
|
||||
)}>
|
||||
|
||||
<div className='shrink-0 px-12 pt-6'>
|
||||
<div className={`mb-1 ${s.textGradient} text-xl font-semibold`}>{t('explore.apps.title')}</div>
|
||||
<div className='text-sm text-text-tertiary'>{t('explore.apps.description')}</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
'mt-6 flex items-center justify-between px-12',
|
||||
)}>
|
||||
<Category
|
||||
list={categories}
|
||||
value={currCategory}
|
||||
onChange={setCurrCategory}
|
||||
allCategoriesEn={allCategoriesEn}
|
||||
/>
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
wrapperClassName='w-[200px] self-start'
|
||||
value={keywords}
|
||||
onChange={e => handleKeywordsChange(e.target.value)}
|
||||
onClear={() => handleKeywordsChange('')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
'relative mt-4 flex flex-1 shrink-0 grow flex-col overflow-auto pb-6',
|
||||
)}>
|
||||
<nav
|
||||
className={cn(
|
||||
s.appList,
|
||||
'grid shrink-0 content-start gap-4 px-6 sm:px-12',
|
||||
)}>
|
||||
{searchFilteredList.map(app => (
|
||||
<AppCard
|
||||
key={app.app_id}
|
||||
isExplore
|
||||
app={app}
|
||||
canCreate={hasEditPermission}
|
||||
onCreate={() => {
|
||||
setCurrApp(app)
|
||||
setIsShowCreateModal(true)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
{isShowCreateModal && (
|
||||
<CreateAppModal
|
||||
appIconType={currApp?.app.icon_type || 'emoji'}
|
||||
appIcon={currApp?.app.icon || ''}
|
||||
appIconBackground={currApp?.app.icon_background || ''}
|
||||
appIconUrl={currApp?.app.icon_url}
|
||||
appName={currApp?.app.name || ''}
|
||||
appDescription={currApp?.app.description || ''}
|
||||
show={isShowCreateModal}
|
||||
onConfirm={onCreate}
|
||||
confirmDisabled={isFetching}
|
||||
onHide={() => setIsShowCreateModal(false)}
|
||||
/>
|
||||
)}
|
||||
{
|
||||
showDSLConfirmModal && (
|
||||
<DSLConfirmModal
|
||||
versions={versions}
|
||||
onCancel={() => setShowDSLConfirmModal(false)}
|
||||
onConfirm={onConfirmDSL}
|
||||
confirmDisabled={isFetching}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Apps)
|
||||
29
dify/web/app/components/explore/app-list/style.module.css
Normal file
29
dify/web/app/components/explore/app-list/style.module.css
Normal file
@@ -0,0 +1,29 @@
|
||||
.textGradient {
|
||||
background: linear-gradient(to right, rgba(16, 74, 225, 1) 0, rgba(0, 152, 238, 1) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.appList {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr))
|
||||
}
|
||||
|
||||
@media (min-width: 1624px) {
|
||||
.appList {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr))
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1300px) and (max-width: 1624px) {
|
||||
.appList {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr))
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1025px) and (max-width: 1300px) {
|
||||
.appList {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr))
|
||||
}
|
||||
}
|
||||
60
dify/web/app/components/explore/category.tsx
Normal file
60
dify/web/app/components/explore/category.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import cn from '@/utils/classnames'
|
||||
import exploreI18n from '@/i18n/en-US/explore'
|
||||
import type { AppCategory } from '@/models/explore'
|
||||
import { ThumbsUp } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
|
||||
|
||||
const categoryI18n = exploreI18n.category
|
||||
|
||||
export type ICategoryProps = {
|
||||
className?: string
|
||||
list: AppCategory[]
|
||||
value: string
|
||||
onChange: (value: AppCategory | string) => void
|
||||
/**
|
||||
* default value for search param 'category' in en
|
||||
*/
|
||||
allCategoriesEn: string
|
||||
}
|
||||
|
||||
const Category: FC<ICategoryProps> = ({
|
||||
className,
|
||||
list,
|
||||
value,
|
||||
onChange,
|
||||
allCategoriesEn,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isAllCategories = !list.includes(value as AppCategory) || value === allCategoriesEn
|
||||
|
||||
const itemClassName = (isSelected: boolean) => cn(
|
||||
'flex h-[32px] cursor-pointer items-center rounded-lg border-[0.5px] border-transparent px-3 py-[7px] font-medium leading-[18px] text-text-tertiary hover:bg-components-main-nav-nav-button-bg-active',
|
||||
isSelected && 'border-components-main-nav-nav-button-border bg-components-main-nav-nav-button-bg-active text-components-main-nav-nav-button-text-active shadow-xs',
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn(className, 'flex flex-wrap gap-1 text-[13px]')}>
|
||||
<div
|
||||
className={itemClassName(isAllCategories)}
|
||||
onClick={() => onChange(allCategoriesEn)}
|
||||
>
|
||||
<ThumbsUp className='mr-1 h-3.5 w-3.5' />
|
||||
{t('explore.apps.allCategories')}
|
||||
</div>
|
||||
{list.filter(name => name !== allCategoriesEn).map(name => (
|
||||
<div
|
||||
key={name}
|
||||
className={itemClassName(name === value)}
|
||||
onClick={() => onChange(name)}
|
||||
>
|
||||
{(categoryI18n as any)[name] ? t(`explore.category.${name}`) : name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Category)
|
||||
223
dify/web/app/components/explore/create-app-modal/index.tsx
Normal file
223
dify/web/app/components/explore/create-app-modal/index.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
'use client'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCloseLine, RiCommandLine, RiCornerDownLeftLine } from '@remixicon/react'
|
||||
import { useDebounceFn, useKeyPress } from 'ahooks'
|
||||
import AppIconPicker from '../../base/app-icon-picker'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import { type AppIconType, AppModeEnum } from '@/types/app'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
export type CreateAppModalProps = {
|
||||
show: boolean
|
||||
isEditModal?: boolean
|
||||
appName: string
|
||||
appDescription: string
|
||||
appIconType: AppIconType | null
|
||||
appIcon: string
|
||||
appIconBackground?: string | null
|
||||
appIconUrl?: string | null
|
||||
appMode?: string
|
||||
appUseIconAsAnswerIcon?: boolean
|
||||
max_active_requests?: number | null
|
||||
onConfirm: (info: {
|
||||
name: string
|
||||
icon_type: AppIconType
|
||||
icon: string
|
||||
icon_background?: string
|
||||
description: string
|
||||
use_icon_as_answer_icon?: boolean
|
||||
max_active_requests?: number | null
|
||||
}) => Promise<void>
|
||||
confirmDisabled?: boolean
|
||||
onHide: () => void
|
||||
}
|
||||
|
||||
const CreateAppModal = ({
|
||||
show = false,
|
||||
isEditModal = false,
|
||||
appIconType,
|
||||
appIcon: _appIcon,
|
||||
appIconBackground,
|
||||
appIconUrl,
|
||||
appName,
|
||||
appDescription,
|
||||
appMode,
|
||||
appUseIconAsAnswerIcon,
|
||||
max_active_requests,
|
||||
onConfirm,
|
||||
confirmDisabled,
|
||||
onHide,
|
||||
}: CreateAppModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [name, setName] = React.useState(appName)
|
||||
const [appIcon, setAppIcon] = useState(
|
||||
() => appIconType === 'image'
|
||||
? { type: 'image' as const, fileId: _appIcon, url: appIconUrl }
|
||||
: { type: 'emoji' as const, icon: _appIcon, background: appIconBackground },
|
||||
)
|
||||
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
|
||||
const [description, setDescription] = useState(appDescription || '')
|
||||
const [useIconAsAnswerIcon, setUseIconAsAnswerIcon] = useState(appUseIconAsAnswerIcon || false)
|
||||
|
||||
const [maxActiveRequestsInput, setMaxActiveRequestsInput] = useState(
|
||||
max_active_requests !== null && max_active_requests !== undefined ? String(max_active_requests) : '',
|
||||
)
|
||||
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps)
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (!name.trim()) {
|
||||
Toast.notify({ type: 'error', message: t('explore.appCustomize.nameRequired') })
|
||||
return
|
||||
}
|
||||
const isValid = maxActiveRequestsInput.trim() !== '' && !isNaN(Number(maxActiveRequestsInput))
|
||||
const payload: any = {
|
||||
name,
|
||||
icon_type: appIcon.type,
|
||||
icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId,
|
||||
icon_background: appIcon.type === 'emoji' ? appIcon.background! : undefined,
|
||||
description,
|
||||
use_icon_as_answer_icon: useIconAsAnswerIcon,
|
||||
}
|
||||
if (isValid)
|
||||
payload.max_active_requests = Number(maxActiveRequestsInput)
|
||||
|
||||
onConfirm(payload)
|
||||
onHide()
|
||||
}, [name, appIcon, description, useIconAsAnswerIcon, onConfirm, onHide, t, maxActiveRequestsInput])
|
||||
|
||||
const { run: handleSubmit } = useDebounceFn(submit, { wait: 300 })
|
||||
|
||||
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
|
||||
if (show && !(!isEditModal && isAppsFull) && name.trim())
|
||||
handleSubmit()
|
||||
})
|
||||
|
||||
useKeyPress('esc', () => {
|
||||
if (show)
|
||||
onHide()
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isShow={show}
|
||||
onClose={noop}
|
||||
className='relative !max-w-[480px] px-8'
|
||||
>
|
||||
<div className='absolute right-4 top-4 cursor-pointer p-2' onClick={onHide}>
|
||||
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
{isEditModal && (
|
||||
<div className='mb-9 text-xl font-semibold leading-[30px] text-text-primary'>{t('app.editAppTitle')}</div>
|
||||
)}
|
||||
{!isEditModal && (
|
||||
<div className='mb-9 text-xl font-semibold leading-[30px] text-text-primary'>{t('explore.appCustomize.title', { name: appName })}</div>
|
||||
)}
|
||||
<div className='mb-9'>
|
||||
{/* icon & name */}
|
||||
<div className='pt-2'>
|
||||
<div className='py-2 text-sm font-medium leading-[20px] text-text-primary'>{t('app.newApp.captionName')}</div>
|
||||
<div className='flex items-center justify-between space-x-2'>
|
||||
<AppIcon
|
||||
size='large'
|
||||
onClick={() => { setShowAppIconPicker(true) }}
|
||||
className='cursor-pointer'
|
||||
iconType={appIcon.type}
|
||||
icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon}
|
||||
background={appIcon.type === 'image' ? undefined : appIcon.background}
|
||||
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
|
||||
/>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder={t('app.newApp.appNamePlaceholder') || ''}
|
||||
className='h-10 grow'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* description */}
|
||||
<div className='pt-2'>
|
||||
<div className='py-2 text-sm font-medium leading-[20px] text-text-primary'>{t('app.newApp.captionDescription')}</div>
|
||||
<Textarea
|
||||
className='resize-none'
|
||||
placeholder={t('app.newApp.appDescriptionPlaceholder') || ''}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* answer icon */}
|
||||
{isEditModal && (appMode === AppModeEnum.CHAT || appMode === AppModeEnum.ADVANCED_CHAT || appMode === AppModeEnum.AGENT_CHAT) && (
|
||||
<div className='pt-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='py-2 text-sm font-medium leading-[20px] text-text-primary'>{t('app.answerIcon.title')}</div>
|
||||
<Switch
|
||||
defaultValue={useIconAsAnswerIcon}
|
||||
onChange={v => setUseIconAsAnswerIcon(v)}
|
||||
/>
|
||||
</div>
|
||||
<p className='body-xs-regular text-text-tertiary'>{t('app.answerIcon.descriptionInExplore')}</p>
|
||||
</div>
|
||||
)}
|
||||
{isEditModal && (
|
||||
<div className='pt-2'>
|
||||
<div className='mb-2 mt-2 text-sm font-medium leading-[20px] text-text-primary'>{t('app.maxActiveRequests')}</div>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
placeholder={t('app.maxActiveRequestsPlaceholder')}
|
||||
value={maxActiveRequestsInput}
|
||||
onChange={(e) => {
|
||||
setMaxActiveRequestsInput(e.target.value)
|
||||
}}
|
||||
className='h-10 w-full'
|
||||
/>
|
||||
<p className='body-xs-regular mb-0 mt-2 text-text-tertiary'>{t('app.maxActiveRequestsTip')}</p>
|
||||
</div>
|
||||
)}
|
||||
{!isEditModal && isAppsFull && <AppsFull className='mt-4' loc='app-explore-create' />}
|
||||
</div>
|
||||
<div className='flex flex-row-reverse'>
|
||||
<Button
|
||||
disabled={(!isEditModal && isAppsFull) || !name.trim() || confirmDisabled}
|
||||
className='ml-2 w-24 gap-1'
|
||||
variant='primary'
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
<span>{!isEditModal ? t('common.operation.create') : t('common.operation.save')}</span>
|
||||
<div className='flex gap-0.5'>
|
||||
<RiCommandLine size={14} className='system-kbd rounded-sm bg-components-kbd-bg-white p-0.5' />
|
||||
<RiCornerDownLeftLine size={14} className='system-kbd rounded-sm bg-components-kbd-bg-white p-0.5' />
|
||||
</div>
|
||||
</Button>
|
||||
<Button className='w-24' onClick={onHide}>{t('common.operation.cancel')}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
{showAppIconPicker && <AppIconPicker
|
||||
onSelect={(payload) => {
|
||||
setAppIcon(payload)
|
||||
setShowAppIconPicker(false)
|
||||
}}
|
||||
onClose={() => {
|
||||
setAppIcon(appIconType === 'image'
|
||||
? { type: 'image' as const, url: appIconUrl, fileId: _appIcon }
|
||||
: { type: 'emoji' as const, icon: _appIcon, background: appIconBackground })
|
||||
setShowAppIconPicker(false)
|
||||
}}
|
||||
/>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateAppModal
|
||||
68
dify/web/app/components/explore/index.tsx
Normal file
68
dify/web/app/components/explore/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import Sidebar from '@/app/components/explore/sidebar'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { fetchMembers } from '@/service/common'
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
|
||||
export type IExploreProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Explore: FC<IExploreProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const [controlUpdateInstalledApps, setControlUpdateInstalledApps] = useState(0)
|
||||
const { userProfile, isCurrentWorkspaceDatasetOperator } = useAppContext()
|
||||
const [hasEditPermission, setHasEditPermission] = useState(false)
|
||||
const [installedApps, setInstalledApps] = useState<InstalledApp[]>([])
|
||||
const [isFetchingInstalledApps, setIsFetchingInstalledApps] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
useDocumentTitle(t('common.menus.explore'))
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const { accounts } = await fetchMembers({ url: '/workspaces/current/members', params: {} })
|
||||
if (!accounts)
|
||||
return
|
||||
const currUser = accounts.find(account => account.id === userProfile.id)
|
||||
setHasEditPermission(currUser?.role !== 'normal')
|
||||
})()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCurrentWorkspaceDatasetOperator)
|
||||
return router.replace('/datasets')
|
||||
}, [isCurrentWorkspaceDatasetOperator])
|
||||
|
||||
return (
|
||||
<div className='flex h-full overflow-hidden border-t border-divider-regular bg-background-body'>
|
||||
<ExploreContext.Provider
|
||||
value={
|
||||
{
|
||||
controlUpdateInstalledApps,
|
||||
setControlUpdateInstalledApps,
|
||||
hasEditPermission,
|
||||
installedApps,
|
||||
setInstalledApps,
|
||||
isFetchingInstalledApps,
|
||||
setIsFetchingInstalledApps,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Sidebar controlUpdateInstalledApps={controlUpdateInstalledApps} />
|
||||
<div className='w-0 grow'>
|
||||
{children}
|
||||
</div>
|
||||
</ExploreContext.Provider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Explore)
|
||||
118
dify/web/app/components/explore/installed-app/index.tsx
Normal file
118
dify/web/app/components/explore/installed-app/index.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import React from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import TextGenerationApp from '@/app/components/share/text-generation'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import ChatWithHistory from '@/app/components/base/chat/chat-with-history'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import AppUnavailable from '../../base/app-unavailable'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore'
|
||||
import type { AppData } from '@/models/share'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
export type IInstalledAppProps = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const InstalledApp: FC<IInstalledAppProps> = ({
|
||||
id,
|
||||
}) => {
|
||||
const { installedApps, isFetchingInstalledApps } = useContext(ExploreContext)
|
||||
const updateAppInfo = useWebAppStore(s => s.updateAppInfo)
|
||||
const installedApp = installedApps.find(item => item.id === id)
|
||||
const updateWebAppAccessMode = useWebAppStore(s => s.updateWebAppAccessMode)
|
||||
const updateAppParams = useWebAppStore(s => s.updateAppParams)
|
||||
const updateWebAppMeta = useWebAppStore(s => s.updateWebAppMeta)
|
||||
const updateUserCanAccessApp = useWebAppStore(s => s.updateUserCanAccessApp)
|
||||
const { isFetching: isFetchingWebAppAccessMode, data: webAppAccessMode, error: webAppAccessModeError } = useGetInstalledAppAccessModeByAppId(installedApp?.id ?? null)
|
||||
const { isFetching: isFetchingAppParams, data: appParams, error: appParamsError } = useGetInstalledAppParams(installedApp?.id ?? null)
|
||||
const { isFetching: isFetchingAppMeta, data: appMeta, error: appMetaError } = useGetInstalledAppMeta(installedApp?.id ?? null)
|
||||
const { data: userCanAccessApp, error: useCanAccessAppError } = useGetUserCanAccessApp({ appId: installedApp?.app.id, isInstalledApp: true })
|
||||
|
||||
useEffect(() => {
|
||||
if (!installedApp) {
|
||||
updateAppInfo(null)
|
||||
}
|
||||
else {
|
||||
const { id, app } = installedApp
|
||||
updateAppInfo({
|
||||
app_id: id,
|
||||
site: {
|
||||
title: app.name,
|
||||
icon_type: app.icon_type,
|
||||
icon: app.icon,
|
||||
icon_background: app.icon_background,
|
||||
icon_url: app.icon_url,
|
||||
prompt_public: false,
|
||||
copyright: '',
|
||||
show_workflow_steps: true,
|
||||
use_icon_as_answer_icon: app.use_icon_as_answer_icon,
|
||||
},
|
||||
plan: 'basic',
|
||||
custom_config: null,
|
||||
} as AppData)
|
||||
}
|
||||
|
||||
if (appParams)
|
||||
updateAppParams(appParams)
|
||||
if (appMeta)
|
||||
updateWebAppMeta(appMeta)
|
||||
if (webAppAccessMode)
|
||||
updateWebAppAccessMode(webAppAccessMode.accessMode)
|
||||
updateUserCanAccessApp(Boolean(userCanAccessApp && userCanAccessApp?.result))
|
||||
}, [installedApp, appMeta, appParams, updateAppInfo, updateAppParams, updateUserCanAccessApp, updateWebAppMeta, userCanAccessApp, webAppAccessMode, updateWebAppAccessMode])
|
||||
|
||||
if (appParamsError) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<AppUnavailable unknownReason={appParamsError.message} />
|
||||
</div>
|
||||
}
|
||||
if (appMetaError) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<AppUnavailable unknownReason={appMetaError.message} />
|
||||
</div>
|
||||
}
|
||||
if (useCanAccessAppError) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<AppUnavailable unknownReason={useCanAccessAppError.message} />
|
||||
</div>
|
||||
}
|
||||
if (webAppAccessModeError) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<AppUnavailable unknownReason={webAppAccessModeError.message} />
|
||||
</div>
|
||||
}
|
||||
if (userCanAccessApp && !userCanAccessApp.result) {
|
||||
return <div className='flex h-full flex-col items-center justify-center gap-y-2'>
|
||||
<AppUnavailable className='h-auto w-auto' code={403} unknownReason='no permission.' />
|
||||
</div>
|
||||
}
|
||||
if (isFetchingAppParams || isFetchingAppMeta || isFetchingWebAppAccessMode || isFetchingInstalledApps) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<Loading />
|
||||
</div>
|
||||
}
|
||||
if (!installedApp) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<AppUnavailable code={404} isUnknownReason />
|
||||
</div>
|
||||
}
|
||||
return (
|
||||
<div className='h-full bg-background-default py-2 pl-0 pr-2 sm:p-2'>
|
||||
{installedApp?.app.mode !== AppModeEnum.COMPLETION && installedApp?.app.mode !== AppModeEnum.WORKFLOW && (
|
||||
<ChatWithHistory installedAppInfo={installedApp} className='overflow-hidden rounded-2xl shadow-md' />
|
||||
)}
|
||||
{installedApp?.app.mode === AppModeEnum.COMPLETION && (
|
||||
<TextGenerationApp isInstalledApp installedAppInfo={installedApp} />
|
||||
)}
|
||||
{installedApp?.app.mode === AppModeEnum.WORKFLOW && (
|
||||
<TextGenerationApp isWorkflow isInstalledApp installedAppInfo={installedApp} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(InstalledApp)
|
||||
90
dify/web/app/components/explore/item-operation/index.tsx
Normal file
90
dify/web/app/components/explore/item-operation/index.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiEditLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { Pin02 } from '../../base/icons/src/vender/line/general'
|
||||
|
||||
import s from './style.module.css'
|
||||
import cn from '@/utils/classnames'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
export type IItemOperationProps = {
|
||||
className?: string
|
||||
isItemHovering?: boolean
|
||||
isPinned: boolean
|
||||
isShowRenameConversation?: boolean
|
||||
onRenameConversation?: () => void
|
||||
isShowDelete: boolean
|
||||
togglePin: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
const ItemOperation: FC<IItemOperationProps> = ({
|
||||
className,
|
||||
isItemHovering,
|
||||
isPinned,
|
||||
togglePin,
|
||||
isShowRenameConversation,
|
||||
onRenameConversation,
|
||||
isShowDelete,
|
||||
onDelete,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
const [isHovering, { setTrue: setIsHovering, setFalse: setNotHovering }] = useBoolean(false)
|
||||
useEffect(() => {
|
||||
if (!isItemHovering && !isHovering)
|
||||
setOpen(false)
|
||||
}, [isItemHovering, isHovering])
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-end'
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
>
|
||||
<div className={cn(className, s.btn, 'h-6 w-6 rounded-md border-none py-1', (isItemHovering || open) && `${s.open} !bg-components-actionbar-bg !shadow-none`)}></div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent
|
||||
className="z-50"
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className={'min-w-[120px] rounded-lg border border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]'}
|
||||
onMouseEnter={setIsHovering}
|
||||
onMouseLeave={setNotHovering}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<div className={cn(s.actionItem, 'group hover:bg-state-base-hover')} onClick={togglePin}>
|
||||
<Pin02 className='h-4 w-4 shrink-0 text-text-secondary' />
|
||||
<span className={s.actionName}>{isPinned ? t('explore.sidebar.action.unpin') : t('explore.sidebar.action.pin')}</span>
|
||||
</div>
|
||||
{isShowRenameConversation && (
|
||||
<div className={cn(s.actionItem, 'group hover:bg-state-base-hover')} onClick={onRenameConversation}>
|
||||
<RiEditLine className='h-4 w-4 shrink-0 text-text-secondary' />
|
||||
<span className={s.actionName}>{t('explore.sidebar.action.rename')}</span>
|
||||
</div>
|
||||
)}
|
||||
{isShowDelete && (
|
||||
<div className={cn(s.actionItem, s.deleteActionItem, 'group hover:bg-state-base-hover')} onClick={onDelete} >
|
||||
<RiDeleteBinLine className={cn(s.deleteActionItemChild, 'h-4 w-4 shrink-0 stroke-current stroke-2 text-text-secondary')} />
|
||||
<span className={cn(s.actionName, s.deleteActionItemChild)}>{t('explore.sidebar.action.delete')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
export default React.memo(ItemOperation)
|
||||
@@ -0,0 +1,33 @@
|
||||
.actionItem {
|
||||
@apply h-9 py-2 px-3 mx-1 flex items-center gap-2 rounded-lg cursor-pointer;
|
||||
}
|
||||
|
||||
.actionName {
|
||||
@apply text-text-secondary text-sm;
|
||||
}
|
||||
|
||||
.commonIcon {
|
||||
@apply w-4 h-4 inline-block align-middle;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.actionIcon {
|
||||
@apply bg-gray-500;
|
||||
mask-image: url(~@/assets/action.svg);
|
||||
}
|
||||
|
||||
body .btn.open,
|
||||
body .btn:hover {
|
||||
background: url(~@/assets/action.svg) center center no-repeat transparent;
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
body .btn:hover {
|
||||
background-color: #F2F4F7;
|
||||
}
|
||||
|
||||
.deleteActionItem:hover .deleteActionItemChild {
|
||||
@apply text-red-500;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
import React, { useRef } from 'react'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useHover } from 'ahooks'
|
||||
import cn from '@/utils/classnames'
|
||||
import ItemOperation from '@/app/components/explore/item-operation'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
|
||||
export type IAppNavItemProps = {
|
||||
isMobile: boolean
|
||||
name: string
|
||||
id: string
|
||||
icon_type: AppIconType | null
|
||||
icon: string
|
||||
icon_background: string
|
||||
icon_url: string
|
||||
isSelected: boolean
|
||||
isPinned: boolean
|
||||
togglePin: () => void
|
||||
uninstallable: boolean
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
export default function AppNavItem({
|
||||
isMobile,
|
||||
name,
|
||||
id,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
icon_url,
|
||||
isSelected,
|
||||
isPinned,
|
||||
togglePin,
|
||||
uninstallable,
|
||||
onDelete,
|
||||
}: IAppNavItemProps) {
|
||||
const router = useRouter()
|
||||
const url = `/explore/installed/${id}`
|
||||
const ref = useRef(null)
|
||||
const isHovering = useHover(ref)
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
key={id}
|
||||
className={cn('system-sm-medium flex h-8 items-center justify-between rounded-lg px-2 text-sm font-normal text-components-menu-item-text mobile:justify-center mobile:px-1',
|
||||
isSelected ? 'bg-state-base-active text-components-menu-item-text-active' : 'hover:bg-state-base-hover hover:text-components-menu-item-text-hover',
|
||||
)}
|
||||
onClick={() => {
|
||||
router.push(url) // use Link causes popup item always trigger jump. Can not be solved by e.stopPropagation().
|
||||
}}
|
||||
>
|
||||
{isMobile && <AppIcon size='tiny' iconType={icon_type} icon={icon} background={icon_background} imageUrl={icon_url} />}
|
||||
{!isMobile && (
|
||||
<>
|
||||
<div className='flex w-0 grow items-center space-x-2'>
|
||||
<AppIcon size='tiny' iconType={icon_type} icon={icon} background={icon_background} imageUrl={icon_url} />
|
||||
<div className='overflow-hidden text-ellipsis whitespace-nowrap' title={name}>{name}</div>
|
||||
</div>
|
||||
<div className='h-6 shrink-0' onClick={e => e.stopPropagation()}>
|
||||
<ItemOperation
|
||||
isPinned={isPinned}
|
||||
isItemHovering={isHovering}
|
||||
togglePin={togglePin}
|
||||
isShowDelete={!uninstallable && !isSelected}
|
||||
onDelete={() => onDelete(id)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
144
dify/web/app/components/explore/sidebar/index.tsx
Normal file
144
dify/web/app/components/explore/sidebar/index.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useSelectedLayoutSegments } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import Toast from '../../base/toast'
|
||||
import Item from './app-nav-item'
|
||||
import cn from '@/utils/classnames'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
|
||||
|
||||
const SelectedDiscoveryIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="current" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M13.4135 1.11725C13.5091 1.09983 13.6483 1.08355 13.8078 1.11745C14.0143 1.16136 14.2017 1.26953 14.343 1.42647C14.4521 1.54766 14.5076 1.67634 14.5403 1.76781C14.5685 1.84673 14.593 1.93833 14.6136 2.01504L15.5533 5.5222C15.5739 5.5989 15.5985 5.69049 15.6135 5.77296C15.6309 5.86852 15.6472 6.00771 15.6133 6.16722C15.5694 6.37378 15.4612 6.56114 15.3043 6.70245C15.1831 6.81157 15.0544 6.86706 14.9629 6.89975C14.884 6.92796 14.7924 6.95247 14.7157 6.97299L14.676 6.98364C14.3365 7.07461 14.0437 7.15309 13.7972 7.19802C13.537 7.24543 13.2715 7.26736 12.9946 7.20849C12.7513 7.15677 12.5213 7.06047 12.3156 6.92591L9.63273 7.64477C9.86399 7.97104 9.99992 8.36965 9.99992 8.80001C9.99992 9.2424 9.85628 9.65124 9.6131 9.98245L12.5508 14.291C12.7582 14.5952 12.6797 15.01 12.3755 15.2174C12.0713 15.4248 11.6566 15.3464 11.4492 15.0422L8.51171 10.7339C8.34835 10.777 8.17682 10.8 7.99992 10.8C7.82305 10.8 7.65155 10.777 7.48823 10.734L4.5508 15.0422C4.34338 15.3464 3.92863 15.4248 3.62442 15.2174C3.32021 15.01 3.24175 14.5952 3.44916 14.291L6.3868 9.98254C6.14358 9.65132 5.99992 9.24244 5.99992 8.80001C5.99992 8.73795 6.00274 8.67655 6.00827 8.61594L4.59643 8.99424C4.51973 9.01483 4.42813 9.03941 4.34567 9.05444C4.25011 9.07185 4.11092 9.08814 3.95141 9.05423C3.74485 9.01033 3.55748 8.90215 3.41618 8.74522C3.38535 8.71097 3.3588 8.67614 3.33583 8.64171L2.49206 8.8678C2.41536 8.88838 2.32376 8.91296 2.2413 8.92799C2.14574 8.94541 2.00655 8.96169 1.84704 8.92779C1.64048 8.88388 1.45311 8.77571 1.31181 8.61877C1.20269 8.49759 1.1472 8.3689 1.1145 8.27744C1.08629 8.1985 1.06177 8.10689 1.04125 8.03018L0.791701 7.09885C0.771119 7.02215 0.746538 6.93055 0.731508 6.84809C0.714092 6.75253 0.697808 6.61334 0.731712 6.45383C0.775619 6.24726 0.883793 6.0599 1.04073 5.9186C1.16191 5.80948 1.2906 5.75399 1.38206 5.72129C1.461 5.69307 1.55261 5.66856 1.62932 5.64804L2.47318 5.42193C2.47586 5.38071 2.48143 5.33735 2.49099 5.29237C2.5349 5.08581 2.64307 4.89844 2.80001 4.75714C2.92119 4.64802 3.04988 4.59253 3.14134 4.55983C3.22027 4.53162 3.31189 4.50711 3.3886 4.48658L11.1078 2.41824C11.2186 2.19888 11.3697 2.00049 11.5545 1.83406C11.7649 1.64462 12.0058 1.53085 12.2548 1.44183C12.4907 1.35749 12.7836 1.27904 13.123 1.18809L13.1628 1.17744C13.2395 1.15686 13.3311 1.13228 13.4135 1.11725ZM13.3642 2.5039C13.0648 2.58443 12.8606 2.64126 12.7036 2.69735C12.5325 2.75852 12.4742 2.80016 12.4467 2.82492C12.3421 2.91912 12.2699 3.04403 12.2407 3.18174C12.233 3.21793 12.2261 3.28928 12.2587 3.46805C12.2927 3.6545 12.3564 3.89436 12.4559 4.26563L12.5594 4.652C12.6589 5.02328 12.7236 5.26287 12.7874 5.44133C12.8486 5.61244 12.8902 5.67079 12.915 5.69829C13.0092 5.80291 13.1341 5.87503 13.2718 5.9043C13.308 5.91199 13.3793 5.91887 13.5581 5.88629C13.7221 5.85641 13.9273 5.80352 14.2269 5.72356L13.3642 2.5039Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const DiscoveryIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="current" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.74786 9.89676L12.0003 14.6669M7.25269 9.89676L4.00027 14.6669M9.3336 8.80031C9.3336 9.53669 8.73665 10.1336 8.00027 10.1336C7.26389 10.1336 6.66694 9.53669 6.66694 8.80031C6.66694 8.06393 7.26389 7.46698 8.00027 7.46698C8.73665 7.46698 9.3336 8.06393 9.3336 8.80031ZM11.4326 3.02182L3.57641 5.12689C3.39609 5.1752 3.30593 5.19936 3.24646 5.25291C3.19415 5.30001 3.15809 5.36247 3.14345 5.43132C3.12681 5.5096 3.15097 5.59976 3.19929 5.78008L3.78595 7.96951C3.83426 8.14984 3.85842 8.24 3.91197 8.29947C3.95907 8.35178 4.02153 8.38784 4.09038 8.40248C4.16866 8.41911 4.25882 8.39496 4.43914 8.34664L12.2953 6.24158L11.4326 3.02182ZM14.5285 6.33338C13.8072 6.52665 13.4466 6.62328 13.1335 6.55673C12.8581 6.49819 12.6082 6.35396 12.4198 6.14471C12.2056 5.90682 12.109 5.54618 11.9157 4.82489L11.8122 4.43852C11.6189 3.71722 11.5223 3.35658 11.5889 3.04347C11.6474 2.76805 11.7916 2.51823 12.0009 2.32982C12.2388 2.11563 12.5994 2.019 13.3207 1.82573C13.501 1.77741 13.5912 1.75325 13.6695 1.76989C13.7383 1.78452 13.8008 1.82058 13.8479 1.87289C13.9014 1.93237 13.9256 2.02253 13.9739 2.20285L14.9057 5.68018C14.954 5.86051 14.9781 5.95067 14.9615 6.02894C14.9469 6.0978 14.9108 6.16025 14.8585 6.20736C14.799 6.2609 14.7088 6.28506 14.5285 6.33338ZM2.33475 8.22033L3.23628 7.97876C3.4166 7.93044 3.50676 7.90628 3.56623 7.85274C3.61854 7.80563 3.6546 7.74318 3.66924 7.67433C3.68588 7.59605 3.66172 7.50589 3.6134 7.32556L3.37184 6.42403C3.32352 6.24371 3.29936 6.15355 3.24581 6.09408C3.19871 6.04176 3.13626 6.00571 3.0674 5.99107C2.98912 5.97443 2.89896 5.99859 2.71864 6.04691L1.81711 6.28847C1.63678 6.33679 1.54662 6.36095 1.48715 6.4145C1.43484 6.4616 1.39878 6.52405 1.38415 6.59291C1.36751 6.67119 1.39167 6.76135 1.43998 6.94167L1.68155 7.8432C1.72987 8.02352 1.75402 8.11369 1.80757 8.17316C1.85467 8.22547 1.91713 8.26153 1.98598 8.27616C2.06426 8.2928 2.15442 8.26864 2.33475 8.22033Z" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export type IExploreSideBarProps = {
|
||||
controlUpdateInstalledApps: number
|
||||
}
|
||||
|
||||
const SideBar: FC<IExploreSideBarProps> = ({
|
||||
controlUpdateInstalledApps,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const segments = useSelectedLayoutSegments()
|
||||
const lastSegment = segments.slice(-1)[0]
|
||||
const isDiscoverySelected = lastSegment === 'apps'
|
||||
const { installedApps, setInstalledApps, setIsFetchingInstalledApps } = useContext(ExploreContext)
|
||||
const { isFetching: isFetchingInstalledApps, data: ret, refetch: fetchInstalledAppList } = useGetInstalledApps()
|
||||
const { mutateAsync: uninstallApp } = useUninstallApp()
|
||||
const { mutateAsync: updatePinStatus } = useUpdateAppPinStatus()
|
||||
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
const [currId, setCurrId] = useState('')
|
||||
const handleDelete = async () => {
|
||||
const id = currId
|
||||
await uninstallApp(id)
|
||||
setShowConfirm(false)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('common.api.remove'),
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdatePinStatus = async (id: string, isPinned: boolean) => {
|
||||
await updatePinStatus({ appId: id, isPinned })
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('common.api.success'),
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const installed_apps = (ret as any)?.installed_apps
|
||||
if (installed_apps && installed_apps.length > 0)
|
||||
setInstalledApps(installed_apps)
|
||||
else
|
||||
setInstalledApps([])
|
||||
}, [ret, setInstalledApps])
|
||||
|
||||
useEffect(() => {
|
||||
setIsFetchingInstalledApps(isFetchingInstalledApps)
|
||||
}, [isFetchingInstalledApps, setIsFetchingInstalledApps])
|
||||
|
||||
useEffect(() => {
|
||||
fetchInstalledAppList()
|
||||
}, [controlUpdateInstalledApps, fetchInstalledAppList])
|
||||
|
||||
const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length
|
||||
return (
|
||||
<div className='w-fit shrink-0 cursor-pointer border-r border-divider-burn px-4 pt-6 sm:w-[216px]'>
|
||||
<div className={cn(isDiscoverySelected ? 'text-text-accent' : 'text-text-tertiary')}>
|
||||
<Link
|
||||
href='/explore/apps'
|
||||
className={cn(isDiscoverySelected ? ' bg-components-main-nav-nav-button-bg-active' : 'font-medium hover:bg-state-base-hover',
|
||||
'flex h-9 items-center gap-2 rounded-lg px-3 mobile:w-fit mobile:justify-center mobile:px-2 pc:w-full pc:justify-start')}
|
||||
style={isDiscoverySelected ? { boxShadow: '0px 1px 2px rgba(16, 24, 40, 0.05)' } : {}}
|
||||
>
|
||||
{isDiscoverySelected ? <SelectedDiscoveryIcon /> : <DiscoveryIcon />}
|
||||
{!isMobile && <div className='text-sm'>{t('explore.sidebar.discovery')}</div>}
|
||||
</Link>
|
||||
</div>
|
||||
{installedApps.length > 0 && (
|
||||
<div className='mt-10'>
|
||||
<p className='break-all pl-2 text-xs font-medium uppercase text-text-tertiary mobile:px-0'>{t('explore.sidebar.workspace')}</p>
|
||||
<div className='mt-3 space-y-1 overflow-y-auto overflow-x-hidden'
|
||||
style={{
|
||||
height: 'calc(100vh - 250px)',
|
||||
}}
|
||||
>
|
||||
{installedApps.map(({ id, is_pinned, uninstallable, app: { name, icon_type, icon, icon_url, icon_background } }, index) => (
|
||||
<React.Fragment key={id}>
|
||||
<Item
|
||||
isMobile={isMobile}
|
||||
name={name}
|
||||
icon_type={icon_type}
|
||||
icon={icon}
|
||||
icon_background={icon_background}
|
||||
icon_url={icon_url}
|
||||
id={id}
|
||||
isSelected={lastSegment?.toLowerCase() === id}
|
||||
isPinned={is_pinned}
|
||||
togglePin={() => handleUpdatePinStatus(id, !is_pinned)}
|
||||
uninstallable={uninstallable}
|
||||
onDelete={(id) => {
|
||||
setCurrId(id)
|
||||
setShowConfirm(true)
|
||||
}}
|
||||
/>
|
||||
{index === pinnedAppsCount - 1 && index !== installedApps.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showConfirm && (
|
||||
<Confirm
|
||||
title={t('explore.sidebar.delete.title')}
|
||||
content={t('explore.sidebar.delete.content')}
|
||||
isShow={showConfirm}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setShowConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SideBar)
|
||||
Reference in New Issue
Block a user