dify/web/app/components/explore/app-list/index.tsx
Jingyi 9b74df21d0
feat(web): refine onboarding UI (#37433)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: fatelei <fatelei@gmail.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Xiyuan Chen <52963600+GareArc@users.noreply.github.com>
Co-authored-by: gigglewang <gigglewang@dify.ai>
Co-authored-by: Yunlu Wen <yunlu.wen@dify.ai>
Co-authored-by: chariri <w@chariri.moe>
Co-authored-by: Evan <2869018789@qq.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-06-15 08:47:15 +00:00

364 lines
12 KiB
TypeScript

'use client'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { Banner as BannerType } from '@/models/app'
import type { App } from '@/models/explore'
import type { App as WorkspaceApp } from '@/types/app'
import type { TryAppSelection } from '@/types/try-app'
import type { TrackCreateAppParams } from '@/utils/create-app-tracking'
import { cn } from '@langgenius/dify-ui/cn'
import { queryOptions, useQueries, useSuspenseQuery } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-confirm-modal'
import AppCard from '@/app/components/explore/app-card'
import Banner from '@/app/components/explore/banner/banner'
import CreateAppModal from '@/app/components/explore/create-app-modal'
import { useAppContext } from '@/context/app-context'
import { useLocale } from '@/context/i18n'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useImportDSL } from '@/hooks/use-import-dsl'
import { DSLImportMode } from '@/models/app'
import dynamic from '@/next/dynamic'
import { consoleQuery } from '@/service/client'
import { fetchAppDetail, fetchAppList, fetchBanners } from '@/service/explore'
import { useMembers } from '@/service/use-common'
import { trackCreateApp } from '@/utils/create-app-tracking'
import { ExploreAppListHeader } from './explore-app-list-header'
import { ExploreRecommendations } from './explore-recommendations'
import { ExploreHomeSkeleton } from './loading-skeletons'
import s from './style.module.css'
const TryApp = dynamic(() => import('../try-app'), { ssr: false })
type ExploreAppListData = {
categories: string[]
allList: App[]
}
const homeContinueWorkAppsInput = {
query: {
page: 1,
limit: 8,
name: '',
},
}
const disabledBannersQueryKey = ['explore', 'home', 'banners', 'disabled'] as const
function getLocaleQueryInput(locale?: string) {
return locale
? { query: { language: locale } }
: {}
}
function getExploreAppListQueryOptions(locale?: string) {
const input = getLocaleQueryInput(locale)
const language = input.query?.language
return queryOptions<ExploreAppListData>({
queryKey: [...consoleQuery.explore.apps.queryKey({ input }), language],
queryFn: async () => {
const { categories, recommended_apps } = await fetchAppList(language)
return {
categories,
allList: [...recommended_apps].sort((a, b) => a.position - b.position),
}
},
})
}
function getContinueWorkAppsQueryOptions() {
return consoleQuery.apps.list.queryOptions({
input: homeContinueWorkAppsInput,
select: (response): WorkspaceApp[] => response.data ?? [],
})
}
function getBannersQueryOptions(locale?: string) {
const input = getLocaleQueryInput(locale)
const language = input.query?.language
return queryOptions<BannerType[]>({
queryKey: [...consoleQuery.explore.banners.queryKey({ input }), language],
queryFn: () => fetchBanners(language),
})
}
function getDisabledBannersQueryOptions() {
return queryOptions<BannerType[]>({
queryKey: disabledBannersQueryKey,
queryFn: async () => [],
initialData: [],
staleTime: 'static',
})
}
const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
const { t } = useTranslation()
const locale = useLocale()
const { userProfile } = useAppContext()
const { data: systemFeatures } = useSuspenseQuery(
systemFeaturesQueryOptions(),
)
const homeQueries = useQueries({
queries: [
getExploreAppListQueryOptions(locale),
getContinueWorkAppsQueryOptions(),
systemFeatures.enable_explore_banner
? getBannersQueryOptions(locale)
: getDisabledBannersQueryOptions(),
],
combine: ([exploreAppListQuery, continueWorkAppsQuery, bannersQuery]) => ({
appListData: exploreAppListQuery.data,
continueWorkApps: continueWorkAppsQuery.data ?? [],
banners: bannersQuery.data ?? [],
isPending: exploreAppListQuery.isPending || continueWorkAppsQuery.isPending || bannersQuery.isPending,
isAppListError: exploreAppListQuery.isError || (!exploreAppListQuery.isPending && !exploreAppListQuery.data),
}),
})
const { data: membersData } = useMembers()
const allCategoriesEn = t('apps.allCategories', { ns: 'explore', lng: 'en' })
const userAccount = membersData?.accounts?.find(
account => account.id === userProfile.id,
)
const hasEditPermission = !!userAccount && userAccount.role !== 'normal'
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] = useQueryState('category', {
defaultValue: allCategoriesEn,
})
const filteredList = useMemo(() => {
if (!homeQueries.appListData)
return []
return homeQueries.appListData.allList.filter(
item =>
currCategory === allCategoriesEn
|| item.categories?.includes(currCategory),
)
}, [homeQueries.appListData, currCategory, allCategoriesEn])
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] = useState<App | null>(null)
const [isShowCreateModal, setIsShowCreateModal] = useState(false)
const { handleImportDSL, handleImportDSLConfirm, versions, isFetching }
= useImportDSL()
const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false)
const [currentTryApp, setCurrentTryApp] = useState<
TryAppSelection | undefined
>(undefined)
const currentCreateAppModeRef = useRef<App['app']['mode'] | null>(null)
const currentCreateAppTrackingRef = useRef<Pick<
TrackCreateAppParams,
'source' | 'templateId'
> | null>(null)
const isShowTryAppPanel = !!currentTryApp
const hideTryAppPanel = useCallback(() => {
setCurrentTryApp(undefined)
}, [])
const handleTryApp = useCallback((params: TryAppSelection) => {
setCurrentTryApp(params)
}, [])
const handleShowFromTryApp = useCallback(() => {
setCurrApp(currentTryApp?.app || null)
currentCreateAppTrackingRef.current = {
source: 'explore_template_preview',
templateId: currentTryApp?.appId || currentTryApp?.app.app_id,
}
setIsShowCreateModal(true)
}, [currentTryApp?.app, currentTryApp?.appId])
const handleCreateFromLearnDify = useCallback((app: App) => {
setCurrApp(app)
setIsShowCreateModal(true)
}, [])
const handleCreateFromAppList = useCallback((app: App) => {
currentCreateAppTrackingRef.current = {
source: 'explore_template_list',
templateId: app.app_id,
}
setCurrApp(app)
setIsShowCreateModal(true)
}, [])
const trackCurrentCreateApp = useCallback(
(appMode?: App['app']['mode'] | null) => {
const currentCreateAppTracking = currentCreateAppTrackingRef.current
const resolvedAppMode = appMode ?? currentCreateAppModeRef.current
if (!resolvedAppMode || !currentCreateAppTracking)
return
trackCreateApp({
...currentCreateAppTracking,
appMode: resolvedAppMode,
})
currentCreateAppTrackingRef.current = null
currentCreateAppModeRef.current = null
},
[],
)
const onCreate: CreateAppModalProps['onConfirm'] = useCallback(
async ({ name, icon_type, icon, icon_background, description }) => {
hideTryAppPanel()
const { export_data, mode } = await fetchAppDetail(
currApp?.app.id as string,
)
currentCreateAppModeRef.current = mode
const payload = {
mode: DSLImportMode.YAML_CONTENT,
yaml_content: export_data,
name,
icon_type,
icon,
icon_background,
description,
}
await handleImportDSL(payload, {
onSuccess: (response) => {
trackCurrentCreateApp(response.app_mode)
setIsShowCreateModal(false)
},
onPending: () => {
setShowDSLConfirmModal(true)
},
})
},
[currApp?.app.id, handleImportDSL, hideTryAppPanel, trackCurrentCreateApp],
)
const onConfirmDSL = useCallback(async () => {
await handleImportDSLConfirm({
onSuccess: (response) => {
trackCurrentCreateApp(response.app_mode)
onSuccess?.()
},
})
}, [handleImportDSLConfirm, onSuccess, trackCurrentCreateApp])
if (homeQueries.isAppListError)
return null
return (
<div
className={cn(
'flex h-full min-h-0 flex-col overflow-hidden border-l-[0.5px] border-divider-regular',
)}
>
<div className="flex flex-1 flex-col overflow-y-auto">
{homeQueries.isPending
? (
<ExploreHomeSkeleton showBanner={systemFeatures.enable_explore_banner} />
)
: (
<>
{systemFeatures.enable_explore_banner && (
<Banner banners={homeQueries.banners} />
)}
<ExploreRecommendations
canCreate={hasEditPermission}
continueWorkApps={homeQueries.continueWorkApps}
onCreate={handleCreateFromLearnDify}
onTry={handleTryApp}
/>
<ExploreAppListHeader
allCategoriesEn={allCategoriesEn}
categories={homeQueries.appListData?.categories ?? []}
currCategory={currCategory}
keywords={keywords}
onCategoryChange={setCurrCategory}
onKeywordsChange={handleKeywordsChange}
/>
<div className={cn('relative flex flex-1 shrink-0 grow flex-col pb-6')}>
<nav
className={cn(
s.appList,
'grid shrink-0 content-start gap-3 px-8',
)}
>
{searchFilteredList.map(app => (
<AppCard
key={app.app_id}
app={app}
canCreate={hasEditPermission}
onCreate={() => handleCreateFromAppList(app)}
onTry={handleTryApp}
/>
))}
</nav>
</div>
</>
)}
</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}
/>
)}
{isShowTryAppPanel && (
<TryApp
appId={currentTryApp?.appId || ''}
app={currentTryApp?.app}
categories={currentTryApp?.app?.categories}
onClose={hideTryAppPanel}
onCreate={handleShowFromTryApp}
/>
)}
</div>
)
}
export default React.memo(Apps)