304 lines
7.6 KiB
TypeScript
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>
|
|
)
|
|
} |