dify
This commit is contained in:
501
dify/web/app/components/apps/app-card.tsx
Normal file
501
dify/web/app/components/apps/app-card.tsx
Normal file
@@ -0,0 +1,501 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { type App, AppModeEnum } from '@/types/app'
|
||||
import Toast, { ToastContext } from '@/app/components/base/toast'
|
||||
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
|
||||
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import type { HtmlContentProps } from '@/app/components/base/popover'
|
||||
import CustomPopover from '@/app/components/base/popover'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { basePath } from '@/utils/var'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import TagSelector from '@/app/components/base/tag-management/selector'
|
||||
import type { EnvironmentVariable } from '@/app/components/workflow/types'
|
||||
import { fetchWorkflowDraft } from '@/service/workflow'
|
||||
import { fetchInstalledAppList } from '@/service/explore'
|
||||
import { AppTypeIcon } from '@/app/components/app/type-selector'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { formatTime } from '@/utils/time'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const EditAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const DuplicateAppModal = dynamic(() => import('@/app/components/app/duplicate-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const Confirm = dynamic(() => import('@/app/components/base/confirm'), {
|
||||
ssr: false,
|
||||
})
|
||||
const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/dsl-export-confirm-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const AccessControl = dynamic(() => import('@/app/components/app/app-access-control'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export type AppCardProps = {
|
||||
app: App
|
||||
onRefresh?: () => void
|
||||
}
|
||||
|
||||
const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||
const { onPlanInfoChanged } = useProviderContext()
|
||||
const { push } = useRouter()
|
||||
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
|
||||
const [showSwitchModal, setShowSwitchModal] = useState<boolean>(false)
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
const [showAccessControl, setShowAccessControl] = useState(false)
|
||||
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
|
||||
|
||||
const onConfirmDelete = useCallback(async () => {
|
||||
try {
|
||||
await deleteApp(app.id)
|
||||
notify({ type: 'success', message: t('app.appDeleted') })
|
||||
if (onRefresh)
|
||||
onRefresh()
|
||||
onPlanInfoChanged()
|
||||
}
|
||||
catch (e: any) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: `${t('app.appDeleteFailed')}${'message' in e ? `: ${e.message}` : ''}`,
|
||||
})
|
||||
}
|
||||
setShowConfirmDelete(false)
|
||||
}, [app.id, notify, onPlanInfoChanged, onRefresh, t])
|
||||
|
||||
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
description,
|
||||
use_icon_as_answer_icon,
|
||||
max_active_requests,
|
||||
}) => {
|
||||
try {
|
||||
await updateAppInfo({
|
||||
appID: app.id,
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
description,
|
||||
use_icon_as_answer_icon,
|
||||
max_active_requests,
|
||||
})
|
||||
setShowEditModal(false)
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('app.editDone'),
|
||||
})
|
||||
if (onRefresh)
|
||||
onRefresh()
|
||||
}
|
||||
catch (e: any) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: e.message || t('app.editFailed'),
|
||||
})
|
||||
}
|
||||
}, [app.id, notify, onRefresh, t])
|
||||
|
||||
const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => {
|
||||
try {
|
||||
const newApp = await copyApp({
|
||||
appID: app.id,
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
mode: app.mode,
|
||||
})
|
||||
setShowDuplicateModal(false)
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('app.newApp.appCreated'),
|
||||
})
|
||||
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
|
||||
if (onRefresh)
|
||||
onRefresh()
|
||||
onPlanInfoChanged()
|
||||
getRedirection(isCurrentWorkspaceEditor, newApp, push)
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('app.newApp.appCreateFailed') })
|
||||
}
|
||||
}
|
||||
|
||||
const onExport = async (include = false) => {
|
||||
try {
|
||||
const { data } = await exportAppConfig({
|
||||
appID: app.id,
|
||||
include,
|
||||
})
|
||||
const a = document.createElement('a')
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
a.download = `${app.name}.yml`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('app.exportFailed') })
|
||||
}
|
||||
}
|
||||
|
||||
const exportCheck = async () => {
|
||||
if (app.mode !== AppModeEnum.WORKFLOW && app.mode !== AppModeEnum.ADVANCED_CHAT) {
|
||||
onExport()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const workflowDraft = await fetchWorkflowDraft(`/apps/${app.id}/workflows/draft`)
|
||||
const list = (workflowDraft.environment_variables || []).filter(env => env.value_type === 'secret')
|
||||
if (list.length === 0) {
|
||||
onExport()
|
||||
return
|
||||
}
|
||||
setSecretEnvList(list)
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('app.exportFailed') })
|
||||
}
|
||||
}
|
||||
|
||||
const onSwitch = () => {
|
||||
if (onRefresh)
|
||||
onRefresh()
|
||||
setShowSwitchModal(false)
|
||||
}
|
||||
|
||||
const onUpdateAccessControl = useCallback(() => {
|
||||
if (onRefresh)
|
||||
onRefresh()
|
||||
setShowAccessControl(false)
|
||||
}, [onRefresh, setShowAccessControl])
|
||||
|
||||
const Operations = (props: HtmlContentProps) => {
|
||||
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({ appId: app?.id, enabled: (!!props?.open && systemFeatures.webapp_auth.enabled) })
|
||||
const onMouseLeave = async () => {
|
||||
props.onClose?.()
|
||||
}
|
||||
const onClickSettings = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
props.onClick?.()
|
||||
e.preventDefault()
|
||||
setShowEditModal(true)
|
||||
}
|
||||
const onClickDuplicate = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
props.onClick?.()
|
||||
e.preventDefault()
|
||||
setShowDuplicateModal(true)
|
||||
}
|
||||
const onClickExport = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
props.onClick?.()
|
||||
e.preventDefault()
|
||||
exportCheck()
|
||||
}
|
||||
const onClickSwitch = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
props.onClick?.()
|
||||
e.preventDefault()
|
||||
setShowSwitchModal(true)
|
||||
}
|
||||
const onClickDelete = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
props.onClick?.()
|
||||
e.preventDefault()
|
||||
setShowConfirmDelete(true)
|
||||
}
|
||||
const onClickAccessControl = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
props.onClick?.()
|
||||
e.preventDefault()
|
||||
setShowAccessControl(true)
|
||||
}
|
||||
const onClickInstalledApp = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
props.onClick?.()
|
||||
e.preventDefault()
|
||||
try {
|
||||
const { installed_apps }: any = await fetchInstalledAppList(app.id) || {}
|
||||
if (installed_apps?.length > 0)
|
||||
window.open(`${basePath}/explore/installed/${installed_apps[0].id}`, '_blank')
|
||||
else
|
||||
throw new Error('No app found in Explore')
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: `${e.message || e}` })
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="relative flex w-full flex-col py-1" onMouseLeave={onMouseLeave}>
|
||||
<button type="button" className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickSettings}>
|
||||
<span className='system-sm-regular text-text-secondary'>{t('app.editApp')}</span>
|
||||
</button>
|
||||
<Divider className="my-1" />
|
||||
<button type="button" className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickDuplicate}>
|
||||
<span className='system-sm-regular text-text-secondary'>{t('app.duplicate')}</span>
|
||||
</button>
|
||||
<button type="button" className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickExport}>
|
||||
<span className='system-sm-regular text-text-secondary'>{t('app.export')}</span>
|
||||
</button>
|
||||
{(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) && (
|
||||
<>
|
||||
<Divider className="my-1" />
|
||||
<button
|
||||
type="button"
|
||||
className='mx-1 flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover'
|
||||
onClick={onClickSwitch}
|
||||
>
|
||||
<span className='text-sm leading-5 text-text-secondary'>{t('app.switch')}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{
|
||||
!app.has_draft_trigger && (
|
||||
(!systemFeatures.webapp_auth.enabled)
|
||||
? <>
|
||||
<Divider className="my-1" />
|
||||
<button type="button" className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickInstalledApp}>
|
||||
<span className='system-sm-regular text-text-secondary'>{t('app.openInExplore')}</span>
|
||||
</button>
|
||||
</>
|
||||
: !(isGettingUserCanAccessApp || !userCanAccessApp?.result) && (
|
||||
<>
|
||||
<Divider className="my-1" />
|
||||
<button type="button" className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickInstalledApp}>
|
||||
<span className='system-sm-regular text-text-secondary'>{t('app.openInExplore')}</span>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
)
|
||||
}
|
||||
<Divider className="my-1" />
|
||||
{
|
||||
systemFeatures.webapp_auth.enabled && isCurrentWorkspaceEditor && <>
|
||||
<button type="button" className='mx-1 flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickAccessControl}>
|
||||
<span className='text-sm leading-5 text-text-secondary'>{t('app.accessControl')}</span>
|
||||
</button>
|
||||
<Divider className='my-1' />
|
||||
</>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
className='group mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-destructive-hover'
|
||||
onClick={onClickDelete}
|
||||
>
|
||||
<span className='system-sm-regular text-text-secondary group-hover:text-text-destructive'>
|
||||
{t('common.operation.delete')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const [tags, setTags] = useState<Tag[]>(app.tags)
|
||||
useEffect(() => {
|
||||
setTags(app.tags)
|
||||
}, [app.tags])
|
||||
|
||||
const EditTimeText = useMemo(() => {
|
||||
const timeText = formatTime({
|
||||
date: (app.updated_at || app.created_at) * 1000,
|
||||
dateFormat: `${t('datasetDocuments.segment.dateTimeFormat')}`,
|
||||
})
|
||||
return `${t('datasetDocuments.segment.editedAt')} ${timeText}`
|
||||
}, [app.updated_at, app.created_at])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
getRedirection(isCurrentWorkspaceEditor, app, push)
|
||||
}}
|
||||
className='group relative col-span-1 inline-flex h-[160px] cursor-pointer flex-col rounded-xl border-[1px] border-solid border-components-card-border bg-components-card-bg 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={app.icon_type}
|
||||
icon={app.icon}
|
||||
background={app.icon_background}
|
||||
imageUrl={app.icon_url}
|
||||
/>
|
||||
<AppTypeIcon type={app.mode} wrapperClassName='absolute -bottom-0.5 -right-0.5 w-4 h-4 shadow-sm' className='h-3 w-3' />
|
||||
</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={app.name}>{app.name}</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-1 text-[10px] font-medium leading-[18px] text-text-tertiary'>
|
||||
<div className='truncate' title={app.author_name}>{app.author_name}</div>
|
||||
<div>·</div>
|
||||
<div className='truncate' title={EditTimeText}>{EditTimeText}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex h-5 w-5 shrink-0 items-center justify-center'>
|
||||
{app.access_mode === AccessMode.PUBLIC && <Tooltip asChild={false} popupContent={t('app.accessItemsDescription.anyone')}>
|
||||
<RiGlobalLine className='h-4 w-4 text-text-quaternary' />
|
||||
</Tooltip>}
|
||||
{app.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && <Tooltip asChild={false} popupContent={t('app.accessItemsDescription.specific')}>
|
||||
<RiLockLine className='h-4 w-4 text-text-quaternary' />
|
||||
</Tooltip>}
|
||||
{app.access_mode === AccessMode.ORGANIZATION && <Tooltip asChild={false} popupContent={t('app.accessItemsDescription.organization')}>
|
||||
<RiBuildingLine className='h-4 w-4 text-text-quaternary' />
|
||||
</Tooltip>}
|
||||
{app.access_mode === AccessMode.EXTERNAL_MEMBERS && <Tooltip asChild={false} popupContent={t('app.accessItemsDescription.external')}>
|
||||
<RiVerifiedBadgeLine className='h-4 w-4 text-text-quaternary' />
|
||||
</Tooltip>}
|
||||
</div>
|
||||
</div>
|
||||
<div className='title-wrapper h-[90px] px-[14px] text-xs leading-normal text-text-tertiary'>
|
||||
<div
|
||||
className='line-clamp-2'
|
||||
title={app.description}
|
||||
>
|
||||
{app.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className='absolute bottom-1 left-0 right-0 flex h-[42px] shrink-0 items-center pb-[6px] pl-[14px] pr-[6px] pt-1'>
|
||||
{isCurrentWorkspaceEditor && (
|
||||
<>
|
||||
<div className={cn('flex w-0 grow items-center gap-1')} onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}}>
|
||||
<div className='mr-[41px] w-full grow group-hover:!mr-0'>
|
||||
<TagSelector
|
||||
position='bl'
|
||||
type='app'
|
||||
targetID={app.id}
|
||||
value={tags.map(tag => tag.id)}
|
||||
selectedTags={tags}
|
||||
onCacheUpdate={setTags}
|
||||
onChange={onRefresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mx-1 !hidden h-[14px] w-[1px] shrink-0 bg-divider-regular group-hover:!flex' />
|
||||
<div className='!hidden shrink-0 group-hover:!flex'>
|
||||
<CustomPopover
|
||||
htmlContent={<Operations />}
|
||||
position="br"
|
||||
trigger="click"
|
||||
btnElement={
|
||||
<div
|
||||
className='flex h-8 w-8 cursor-pointer items-center justify-center rounded-md'
|
||||
>
|
||||
<RiMoreFill className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
}
|
||||
btnClassName={open =>
|
||||
cn(
|
||||
open ? '!bg-state-base-hover !shadow-none' : '!bg-transparent',
|
||||
'h-8 w-8 rounded-md border-none !p-2 hover:!bg-state-base-hover',
|
||||
)
|
||||
}
|
||||
popupClassName={
|
||||
(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT)
|
||||
? '!w-[256px] translate-x-[-224px]'
|
||||
: '!w-[216px] translate-x-[-128px]'
|
||||
}
|
||||
className={'!z-20 h-fit'}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showEditModal && (
|
||||
<EditAppModal
|
||||
isEditModal
|
||||
appName={app.name}
|
||||
appIconType={app.icon_type}
|
||||
appIcon={app.icon}
|
||||
appIconBackground={app.icon_background}
|
||||
appIconUrl={app.icon_url}
|
||||
appDescription={app.description}
|
||||
appMode={app.mode}
|
||||
appUseIconAsAnswerIcon={app.use_icon_as_answer_icon}
|
||||
max_active_requests={app.max_active_requests ?? null}
|
||||
show={showEditModal}
|
||||
onConfirm={onEdit}
|
||||
onHide={() => setShowEditModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showDuplicateModal && (
|
||||
<DuplicateAppModal
|
||||
appName={app.name}
|
||||
icon_type={app.icon_type}
|
||||
icon={app.icon}
|
||||
icon_background={app.icon_background}
|
||||
icon_url={app.icon_url}
|
||||
show={showDuplicateModal}
|
||||
onConfirm={onCopy}
|
||||
onHide={() => setShowDuplicateModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showSwitchModal && (
|
||||
<SwitchAppModal
|
||||
show={showSwitchModal}
|
||||
appDetail={app}
|
||||
onClose={() => setShowSwitchModal(false)}
|
||||
onSuccess={onSwitch}
|
||||
/>
|
||||
)}
|
||||
{showConfirmDelete && (
|
||||
<Confirm
|
||||
title={t('app.deleteAppConfirmTitle')}
|
||||
content={t('app.deleteAppConfirmContent')}
|
||||
isShow={showConfirmDelete}
|
||||
onConfirm={onConfirmDelete}
|
||||
onCancel={() => setShowConfirmDelete(false)}
|
||||
/>
|
||||
)}
|
||||
{secretEnvList.length > 0 && (
|
||||
<DSLExportConfirmModal
|
||||
envList={secretEnvList}
|
||||
onConfirm={onExport}
|
||||
onClose={() => setSecretEnvList([])}
|
||||
/>
|
||||
)}
|
||||
{showAccessControl && (
|
||||
<AccessControl app={app} onConfirm={onUpdateAccessControl} onClose={() => setShowAccessControl(false)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AppCard)
|
||||
35
dify/web/app/components/apps/empty.tsx
Normal file
35
dify/web/app/components/apps/empty.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const DefaultCards = React.memo(() => {
|
||||
const renderArray = Array.from({ length: 36 })
|
||||
return (
|
||||
<>
|
||||
{
|
||||
renderArray.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='inline-flex h-[160px] rounded-xl bg-background-default-lighter'
|
||||
/>
|
||||
))
|
||||
}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const Empty = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<DefaultCards />
|
||||
<div className='absolute bottom-0 left-0 right-0 top-0 flex items-center justify-center bg-gradient-to-t from-background-body to-transparent'>
|
||||
<span className='system-md-medium text-text-tertiary'>
|
||||
{t('app.newApp.noAppsFound')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Empty)
|
||||
49
dify/web/app/components/apps/footer.tsx
Normal file
49
dify/web/app/components/apps/footer.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { RiDiscordFill, RiDiscussLine, RiGithubFill } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type CustomLinkProps = {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const CustomLink = React.memo(({
|
||||
href,
|
||||
children,
|
||||
}: CustomLinkProps) => {
|
||||
return (
|
||||
<Link
|
||||
className='flex h-8 w-8 cursor-pointer items-center justify-center transition-opacity duration-200 ease-in-out hover:opacity-80'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
href={href}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
})
|
||||
|
||||
const Footer = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<footer className='relative shrink-0 grow-0 px-12 py-2'>
|
||||
<h3 className='text-gradient text-xl font-semibold leading-tight'>{t('app.join')}</h3>
|
||||
<p className='system-sm-regular mt-1 text-text-tertiary'>{t('app.communityIntro')}</p>
|
||||
<div className='mt-3 flex items-center gap-2'>
|
||||
<CustomLink href='https://github.com/langgenius/dify'>
|
||||
<RiGithubFill className='h-5 w-5 text-text-tertiary' />
|
||||
</CustomLink>
|
||||
<CustomLink href='https://discord.gg/FngNHpbcY7'>
|
||||
<RiDiscordFill className='h-5 w-5 text-text-tertiary' />
|
||||
</CustomLink>
|
||||
<CustomLink href='https://forum.dify.ai'>
|
||||
<RiDiscussLine className='h-5 w-5 text-text-tertiary' />
|
||||
</CustomLink>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Footer)
|
||||
60
dify/web/app/components/apps/hooks/use-apps-query-state.ts
Normal file
60
dify/web/app/components/apps/hooks/use-apps-query-state.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { type ReadonlyURLSearchParams, usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
type AppsQuery = {
|
||||
tagIDs?: string[]
|
||||
keywords?: string
|
||||
isCreatedByMe?: boolean
|
||||
}
|
||||
|
||||
// Parse the query parameters from the URL search string.
|
||||
function parseParams(params: ReadonlyURLSearchParams): AppsQuery {
|
||||
const tagIDs = params.get('tagIDs')?.split(';')
|
||||
const keywords = params.get('keywords') || undefined
|
||||
const isCreatedByMe = params.get('isCreatedByMe') === 'true'
|
||||
return { tagIDs, keywords, isCreatedByMe }
|
||||
}
|
||||
|
||||
// Update the URL search string with the given query parameters.
|
||||
function updateSearchParams(query: AppsQuery, current: URLSearchParams) {
|
||||
const { tagIDs, keywords, isCreatedByMe } = query || {}
|
||||
|
||||
if (tagIDs && tagIDs.length > 0)
|
||||
current.set('tagIDs', tagIDs.join(';'))
|
||||
else
|
||||
current.delete('tagIDs')
|
||||
|
||||
if (keywords)
|
||||
current.set('keywords', keywords)
|
||||
else
|
||||
current.delete('keywords')
|
||||
|
||||
if (isCreatedByMe)
|
||||
current.set('isCreatedByMe', 'true')
|
||||
else
|
||||
current.delete('isCreatedByMe')
|
||||
}
|
||||
|
||||
function useAppsQueryState() {
|
||||
const searchParams = useSearchParams()
|
||||
const [query, setQuery] = useState<AppsQuery>(() => parseParams(searchParams))
|
||||
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const syncSearchParams = useCallback((params: URLSearchParams) => {
|
||||
const search = params.toString()
|
||||
const query = search ? `?${search}` : ''
|
||||
router.push(`${pathname}${query}`, { scroll: false })
|
||||
}, [router, pathname])
|
||||
|
||||
// Update the URL search string whenever the query changes.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
updateSearchParams(query, params)
|
||||
syncSearchParams(params)
|
||||
}, [query, searchParams, syncSearchParams])
|
||||
|
||||
return useMemo(() => ({ query, setQuery }), [query])
|
||||
}
|
||||
|
||||
export default useAppsQueryState
|
||||
72
dify/web/app/components/apps/hooks/use-dsl-drag-drop.ts
Normal file
72
dify/web/app/components/apps/hooks/use-dsl-drag-drop.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
type DSLDragDropHookProps = {
|
||||
onDSLFileDropped: (file: File) => void
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export const useDSLDragDrop = ({ onDSLFileDropped, containerRef, enabled = true }: DSLDragDropHookProps) => {
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.dataTransfer?.types.includes('Files'))
|
||||
setDragging(true)
|
||||
}
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.relatedTarget === null || !containerRef.current?.contains(e.relatedTarget as Node))
|
||||
setDragging(false)
|
||||
}
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragging(false)
|
||||
|
||||
if (!e.dataTransfer)
|
||||
return
|
||||
|
||||
const files = [...e.dataTransfer.files]
|
||||
if (files.length === 0)
|
||||
return
|
||||
|
||||
const file = files[0]
|
||||
if (file.name.toLowerCase().endsWith('.yaml') || file.name.toLowerCase().endsWith('.yml'))
|
||||
onDSLFileDropped(file)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled)
|
||||
return
|
||||
|
||||
const current = containerRef.current
|
||||
if (current) {
|
||||
current.addEventListener('dragenter', handleDragEnter)
|
||||
current.addEventListener('dragover', handleDragOver)
|
||||
current.addEventListener('dragleave', handleDragLeave)
|
||||
current.addEventListener('drop', handleDrop)
|
||||
}
|
||||
return () => {
|
||||
if (current) {
|
||||
current.removeEventListener('dragenter', handleDragEnter)
|
||||
current.removeEventListener('dragover', handleDragOver)
|
||||
current.removeEventListener('dragleave', handleDragLeave)
|
||||
current.removeEventListener('drop', handleDrop)
|
||||
}
|
||||
}
|
||||
}, [containerRef, enabled])
|
||||
|
||||
return {
|
||||
dragging: enabled ? dragging : false,
|
||||
}
|
||||
}
|
||||
20
dify/web/app/components/apps/index.tsx
Normal file
20
dify/web/app/components/apps/index.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
import { useEducationInit } from '@/app/education-apply/hooks'
|
||||
import List from './list'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const Apps = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
useDocumentTitle(t('common.menus.apps'))
|
||||
useEducationInit()
|
||||
|
||||
return (
|
||||
<div className='relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body'>
|
||||
<List />
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
export default Apps
|
||||
273
dify/web/app/components/apps/list.tsx
Normal file
273
dify/web/app/components/apps/list.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
useRouter,
|
||||
} from 'next/navigation'
|
||||
import useSWRInfinite from 'swr/infinite'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import {
|
||||
RiApps2Line,
|
||||
RiDragDropLine,
|
||||
RiExchange2Line,
|
||||
RiFile4Line,
|
||||
RiMessage3Line,
|
||||
RiRobot3Line,
|
||||
} from '@remixicon/react'
|
||||
import AppCard from './app-card'
|
||||
import NewAppCard from './new-app-card'
|
||||
import useAppsQueryState from './hooks/use-apps-query-state'
|
||||
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
|
||||
import type { AppListResponse } from '@/models/app'
|
||||
import { fetchAppList } from '@/service/apps'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { CheckModal } from '@/hooks/use-pay'
|
||||
import TabSliderNew from '@/app/components/base/tab-slider-new'
|
||||
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import TagFilter from '@/app/components/base/tag-management/filter'
|
||||
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Empty from './empty'
|
||||
import Footer from './footer'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
|
||||
ssr: false,
|
||||
})
|
||||
const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-from-dsl-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
const getKey = (
|
||||
pageIndex: number,
|
||||
previousPageData: AppListResponse,
|
||||
activeTab: string,
|
||||
isCreatedByMe: boolean,
|
||||
tags: string[],
|
||||
keywords: string,
|
||||
) => {
|
||||
if (!pageIndex || previousPageData.has_more) {
|
||||
const params: any = { url: 'apps', params: { page: pageIndex + 1, limit: 30, name: keywords, is_created_by_me: isCreatedByMe } }
|
||||
|
||||
if (activeTab !== 'all')
|
||||
params.params.mode = activeTab
|
||||
else
|
||||
delete params.params.mode
|
||||
|
||||
if (tags.length)
|
||||
params.params.tag_ids = tags
|
||||
|
||||
return params
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const List = () => {
|
||||
const { t } = useTranslation()
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const router = useRouter()
|
||||
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator } = useAppContext()
|
||||
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
||||
const [activeTab, setActiveTab] = useTabSearchParams({
|
||||
defaultTab: 'all',
|
||||
})
|
||||
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
|
||||
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
|
||||
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)
|
||||
const [searchKeywords, setSearchKeywords] = useState(keywords)
|
||||
const newAppCardRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
|
||||
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
|
||||
const setKeywords = useCallback((keywords: string) => {
|
||||
setQuery(prev => ({ ...prev, keywords }))
|
||||
}, [setQuery])
|
||||
const setTagIDs = useCallback((tagIDs: string[]) => {
|
||||
setQuery(prev => ({ ...prev, tagIDs }))
|
||||
}, [setQuery])
|
||||
|
||||
const handleDSLFileDropped = useCallback((file: File) => {
|
||||
setDroppedDSLFile(file)
|
||||
setShowCreateFromDSLModal(true)
|
||||
}, [])
|
||||
|
||||
const { dragging } = useDSLDragDrop({
|
||||
onDSLFileDropped: handleDSLFileDropped,
|
||||
containerRef,
|
||||
enabled: isCurrentWorkspaceEditor,
|
||||
})
|
||||
|
||||
const { data, isLoading, error, setSize, mutate } = useSWRInfinite(
|
||||
(pageIndex: number, previousPageData: AppListResponse) => getKey(pageIndex, previousPageData, activeTab, isCreatedByMe, tagIDs, searchKeywords),
|
||||
fetchAppList,
|
||||
{
|
||||
revalidateFirstPage: true,
|
||||
shouldRetryOnError: false,
|
||||
dedupingInterval: 500,
|
||||
errorRetryCount: 3,
|
||||
},
|
||||
)
|
||||
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const options = [
|
||||
{ value: 'all', text: t('app.types.all'), icon: <RiApps2Line className='mr-1 h-[14px] w-[14px]' /> },
|
||||
{ value: AppModeEnum.WORKFLOW, text: t('app.types.workflow'), icon: <RiExchange2Line className='mr-1 h-[14px] w-[14px]' /> },
|
||||
{ value: AppModeEnum.ADVANCED_CHAT, text: t('app.types.advanced'), icon: <RiMessage3Line className='mr-1 h-[14px] w-[14px]' /> },
|
||||
{ value: AppModeEnum.CHAT, text: t('app.types.chatbot'), icon: <RiMessage3Line className='mr-1 h-[14px] w-[14px]' /> },
|
||||
{ value: AppModeEnum.AGENT_CHAT, text: t('app.types.agent'), icon: <RiRobot3Line className='mr-1 h-[14px] w-[14px]' /> },
|
||||
{ value: AppModeEnum.COMPLETION, text: t('app.types.completion'), icon: <RiFile4Line className='mr-1 h-[14px] w-[14px]' /> },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
|
||||
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
|
||||
mutate()
|
||||
}
|
||||
}, [mutate, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCurrentWorkspaceDatasetOperator)
|
||||
return router.replace('/datasets')
|
||||
}, [router, isCurrentWorkspaceDatasetOperator])
|
||||
|
||||
useEffect(() => {
|
||||
const hasMore = data?.at(-1)?.has_more ?? true
|
||||
let observer: IntersectionObserver | undefined
|
||||
|
||||
if (error) {
|
||||
if (observer)
|
||||
observer.disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
if (anchorRef.current && containerRef.current) {
|
||||
// Calculate dynamic rootMargin: clamps to 100-200px range, using 20% of container height as the base value for better responsiveness
|
||||
const containerHeight = containerRef.current.clientHeight
|
||||
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) // Clamps to 100-200px range, using 20% of container height as the base value
|
||||
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && !isLoading && !error && hasMore)
|
||||
setSize((size: number) => size + 1)
|
||||
}, {
|
||||
root: containerRef.current,
|
||||
rootMargin: `${dynamicMargin}px`,
|
||||
threshold: 0.1, // Trigger when 10% of the anchor element is visible
|
||||
})
|
||||
observer.observe(anchorRef.current)
|
||||
}
|
||||
return () => observer?.disconnect()
|
||||
}, [isLoading, setSize, data, error])
|
||||
|
||||
const { run: handleSearch } = useDebounceFn(() => {
|
||||
setSearchKeywords(keywords)
|
||||
}, { wait: 500 })
|
||||
const handleKeywordsChange = (value: string) => {
|
||||
setKeywords(value)
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const { run: handleTagsUpdate } = useDebounceFn(() => {
|
||||
setTagIDs(tagFilterValue)
|
||||
}, { wait: 500 })
|
||||
const handleTagsChange = (value: string[]) => {
|
||||
setTagFilterValue(value)
|
||||
handleTagsUpdate()
|
||||
}
|
||||
|
||||
const handleCreatedByMeChange = useCallback(() => {
|
||||
const newValue = !isCreatedByMe
|
||||
setIsCreatedByMe(newValue)
|
||||
setQuery(prev => ({ ...prev, isCreatedByMe: newValue }))
|
||||
}, [isCreatedByMe, setQuery])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} className='relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body'>
|
||||
{dragging && (
|
||||
<div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2">
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pb-5 pt-7'>
|
||||
<TabSliderNew
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
options={options}
|
||||
/>
|
||||
<div className='flex items-center gap-2'>
|
||||
<CheckboxWithLabel
|
||||
className='mr-2'
|
||||
label={t('app.showMyCreatedAppsOnly')}
|
||||
isChecked={isCreatedByMe}
|
||||
onChange={handleCreatedByMeChange}
|
||||
/>
|
||||
<TagFilter type='app' value={tagFilterValue} onChange={handleTagsChange} />
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
wrapperClassName='w-[200px]'
|
||||
value={keywords}
|
||||
onChange={e => handleKeywordsChange(e.target.value)}
|
||||
onClear={() => handleKeywordsChange('')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(data && data[0].total > 0)
|
||||
? <div className='relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6'>
|
||||
{isCurrentWorkspaceEditor
|
||||
&& <NewAppCard ref={newAppCardRef} onSuccess={mutate} selectedAppType={activeTab} />}
|
||||
{data.map(({ data: apps }) => apps.map(app => (
|
||||
<AppCard key={app.id} app={app} onRefresh={mutate} />
|
||||
)))}
|
||||
</div>
|
||||
: <div className='relative grid grow grid-cols-1 content-start gap-4 overflow-hidden px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6'>
|
||||
{isCurrentWorkspaceEditor
|
||||
&& <NewAppCard ref={newAppCardRef} className='z-10' onSuccess={mutate} selectedAppType={activeTab} />}
|
||||
<Empty />
|
||||
</div>}
|
||||
|
||||
{isCurrentWorkspaceEditor && (
|
||||
<div
|
||||
className={`flex items-center justify-center gap-2 py-4 ${dragging ? 'text-text-accent' : 'text-text-quaternary'}`}
|
||||
role="region"
|
||||
aria-label={t('app.newApp.dropDSLToCreateApp')}
|
||||
>
|
||||
<RiDragDropLine className="h-4 w-4" />
|
||||
<span className="system-xs-regular">{t('app.newApp.dropDSLToCreateApp')}</span>
|
||||
</div>
|
||||
)}
|
||||
{!systemFeatures.branding.enabled && (
|
||||
<Footer />
|
||||
)}
|
||||
<CheckModal />
|
||||
<div ref={anchorRef} className='h-0'> </div>
|
||||
{showTagManagementModal && (
|
||||
<TagManagementModal type='app' show={showTagManagementModal} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreateFromDSLModal && (
|
||||
<CreateFromDSLModal
|
||||
show={showCreateFromDSLModal}
|
||||
onClose={() => {
|
||||
setShowCreateFromDSLModal(false)
|
||||
setDroppedDSLFile(undefined)
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setShowCreateFromDSLModal(false)
|
||||
setDroppedDSLFile(undefined)
|
||||
mutate()
|
||||
}}
|
||||
droppedFile={droppedDSLFile}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default List
|
||||
134
dify/web/app/components/apps/new-app-card.tsx
Normal file
134
dify/web/app/components/apps/new-app-card.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client'
|
||||
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import {
|
||||
useRouter,
|
||||
useSearchParams,
|
||||
} from 'next/navigation'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { FileArrow01, FilePlus01, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files'
|
||||
import cn from '@/utils/classnames'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const CreateAppModal = dynamic(() => import('@/app/components/app/create-app-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const CreateAppTemplateDialog = dynamic(() => import('@/app/components/app/create-app-dialog'), {
|
||||
ssr: false,
|
||||
})
|
||||
const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-from-dsl-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export type CreateAppCardProps = {
|
||||
className?: string
|
||||
onSuccess?: () => void
|
||||
ref: React.RefObject<HTMLDivElement | null>
|
||||
selectedAppType?: string
|
||||
}
|
||||
|
||||
const CreateAppCard = ({
|
||||
ref,
|
||||
className,
|
||||
onSuccess,
|
||||
selectedAppType,
|
||||
}: CreateAppCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { onPlanInfoChanged } = useProviderContext()
|
||||
const searchParams = useSearchParams()
|
||||
const { replace } = useRouter()
|
||||
const dslUrl = searchParams.get('remoteInstallUrl') || undefined
|
||||
|
||||
const [showNewAppTemplateDialog, setShowNewAppTemplateDialog] = useState(false)
|
||||
const [showNewAppModal, setShowNewAppModal] = useState(false)
|
||||
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(!!dslUrl)
|
||||
|
||||
const activeTab = useMemo(() => {
|
||||
if (dslUrl)
|
||||
return CreateFromDSLModalTab.FROM_URL
|
||||
|
||||
return undefined
|
||||
}, [dslUrl])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg', className)}
|
||||
>
|
||||
<div className='grow rounded-t-xl p-2'>
|
||||
<div className='px-6 pb-1 pt-2 text-xs font-medium leading-[18px] text-text-tertiary'>{t('app.createApp')}</div>
|
||||
<button type="button" className='mb-1 flex w-full cursor-pointer items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary' onClick={() => setShowNewAppModal(true)}>
|
||||
<FilePlus01 className='mr-2 h-4 w-4 shrink-0' />
|
||||
{t('app.newApp.startFromBlank')}
|
||||
</button>
|
||||
<button type="button" className='flex w-full cursor-pointer items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary' onClick={() => setShowNewAppTemplateDialog(true)}>
|
||||
<FilePlus02 className='mr-2 h-4 w-4 shrink-0' />
|
||||
{t('app.newApp.startFromTemplate')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateFromDSLModal(true)}
|
||||
className='flex w-full cursor-pointer items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary'>
|
||||
<FileArrow01 className='mr-2 h-4 w-4 shrink-0' />
|
||||
{t('app.importDSL')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showNewAppModal && (
|
||||
<CreateAppModal
|
||||
show={showNewAppModal}
|
||||
onClose={() => setShowNewAppModal(false)}
|
||||
onSuccess={() => {
|
||||
onPlanInfoChanged()
|
||||
if (onSuccess)
|
||||
onSuccess()
|
||||
}}
|
||||
onCreateFromTemplate={() => {
|
||||
setShowNewAppTemplateDialog(true)
|
||||
setShowNewAppModal(false)
|
||||
}}
|
||||
defaultAppMode={selectedAppType !== 'all' ? selectedAppType as any : undefined}
|
||||
/>
|
||||
)}
|
||||
{showNewAppTemplateDialog && (
|
||||
<CreateAppTemplateDialog
|
||||
show={showNewAppTemplateDialog}
|
||||
onClose={() => setShowNewAppTemplateDialog(false)}
|
||||
onSuccess={() => {
|
||||
onPlanInfoChanged()
|
||||
if (onSuccess)
|
||||
onSuccess()
|
||||
}}
|
||||
onCreateFromBlank={() => {
|
||||
setShowNewAppModal(true)
|
||||
setShowNewAppTemplateDialog(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showCreateFromDSLModal && (
|
||||
<CreateFromDSLModal
|
||||
show={showCreateFromDSLModal}
|
||||
onClose={() => {
|
||||
setShowCreateFromDSLModal(false)
|
||||
|
||||
if (dslUrl)
|
||||
replace('/')
|
||||
}}
|
||||
activeTab={activeTab}
|
||||
dslUrl={dslUrl}
|
||||
onSuccess={() => {
|
||||
onPlanInfoChanged()
|
||||
if (onSuccess)
|
||||
onSuccess()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
CreateAppCard.displayName = 'CreateAppCard'
|
||||
|
||||
export default React.memo(CreateAppCard)
|
||||
Reference in New Issue
Block a user