chore: app card ui

This commit is contained in:
Joel
2026-05-13 14:02:05 +08:00
committed by Jingyi-Dify
parent ba935612a2
commit 1f5c88f4e0
6 changed files with 274 additions and 152 deletions

View File

@ -426,32 +426,32 @@ describe('AppCard', () => {
})
describe('Access Mode Icons', () => {
it('should show public icon for public access mode', () => {
it('should not show public icon on the card', () => {
const publicApp = { ...mockApp, access_mode: AccessMode.PUBLIC }
const { container } = render(<AppCard app={publicApp} />)
const tooltip = container.querySelector('[title="app.accessItemsDescription.anyone"]')
expect(tooltip).toBeInTheDocument()
expect(tooltip).not.toBeInTheDocument()
})
it('should show lock icon for specific groups access mode', () => {
it('should not show lock icon on the card', () => {
const specificApp = { ...mockApp, access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS }
const { container } = render(<AppCard app={specificApp} />)
const tooltip = container.querySelector('[title="app.accessItemsDescription.specific"]')
expect(tooltip).toBeInTheDocument()
expect(tooltip).not.toBeInTheDocument()
})
it('should show organization icon for organization access mode', () => {
it('should not show organization icon on the card', () => {
const orgApp = { ...mockApp, access_mode: AccessMode.ORGANIZATION }
const { container } = render(<AppCard app={orgApp} />)
const tooltip = container.querySelector('[title="app.accessItemsDescription.organization"]')
expect(tooltip).toBeInTheDocument()
expect(tooltip).not.toBeInTheDocument()
})
it('should show external icon for external access mode', () => {
it('should not show external icon on the card', () => {
const externalApp = { ...mockApp, access_mode: AccessMode.EXTERNAL_MEMBERS }
const { container } = render(<AppCard app={externalApp} />)
const tooltip = container.querySelector('[title="app.accessItemsDescription.external"]')
expect(tooltip).toBeInTheDocument()
expect(tooltip).not.toBeInTheDocument()
})
})
@ -485,6 +485,8 @@ describe('AppCard', () => {
render(<AppCard app={mockApp} />)
const operationsTriggerWrapper = screen.getByTestId('dropdown-menu-trigger').closest('.absolute')
expect(operationsTriggerWrapper).toHaveClass('top-[-0.5px]')
expect(operationsTriggerWrapper).toHaveClass('right-[-0.5px]')
expect(operationsTriggerWrapper).toHaveClass('group-focus-within:pointer-events-auto')
expect(operationsTriggerWrapper).toHaveClass('group-focus-within:opacity-100')
expect(screen.getByTestId('dropdown-menu-trigger')).toHaveClass('focus-visible:ring-1')

View File

@ -12,12 +12,14 @@ type AppCardSkeletonProps = {
* Matches the visual layout of AppCard component.
*/
export const AppCardSkeleton = React.memo(({ count = 6 }: AppCardSkeletonProps) => {
const skeletonKeys = Array.from({ length: count }, (_, index) => `app-card-skeleton-${index}`)
return (
<>
{Array.from({ length: count }).map((_, index) => (
{skeletonKeys.map(key => (
<div
key={index}
className="h-[160px] rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg p-4"
key={key}
className="h-[160px] overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg p-4 shadow-xs"
>
<SkeletonContainer className="h-full">
<SkeletonRow>

View File

@ -23,11 +23,6 @@ import {
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@langgenius/dify-ui/tooltip'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useCallback, useId, useMemo, useState } from 'react'
@ -41,7 +36,6 @@ import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { AppCardTags } from '@/features/tag-management/components/app-card-tags'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { AccessMode } from '@/models/access-control'
import dynamic from '@/next/dynamic'
import { useRouter } from '@/next/navigation'
import { useGetUserCanAccessApp } from '@/service/access-control'
@ -207,7 +201,7 @@ const AppCardOperationsMenuContent: React.FC<AppCardOperationsMenuContentProps>
)
}
const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () => {} }: AppCardProps) => {
const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () => { } }: AppCardProps) => {
const { t } = useTranslation()
const deleteAppNameInputId = useId()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
@ -396,7 +390,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
const shouldShowAccessControlOption = systemFeatures.webapp_auth.enabled && isCurrentWorkspaceEditor
const operationsMenuWidthClassName = shouldShowSwitchOption ? 'w-[256px]' : 'w-[216px]'
const EditTimeText = useMemo(() => {
const editTimeText = useMemo(() => {
const timeText = formatTime({
date: (app.updated_at || app.created_at) * 1000,
dateFormat: `${t('segment.dateTimeFormat', { ns: 'datasetDocuments' })}`,
@ -404,6 +398,23 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
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) => {
@ -425,9 +436,9 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
e.preventDefault()
getRedirection(isCurrentWorkspaceEditor, app, push)
}}
className="group relative col-span-1 inline-flex h-[160px] cursor-pointer flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-sm transition-shadow duration-200 ease-in-out hover:shadow-lg"
className="group relative col-span-1 inline-flex h-41.5 cursor-pointer flex-col overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs transition-shadow duration-200 ease-in-out hover:shadow-lg"
>
<div className="flex h-[66px] shrink-0 grow-0 items-center gap-3 px-[14px] pt-[14px] pb-3">
<div className="flex shrink-0 items-center gap-3 pt-4 pb-2 pl-4">
<div className="relative shrink-0">
<AppIcon
size="large"
@ -438,152 +449,115 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
/>
<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-px">
<div className="flex items-center text-sm leading-5 font-semibold text-text-secondary">
<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 className="truncate" title={app.name}>{app.name}</div>
</div>
<div className="flex items-center gap-1 text-[10px] leading-[18px] font-medium 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 className="truncate system-2xs-medium-uppercase text-text-tertiary" title={appModeLabel}>{appModeLabel}</div>
</div>
<div className="flex h-full shrink-0 flex-col items-end justify-between py-px">
{onlinePresenceUsers.length > 0 && (
{onlinePresenceUsers.length > 0 && (
<div className="ml-3 flex h-10 shrink-0 flex-col items-end">
<UserAvatarList users={onlinePresenceUsers} size="xxs" maxVisible={3} className="justify-end" />
)}
<div className="flex h-5 w-5 items-center justify-center">
{app.access_mode === AccessMode.PUBLIC && (
<Tooltip>
<TooltipTrigger
aria-label={t('accessItemsDescription.anyone', { ns: 'app' })}
render={<span title={t('accessItemsDescription.anyone', { ns: 'app' })} className="i-ri-global-line h-4 w-4 text-text-quaternary" />}
/>
<TooltipContent>{t('accessItemsDescription.anyone', { ns: 'app' })}</TooltipContent>
</Tooltip>
)}
{app.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && (
<Tooltip>
<TooltipTrigger
aria-label={t('accessItemsDescription.specific', { ns: 'app' })}
render={<span title={t('accessItemsDescription.specific', { ns: 'app' })} className="i-ri-lock-line h-4 w-4 text-text-quaternary" />}
/>
<TooltipContent>{t('accessItemsDescription.specific', { ns: 'app' })}</TooltipContent>
</Tooltip>
)}
{app.access_mode === AccessMode.ORGANIZATION && (
<Tooltip>
<TooltipTrigger
aria-label={t('accessItemsDescription.organization', { ns: 'app' })}
render={<span title={t('accessItemsDescription.organization', { ns: 'app' })} className="i-ri-building-line h-4 w-4 text-text-quaternary" />}
/>
<TooltipContent>{t('accessItemsDescription.organization', { ns: 'app' })}</TooltipContent>
</Tooltip>
)}
{app.access_mode === AccessMode.EXTERNAL_MEMBERS && (
<Tooltip>
<TooltipTrigger
aria-label={t('accessItemsDescription.external', { ns: 'app' })}
render={<span title={t('accessItemsDescription.external', { ns: 'app' })} className="i-ri-verified-badge-line h-4 w-4 text-text-quaternary" />}
/>
<TooltipContent>{t('accessItemsDescription.external', { ns: 'app' })}</TooltipContent>
</Tooltip>
)}
</div>
</div>
)}
</div>
<div className="h-[90px] px-[14px] text-xs leading-normal text-text-tertiary">
<div className="shrink-0 px-4 py-1 system-xs-regular text-text-tertiary">
<div
className="line-clamp-2"
className="line-clamp-2 min-h-8"
title={app.description}
>
{app.description}
</div>
</div>
<div className="absolute right-0 bottom-1 left-0 flex h-[42px] shrink-0 items-center pt-1 pr-[6px] pb-[6px] pl-[14px]">
<div className="flex h-[26px] shrink-0 items-start px-3">
{isCurrentWorkspaceEditor && (
<>
<div
className={cn('flex w-0 grow items-center gap-1')}
<div
className="w-full min-w-0"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<AppCardTags
appId={app.id}
tags={app.tags}
onOpenTagManagement={onOpenTagManagement}
onTagsChange={onRefresh}
/>
</div>
)}
</div>
<div className="flex min-w-0 shrink-0 items-center pt-2 pr-3 pb-3 pl-4 system-xs-regular text-text-tertiary">
<div className="flex min-w-0 flex-1 items-center gap-1 whitespace-nowrap">
<div className="truncate" title={app.author_name}>{app.author_name}</div>
<div className="shrink-0">·</div>
<div className="truncate" title={editTimeText}>{editTimeText}</div>
</div>
</div>
{isCurrentWorkspaceEditor && (
<div
className={cn(
'absolute top-[-0.5px] right-[-0.5px] flex h-16 w-[120px] items-start justify-end bg-linear-to-r from-components-card-bg-alt-transparent to-components-card-bg-alt p-2 transition-opacity',
isOperationsMenuOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
)}
>
<DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 backdrop-blur-xs hover:bg-components-actionbar-bg focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
isOperationsMenuOpen ? 'shadow-none' : 'shadow-lg',
)}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className="mr-[41px] min-w-0 grow overflow-hidden">
<AppCardTags
appId={app.id}
tags={app.tags}
onOpenTagManagement={onOpenTagManagement}
onTagsChange={onRefresh}
/>
<div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover">
<span className="sr-only">{t('operation.more', { ns: 'common' })}</span>
<span aria-hidden className="i-ri-more-fill h-[18px] w-[18px] text-text-tertiary" />
</div>
</div>
<div
className={cn(
'absolute top-1/2 right-[6px] flex -translate-y-1/2 items-center transition-opacity',
isOperationsMenuOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
)}
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName={operationsMenuWidthClassName}
>
<div className="mx-1 h-[14px] w-px shrink-0 bg-divider-regular" />
<DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
isOperationsMenuOpen ? 'bg-state-base-hover shadow-none' : 'bg-transparent',
'flex h-8 w-8 items-center justify-center rounded-md border-none p-2 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
{systemFeatures.webapp_auth.enabled
? (
<AppCardOperationsMenuContent
app={app}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowAccessControlOption={shouldShowAccessControlOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
/>
)
: (
<AppCardOperationsMenu
app={app}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowOpenInExploreOption={!app.has_draft_trigger}
shouldShowAccessControlOption={shouldShowAccessControlOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
/>
)}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md">
<span className="sr-only">{t('operation.more', { ns: 'common' })}</span>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName={operationsMenuWidthClassName}
>
{systemFeatures.webapp_auth.enabled
? (
<AppCardOperationsMenuContent
app={app}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowAccessControlOption={shouldShowAccessControlOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
/>
)
: (
<AppCardOperationsMenu
app={app}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowOpenInExploreOption={!app.has_draft_trigger}
shouldShowAccessControlOption={shouldShowAccessControlOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
{showEditModal && (
<EditAppModal

View File

@ -1 +1,123 @@
import type { App } from '@/types/app'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
export const APP_LIST_SEARCH_DEBOUNCE_MS = 500
const mockAppBase = {
icon_type: 'emoji',
icon_url: null,
use_icon_as_answer_icon: false,
enable_site: true,
enable_api: true,
api_rpm: 0,
api_rph: 0,
is_demo: false,
model_config: {} as App['model_config'],
app_model_config: {} as App['app_model_config'],
site: {} as App['site'],
api_base_url: '',
tags: [],
access_mode: AccessMode.PUBLIC,
max_active_requests: null,
has_draft_trigger: false,
} satisfies Partial<App>
export const MOCK_APP_LIST: App[] = [
{
...mockAppBase,
id: 'mock-app-content-brief',
name: 'Content Brief Builder',
description: 'Drafts campaign briefs, audience notes, and channel-specific launch copy for marketing teams.',
author_name: 'Joel',
icon: '📝',
icon_background: '#EEF4FF',
mode: AppModeEnum.CHAT,
created_at: 1778640000,
updated_at: 1778640000,
},
{
...mockAppBase,
id: 'mock-app-sales-research',
name: 'Sales Research Copilot',
description: 'Summarizes accounts, finds buying signals, and prepares concise call notes before customer meetings.',
author_name: 'Evan',
icon: '🔎',
icon_background: '#ECFDF3',
mode: AppModeEnum.AGENT_CHAT,
created_at: 1778636400,
updated_at: 1778636400,
},
{
...mockAppBase,
id: 'mock-app-contract-review',
name: 'Contract Review Flow',
description: 'Checks uploaded agreements for unusual clauses, missing terms, and follow-up questions.',
author_name: 'Sarah',
icon: '📄',
icon_background: '#FDF2FA',
mode: AppModeEnum.WORKFLOW,
created_at: 1778632800,
updated_at: 1778632800,
},
{
...mockAppBase,
id: 'mock-app-support-router',
name: 'Support Ticket Router',
description: 'Classifies incoming support requests and routes them to the right queue with priority labels.',
author_name: 'Maya',
icon: '🎧',
icon_background: '#E0F2FE',
mode: AppModeEnum.ADVANCED_CHAT,
created_at: 1778629200,
updated_at: 1778629200,
},
{
...mockAppBase,
id: 'mock-app-report-summarizer',
name: 'Report Summarizer',
description: 'Turns long operational reports into concise executive summaries with highlights and risks.',
author_name: 'Dify',
icon: '📊',
icon_background: '#FFF7ED',
mode: AppModeEnum.COMPLETION,
created_at: 1778625600,
updated_at: 1778625600,
},
{
...mockAppBase,
id: 'mock-app-product-feedback',
name: 'Product Feedback Miner',
description: 'Clusters customer feedback, extracts recurring requests, and drafts product insight digests.',
author_name: 'Nora',
icon: '💬',
icon_background: '#F4F3FF',
mode: AppModeEnum.WORKFLOW,
created_at: 1778622000,
updated_at: 1778622000,
},
{
...mockAppBase,
id: 'mock-app-data-cleanup',
name: 'Data Cleanup Assistant',
description: 'Normalizes messy CSV columns, suggests validation rules, and explains transformation steps.',
author_name: 'Leo',
icon: '🧹',
icon_background: '#F0FDF4',
mode: AppModeEnum.CHAT,
created_at: 1778618400,
updated_at: 1778618400,
},
{
...mockAppBase,
id: 'mock-app-onboarding-guide',
name: 'Onboarding Guide',
description: 'Creates role-specific onboarding checklists and answers common first-week questions.',
author_name: 'Ivy',
icon: '🧭',
icon_background: '#FFFBEB',
mode: AppModeEnum.ADVANCED_CHAT,
created_at: 1778614800,
updated_at: 1778614800,
},
]

View File

@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import Input from '@/app/components/base/input'
import TabSliderNew from '@/app/components/base/tab-slider-new'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { IS_DEV, NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { TagFilter } from '@/features/tag-management/components/tag-filter'
import { CheckModal } from '@/hooks/use-pay'
@ -20,7 +20,7 @@ import { systemFeaturesQueryOptions } from '@/service/system-features'
import { AppModeEnum } from '@/types/app'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
import { APP_LIST_SEARCH_DEBOUNCE_MS } from './constants'
import { APP_LIST_SEARCH_DEBOUNCE_MS, MOCK_APP_LIST } from './constants'
import Empty from './empty'
import Footer from './footer'
import { isAppListCategory, useAppsQueryState } from './hooks/use-apps-query-state'
@ -163,7 +163,29 @@ const List: FC<Props> = ({
}, [isCreatedByMe, setIsCreatedByMe])
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages])
const mockApps = useMemo(() => {
if (!IS_DEV)
return []
if (tagIDs.length)
return []
const normalizedKeywords = debouncedKeywords.trim().toLowerCase()
return MOCK_APP_LIST.filter((app) => {
if (category !== 'all' && app.mode !== category)
return false
if (!normalizedKeywords)
return true
return [app.name, app.description, app.author_name].some(value =>
value.toLowerCase().includes(normalizedKeywords),
)
})
}, [category, debouncedKeywords, tagIDs.length])
const apps = useMemo(() => [
...pages.flatMap(({ data: pageApps }) => pageApps),
...mockApps,
], [mockApps, pages])
const workflowOnlineUserAppIds = useMemo(() => {
const appIds = new Set<string>()
@ -181,7 +203,7 @@ const List: FC<Props> = ({
enabled: systemFeatures.enable_collaboration_mode,
})
const hasAnyApp = (pages[0]?.total ?? 0) > 0
const hasAnyApp = (pages[0]?.total ?? 0) > 0 || mockApps.length > 0
// Show skeleton during initial load or when refetching with no previous data
const showSkeleton = isLoading || (isFetching && pages.length === 0)
@ -221,7 +243,7 @@ const List: FC<Props> = ({
</div>
</div>
<div className={cn(
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
'relative grid grow grid-cols-1 content-start gap-3 px-12 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
!hasAnyApp && 'overflow-hidden',
)}
>

View File

@ -68,7 +68,7 @@ const CreateAppCard = ({
<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 transition-opacity',
'relative col-span-1 inline-flex h-41.5 flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg transition-opacity',
isLoading && 'pointer-events-none opacity-50',
className,
)}