dify/web/app/components/plugins/card/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

182 lines
6.9 KiB
TypeScript

'use client'
import type { Plugin } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useTranslation } from '#i18n'
import { useSelector } from '@/context/app-context'
import { useGetLanguage } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import {
renderI18nObject,
} from '@/i18n-config'
import { Theme } from '@/types/app'
import { formatNumber } from '@/utils/format'
import Partner from '../base/badges/partner'
import Verified from '../base/badges/verified'
import Icon from '../card/base/card-icon'
import { useCategories } from '../hooks'
import { getPluginCardIconUrl } from '../utils'
import CornerMark from './base/corner-mark'
import Description from './base/description'
import OrgInfo from './base/org-info'
import Placeholder from './base/placeholder'
import Title from './base/title'
export type CardPayload = Omit<Plugin, 'icon' | 'icon_dark'> & {
icon: string | { content: string, background: string }
icon_dark?: string | { content: string, background: string }
}
type Props = Readonly<{
className?: string
payload: CardPayload
titleLeft?: React.ReactNode
installed?: boolean
installFailed?: boolean
hideCornerMark?: boolean
descriptionLineRows?: number
footer?: React.ReactNode
isLoading?: boolean
loadingFileName?: string
limitedInstall?: boolean
compact?: boolean
variant?: 'default' | 'marketplace'
}>
const Card = ({
className,
payload,
titleLeft,
installed,
installFailed,
hideCornerMark,
descriptionLineRows,
footer,
isLoading = false,
loadingFileName,
limitedInstall = false,
compact = false,
variant = 'default',
}: Props) => {
const locale = useGetLanguage()
const { t } = useTranslation()
const { categoriesMap } = useCategories(true)
const currentWorkspaceId = useSelector(s => s.currentWorkspace.id)
const { category, type, name, org, label, brief, icon, icon_dark, verified, from } = payload
const badges = payload.badges ?? []
const { theme } = useTheme()
const iconSrc = getPluginCardIconUrl(
{ from, name, org, type },
theme === Theme.dark && icon_dark ? icon_dark : icon,
currentWorkspaceId,
)
const getLocalizedText = (obj: Record<string, string> | undefined) =>
obj ? renderI18nObject(obj, locale) : ''
const isPartner = badges.includes('partner')
const effectiveDescriptionLineRows = descriptionLineRows ?? (compact ? 1 : 2)
const isMarketplaceVariant = variant === 'marketplace'
const cornerMarkText = categoriesMap[type === 'bundle' ? type : category]?.label ?? ''
const wrapClassName = cn(
// eslint-disable-next-line tailwindcss/no-unknown-classes -- Used by page feedback tooling to identify plugin cards.
'hover-bg-components-panel-on-panel-item-bg relative overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs',
isMarketplaceVariant && 'h-[148px] transition-all group-hover:bg-components-panel-on-panel-item-bg-hover group-hover:shadow-md',
className,
)
if (isLoading) {
return (
<Placeholder
wrapClassName={wrapClassName}
loadingFileName={loadingFileName!}
/>
)
}
if (isMarketplaceVariant) {
return (
<div className={wrapClassName}>
<div className="relative flex h-full flex-col">
{!hideCornerMark && <CornerMark text={cornerMarkText} />}
<div className="flex items-center gap-3 px-4 pt-4 pb-2">
<Icon src={iconSrc} installed={installed} installFailed={installFailed} />
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
<div className="flex h-5 min-w-0 items-center">
<div className="truncate system-md-medium text-text-primary">
{getLocalizedText(label)}
</div>
{isPartner && <Partner className="ml-0.5 size-4" text={t('marketplace.partnerTip', { ns: 'plugin' })} />}
{verified && <Verified className="ml-0.5 size-4" text={t('marketplace.verifiedTip', { ns: 'plugin' })} />}
{titleLeft}
</div>
<div className="flex h-4 min-w-0 items-center gap-2 system-xs-regular text-text-tertiary">
{org && (
<div className="flex min-w-0 items-center gap-1">
<span className="shrink-0 lowercase">{t('author', { ns: 'tools' })}</span>
<span className="truncate">{org}</span>
</div>
)}
{org && payload.install_count !== undefined && <span className="shrink-0 text-text-quaternary">·</span>}
{payload.install_count !== undefined && (
<span className="shrink-0">
{t('install', { ns: 'plugin', num: formatNumber(payload.install_count) })}
</span>
)}
</div>
</div>
</div>
<Description
className="mx-4 mt-1 text-text-secondary"
text={getLocalizedText(brief)}
descriptionLineRows={effectiveDescriptionLineRows}
/>
{!!footer && <div className="px-4 pt-2 pr-5">{footer}</div>}
</div>
</div>
)
}
return (
<div className={wrapClassName}>
<div className={cn(compact ? 'p-3 pb-2' : 'p-4 pb-3', limitedInstall && 'pb-1')}>
{!hideCornerMark && <CornerMark text={cornerMarkText} />}
{/* Header */}
<div className="flex">
<Icon src={iconSrc} installed={installed} installFailed={installFailed} />
<div className="ml-3 w-0 grow">
<div className="flex h-5 items-center">
<Title title={getLocalizedText(label)} />
{isPartner && <Partner className="ml-0.5 size-4" text={t('marketplace.partnerTip', { ns: 'plugin' })} />}
{verified && <Verified className="ml-0.5 size-4" text={t('marketplace.verifiedTip', { ns: 'plugin' })} />}
{titleLeft}
{' '}
{/* This can be version badge */}
</div>
<OrgInfo
className="mt-0.5"
orgName={org}
packageName={name}
/>
</div>
</div>
<Description
className={compact ? 'mt-1' : 'mt-3'}
text={getLocalizedText(brief)}
descriptionLineRows={effectiveDescriptionLineRows}
/>
{!!footer && <div>{footer}</div>}
</div>
{limitedInstall
&& (
<div className="relative flex h-8 items-center gap-x-2 px-3 after:absolute after:inset-0 after:bg-toast-warning-bg after:opacity-40">
<span aria-hidden className="i-ri-alert-fill size-3 shrink-0 text-text-warning-secondary" />
<p className="z-10 grow system-xs-regular text-text-secondary">
{t('installModal.installWarning', { ns: 'plugin' })}
</p>
</div>
)}
</div>
)
}
export default React.memo(Card)