Files
lti-web-client/src/components/Card.tsx
T

248 lines
6.0 KiB
TypeScript

'use client';
import { HTMLAttributes, ReactNode, useState } from 'react';
import { cn } from '@/lib/helper';
import Image from 'next/image';
import Collapse from '@/components/Collapse';
import { Icon } from '@iconify/react';
export interface CardProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'className'> {
title?: string;
subtitle?: string;
image?: string;
imageAlt?: string;
imageWidth?: number;
imageHeight?: number;
actions?: ReactNode;
footer?: ReactNode;
collapsible?: boolean;
defaultCollapsed?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
className?: {
wrapper?: string;
image?: string;
body?: string;
title?: string;
subtitle?: string;
actions?: string;
footer?: string;
collapsible?: string;
};
variant?: 'default' | 'compact' | 'bordered' | 'shadow' | 'image-full';
size?: 'sm' | 'md' | 'lg';
}
const Card = ({
title,
subtitle,
image,
imageAlt,
imageWidth,
imageHeight,
actions,
footer,
collapsible,
defaultCollapsed = false,
onCollapsedChange,
className,
variant = 'default',
size = 'md',
children,
...props
}: CardProps) => {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
const handleCollapsedChange = (open: boolean) => {
const collapsed = !open;
setIsCollapsed(collapsed);
onCollapsedChange?.(collapsed);
};
const getCardClasses = () => {
const baseClasses = 'card bg-base-100';
const variantClasses = {
default: '',
compact: 'card-compact',
bordered: 'border border-base-300',
shadow: 'shadow-xl',
'image-full': 'card-side card-compact shadow-xl',
};
const sizeClasses = {
sm: 'w-64',
md: 'w-96',
lg: 'w-[28rem]',
};
return cn(
baseClasses,
variantClasses[variant],
variant !== 'image-full' ? sizeClasses[size] : '',
className?.wrapper
);
};
const getImageDimensions = () => {
if (variant === 'image-full') {
return {
width: imageWidth || 128,
height: imageHeight || 128,
};
}
const cardWidths = {
sm: 256, // w-64
md: 384, // w-96
lg: 448, // w-[28rem]
};
return {
width: imageWidth || cardWidths[size],
height: imageHeight || 192,
};
};
const getImageClasses = () => {
if (variant === 'image-full') {
return cn('object-cover', className?.image);
}
return cn('w-full object-cover', className?.image);
};
const getBodyClasses = () => {
const baseClasses = 'card-body';
if (variant === 'compact' || variant === 'image-full') {
return cn(baseClasses, 'p-4', className?.body);
}
return cn(baseClasses, 'p-6', className?.body);
};
const getTitleClasses = () => {
const sizeClasses = {
sm: 'text-lg',
md: 'text-xl',
lg: 'text-2xl',
};
return cn('card-title font-bold', sizeClasses[size], className?.title);
};
const getSubtitleClasses = () => {
return cn('text-base-content/70 text-sm mt-1', className?.subtitle);
};
const getActionsClasses = () => {
return cn('card-actions justify-end mt-4', className?.actions);
};
const getFooterClasses = () => {
return cn('border-t border-base-300 mt-4 pt-4', className?.footer);
};
const renderCardContent = () => {
const hasContent = children || actions || footer;
const titleContent = (
<div
className={
`group flex items-center justify-between! w-full` + getTitleClasses()
}
>
<div className='flex-1'>
{title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
</div>
{collapsible && (
<button
onClick={() => handleCollapsedChange(!isCollapsed)}
className={`btn btn-ghost btn-sm btn-circle` + getTitleClasses()}
aria-label={isCollapsed ? 'Expand content' : 'Collapse content'}
>
<Icon
icon={
isCollapsed
? 'material-symbols:expand-more'
: 'material-symbols:expand-less'
}
width={20}
/>
</button>
)}
</div>
);
const cardContent = (
<div className='space-y-4'>
{children}
{actions && <div className={getActionsClasses()}>{actions}</div>}
{footer && <div className={getFooterClasses()}>{footer}</div>}
</div>
);
return (
<>
{image && (
<figure>
<Image
src={image}
alt={imageAlt || title || 'Card image'}
width={getImageDimensions().width}
height={getImageDimensions().height}
className={getImageClasses()}
/>
</figure>
)}
<div className={getBodyClasses()}>
{collapsible && hasContent ? (
<Collapse
variant='default'
bordered={false}
open={!isCollapsed}
onOpenChange={handleCollapsedChange}
title={titleContent}
titleClassName='w-full cursor-pointer'
contentClassName='p-0'
fullWidth={true}
>
{cardContent}
</Collapse>
) : (
<>
{(title || subtitle) && (
<div className='mb-4'>
{title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && (
<p className={getSubtitleClasses()}>{subtitle}</p>
)}
</div>
)}
{hasContent && cardContent}
</>
)}
</div>
</>
);
};
if (variant === 'image-full' && image) {
return (
<div className={getCardClasses()} {...props}>
{renderCardContent()}
</div>
);
}
return (
<div className={getCardClasses()} {...props}>
{renderCardContent()}
</div>
);
};
export default Card;