dify/web/app/components/apps/app-card/index.tsx

238 lines
9.7 KiB
TypeScript

'use client'
import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen'
import type { WorkflowOnlineUser } from '@/models/app'
import { zIconType } from '@dify/contracts/api/console/apps/zod.gen'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { memo, useCallback, useId, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { AppTypeIcon } from '@/app/components/app/type-selector'
import AppIcon from '@/app/components/base/app-icon'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { AppCardTags } from '@/features/tag-management/components/app-card-tags'
import Link from '@/next/link'
import { AppModeEnum } from '@/types/app'
import { getRedirectionPath } from '@/utils/app-redirection'
import {
getAppACLCapabilities,
hasOnlyAppPreviewPermission,
hasPermission,
} from '@/utils/permission'
import { formatTime } from '@/utils/time'
import { AppCardInteractions } from './interactions'
const EMPTY_ONLINE_USERS: WorkflowOnlineUser[] = []
type AppCardProps = {
app: AppPartial
onlineUsers?: WorkflowOnlineUser[]
onOpenTagManagement?: () => void
stepByStepTourActionMenuOpen?: boolean
stepByStepTourCardTarget?: string
stepByStepTourCardHighlightPart?: string
stepByStepTourActionMenuHighlightPart?: string
}
export const AppCard = memo(
({
app,
onlineUsers = EMPTY_ONLINE_USERS,
onOpenTagManagement,
stepByStepTourActionMenuOpen = false,
stepByStepTourCardTarget,
stepByStepTourCardHighlightPart,
stepByStepTourActionMenuHighlightPart,
}: AppCardProps) => {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: currentUserId } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile.id,
})
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const isRbacEnabled = systemFeatures.rbac_enabled
const resourceMaintainer = app.maintainer ?? undefined
const maintainerPermissionOptions = useMemo(
() => ({
currentUserId,
resourceMaintainer,
workspacePermissionKeys,
isRbacEnabled,
}),
[currentUserId, isRbacEnabled, resourceMaintainer, workspacePermissionKeys],
)
const appACLCapabilities = useMemo(
() => getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions),
[app.permission_keys, maintainerPermissionOptions],
)
const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys)
const canManageAppTags = hasPermission(workspacePermissionKeys, 'app.tag.manage')
const canBindOrUnbindTags = !isPreviewOnly && (canManageAppTags || appACLCapabilities.canEdit)
const editTimeText = useMemo(() => {
const timestamp = app.updated_at || app.created_at
if (!timestamp) return ''
const timeText = formatTime({
date: timestamp * 1000,
dateFormat: `${t(($) => $['segment.dateTimeFormat'], { ns: 'datasetDocuments' })}`,
})
return `${t(($) => $['segment.editedAt'], { ns: 'datasetDocuments' })} ${timeText}`
}, [app.updated_at, app.created_at, t])
const appModeLabel = useMemo(() => {
switch (app.mode) {
case AppModeEnum.CHAT:
return t(($) => $['types.chatbot'], { ns: 'app' })
case AppModeEnum.ADVANCED_CHAT:
return t(($) => $['types.advanced'], { ns: 'app' })
case AppModeEnum.AGENT_CHAT:
return t(($) => $['types.agent'], { ns: 'app' })
case AppModeEnum.COMPLETION:
return t(($) => $['types.completion'], { ns: 'app' })
case AppModeEnum.WORKFLOW:
return t(($) => $['types.workflow'], { ns: 'app' })
default:
return app.mode
}
}, [app.mode, t])
const onlinePresenceUsers = useMemo(() => {
return onlineUsers
.map((user, index) => {
const id = user.user_id || user.sid || `${app.id}-online-${index}`
const name = user.username || user.user_id || user.sid || `${index + 1}`
return {
id,
name,
avatar_url: user.avatar || null,
}
})
.filter((user) => Boolean(user.id))
}, [app.id, onlineUsers])
const appNameId = useId()
const appDescriptionId = useId()
const appIconType = zIconType.safeParse(app.icon_type).data ?? null
const appHref = getRedirectionPath(app, maintainerPermissionOptions)
const appCardClassName = cn(
'inline-flex h-full w-full touch-manipulation flex-col rounded-xl outline-hidden',
isPreviewOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
)
const showPreviewOnlyAccessWarning = useCallback(() => {
toast.warning(t(($) => $.noAccessResourcePermission, { ns: 'app' }))
}, [t])
const appCardContent = (
<>
<div className="flex shrink-0 items-center gap-3 pt-4 pr-4 pb-2 pl-4">
<div className="relative shrink-0">
<AppIcon
size="large"
iconType={appIconType}
icon={app.icon ?? undefined}
background={app.icon_background}
imageUrl={app.icon_url}
decorative
/>
<AppTypeIcon
type={app.mode}
wrapperClassName="absolute -bottom-0.5 -right-0.5 w-4 h-4 shadow-sm"
className="size-3"
/>
</div>
<div className="flex w-0 grow flex-col gap-1 py-px">
<div className="flex items-center text-sm/5 font-semibold text-text-secondary">
<div id={appNameId} className="truncate">
{app.name}
</div>
</div>
<div className="truncate system-2xs-medium-uppercase text-text-tertiary">
{appModeLabel}
</div>
</div>
{onlinePresenceUsers.length > 0 && (
<div className="ml-3 flex shrink-0 items-start">
<UserAvatarList
users={onlinePresenceUsers}
size="xxs"
maxVisible={3}
className="justify-end"
/>
</div>
)}
</div>
<div className="shrink-0 px-4 py-1 system-xs-regular text-text-tertiary">
<div id={appDescriptionId} className="line-clamp-2 min-h-8">
{app.description}
</div>
</div>
<div className="mt-6.5 flex min-w-0 shrink-0 items-center overflow-hidden pt-2 pr-4 pb-3 pl-4 system-xs-regular text-text-tertiary">
<div className="flex min-w-0 flex-1 items-center gap-1 whitespace-nowrap">
{app.author_name && (
<>
<div className="min-w-0 truncate">{app.author_name}</div>
<div className="shrink-0">·</div>
</>
)}
<div className="min-w-0 truncate">{editTimeText}</div>
</div>
</div>
</>
)
return (
<li
className={cn(
"group relative isolate col-span-1 h-41.5 min-w-0 overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-shadow duration-200 ease-in-out after:pointer-events-none after:absolute after:inset-0 after:z-1 after:rounded-xl after:content-[''] focus-within:bg-components-card-bg-alt has-[>a:focus-visible]:after:inset-ring-2 has-[>a:focus-visible]:after:inset-ring-state-accent-solid has-[>button:focus-visible]:after:inset-ring-2 has-[>button:focus-visible]:after:inset-ring-state-accent-solid motion-reduce:transition-none",
!isPreviewOnly &&
'hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5 has-data-popup-open:bg-components-card-bg-alt has-data-popup-open:shadow-md has-data-popup-open:shadow-shadow-shadow-5 [@media(hover:none)]:bg-components-card-bg-alt',
)}
>
{isPreviewOnly ? (
<button
type="button"
aria-labelledby={appNameId}
aria-describedby={app.description ? appDescriptionId : undefined}
data-step-by-step-tour-target={stepByStepTourCardTarget}
data-step-by-step-tour-highlight-part={stepByStepTourCardHighlightPart}
className={cn(appCardClassName, 'text-left')}
onClick={showPreviewOnlyAccessWarning}
>
{appCardContent}
</button>
) : (
<AppCardInteractions
app={app}
stepByStepTourActionMenuOpen={stepByStepTourActionMenuOpen}
stepByStepTourActionMenuHighlightPart={stepByStepTourActionMenuHighlightPart}
>
<Link
href={appHref}
aria-labelledby={appNameId}
aria-describedby={app.description ? appDescriptionId : undefined}
data-step-by-step-tour-target={stepByStepTourCardTarget}
data-step-by-step-tour-highlight-part={stepByStepTourCardHighlightPart}
className={appCardClassName}
>
{appCardContent}
</Link>
</AppCardInteractions>
)}
<div className="absolute top-26 right-3 left-3 flex h-6.5 min-w-0 items-start">
<AppCardTags
appId={app.id}
appName={app.name}
tags={app.tags ?? []}
canBindOrUnbindTags={canBindOrUnbindTags}
onOpenTagManagement={onOpenTagManagement}
/>
</div>
</li>
)
},
)