2026-05-19 18:07:22 +02:00

304 lines
7.6 KiB
TypeScript

// frontend/src/components/Card.tsx
import type { ReactNode } from 'react'
type CardVariant =
| 'basic'
| 'edge-to-edge-mobile'
| 'with-header'
| 'with-footer'
| 'with-header-footer'
| 'with-gray-header'
| 'with-gray-footer'
| 'with-gray-header-footer'
| 'gray-footer'
| 'gray-body'
| 'well'
| 'well-on-gray'
| 'well-edge-to-edge-mobile'
type CardHeaderVariant =
| 'simple'
| 'with-action'
| 'with-description'
| 'with-description-action'
| 'with-avatar-actions'
| 'with-avatar-meta-actions'
| 'custom'
type CardProps = {
children?: ReactNode
header?: ReactNode
footer?: ReactNode
headerVariant?: CardHeaderVariant
headerTitle?: ReactNode
headerDescription?: ReactNode
headerMeta?: ReactNode
headerAction?: ReactNode
headerActions?: ReactNode
headerAvatar?: ReactNode
variant?: CardVariant
className?: string
headerClassName?: string
bodyClassName?: string
footerClassName?: string
}
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
export default function Card({
children,
header,
footer,
headerVariant = 'custom',
headerTitle,
headerDescription,
headerMeta,
headerAction,
headerActions,
headerAvatar,
variant = 'basic',
className,
headerClassName,
bodyClassName,
footerClassName,
}: CardProps) {
const isEdgeToEdgeMobile =
variant === 'edge-to-edge-mobile' || variant === 'well-edge-to-edge-mobile'
const isWell =
variant === 'well' ||
variant === 'well-on-gray' ||
variant === 'well-edge-to-edge-mobile'
const hasGrayHeader =
variant === 'with-gray-header' ||
variant === 'with-gray-header-footer'
const hasGrayFooter =
variant === 'with-gray-footer' ||
variant === 'with-gray-header-footer' ||
variant === 'gray-footer'
const hasDivider =
variant === 'with-header' ||
variant === 'with-footer' ||
variant === 'with-header-footer' ||
variant === 'with-gray-header' ||
variant === 'with-gray-footer' ||
variant === 'with-gray-header-footer'
const renderedHeader = renderHeader()
const hasHeader =
Boolean(renderedHeader) &&
(variant === 'with-header' ||
variant === 'with-header-footer' ||
variant === 'with-gray-header' ||
variant === 'with-gray-header-footer' ||
variant === 'gray-body')
const hasFooter =
Boolean(footer) &&
(variant === 'with-footer' ||
variant === 'with-header-footer' ||
variant === 'with-gray-footer' ||
variant === 'with-gray-header-footer' ||
variant === 'gray-footer')
function renderHeader() {
if (header) {
return header
}
if (!headerTitle && !headerDescription && !headerAction && !headerActions && !headerAvatar && !headerMeta) {
return null
}
const actions = headerActions ?? headerAction
if (headerVariant === 'with-action') {
return (
<div className="-mt-2 -ml-4 flex flex-wrap items-center justify-between sm:flex-nowrap">
<div className="mt-2 ml-4 min-w-0">
<h3 className="truncate text-base font-semibold text-gray-900 dark:text-white">
{headerTitle}
</h3>
</div>
{actions && (
<div className="mt-2 ml-4 shrink-0">
{actions}
</div>
)}
</div>
)
}
if (headerVariant === 'with-description-action') {
return (
<div className="-mt-4 -ml-4 flex flex-wrap items-center justify-between sm:flex-nowrap">
<div className="mt-4 ml-4 min-w-0">
<h3 className="truncate text-base font-semibold text-gray-900 dark:text-white">
{headerTitle}
</h3>
{headerDescription && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{headerDescription}
</p>
)}
</div>
{actions && (
<div className="mt-4 ml-4 shrink-0">
{actions}
</div>
)}
</div>
)
}
if (headerVariant === 'with-description') {
return (
<>
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
{headerTitle}
</h3>
{headerDescription && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{headerDescription}
</p>
)}
</>
)
}
if (headerVariant === 'with-avatar-actions') {
return (
<div className="-mt-4 -ml-4 flex flex-wrap items-center justify-between sm:flex-nowrap">
<div className="mt-4 ml-4 min-w-0">
<div className="flex items-center">
{headerAvatar && (
<div className="shrink-0">
{headerAvatar}
</div>
)}
<div className={classNames(headerAvatar ? 'ml-4' : false, 'min-w-0')}>
<h3 className="truncate text-base font-semibold text-gray-900 dark:text-white">
{headerTitle}
</h3>
{headerDescription && (
<p className="truncate text-sm text-gray-500 dark:text-gray-400">
{headerDescription}
</p>
)}
</div>
</div>
</div>
{actions && (
<div className="mt-4 ml-4 flex shrink-0">
{actions}
</div>
)}
</div>
)
}
if (headerVariant === 'with-avatar-meta-actions') {
return (
<div className="flex space-x-3">
{headerAvatar && (
<div className="shrink-0">
{headerAvatar}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-gray-900 dark:text-white">
{headerTitle}
</p>
{headerMeta && (
<p className="truncate text-sm text-gray-500 dark:text-gray-400">
{headerMeta}
</p>
)}
</div>
{actions && (
<div className="flex shrink-0 self-center">
{actions}
</div>
)}
</div>
)
}
return (
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
{headerTitle}
</h3>
)
}
return (
<div
className={classNames(
'overflow-hidden',
isEdgeToEdgeMobile ? 'sm:rounded-lg' : 'rounded-lg',
isWell
? variant === 'well-on-gray'
? 'bg-gray-200 dark:bg-gray-800/50'
: 'bg-gray-100 dark:bg-gray-800'
: 'bg-white shadow-sm dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10',
hasDivider && 'divide-y divide-gray-200 dark:divide-white/10',
className,
)}
>
{hasHeader && (
<div
className={classNames(
'px-4 py-5 sm:px-6',
hasGrayHeader && 'bg-gray-100 dark:bg-gray-800',
headerClassName,
)}
>
{renderedHeader}
</div>
)}
<div
className={classNames(
'px-4 py-5 sm:p-6',
variant === 'gray-body' && 'bg-gray-100 dark:bg-gray-800',
bodyClassName,
)}
>
{children}
</div>
{hasFooter && (
<div
className={classNames(
'px-4 py-4 sm:px-6',
hasGrayFooter && 'bg-gray-100 dark:bg-gray-800',
footerClassName,
)}
>
{footer}
</div>
)}
</div>
)
}