247 lines
6.3 KiB
TypeScript
247 lines
6.3 KiB
TypeScript
'use client';
|
|
|
|
import React, { ReactNode, HTMLAttributes, forwardRef } from 'react';
|
|
import Link from 'next/link';
|
|
import { cn } from '@/lib/utils';
|
|
import { Card } from '@/components/ui';
|
|
|
|
// Base card sizes
|
|
export type CardSize = 'sm' | 'md' | 'lg';
|
|
|
|
// Base card layouts
|
|
export type CardLayout = 'vertical' | 'horizontal';
|
|
|
|
// Base card props interface
|
|
export interface BaseCardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
|
|
/** Card title */
|
|
title?: ReactNode;
|
|
/** Card description/excerpt */
|
|
description?: ReactNode;
|
|
/** Card image URL */
|
|
image?: string;
|
|
/** Card image alt text */
|
|
imageAlt?: string;
|
|
/** Card size */
|
|
size?: CardSize;
|
|
/** Card layout */
|
|
layout?: CardLayout;
|
|
/** Card href/link */
|
|
href?: string;
|
|
/** Card badge/badges */
|
|
badge?: ReactNode;
|
|
/** Card footer content */
|
|
footer?: ReactNode;
|
|
/** Card header content */
|
|
header?: ReactNode;
|
|
/** Loading state */
|
|
loading?: boolean;
|
|
/** Hover effect */
|
|
hoverable?: boolean;
|
|
/** Card variant */
|
|
variant?: 'elevated' | 'flat' | 'bordered';
|
|
/** Image height */
|
|
imageHeight?: 'sm' | 'md' | 'lg' | 'xl';
|
|
/** Children content */
|
|
children?: ReactNode;
|
|
}
|
|
|
|
// Helper function to get size styles
|
|
const getSizeStyles = (size: CardSize, layout: CardLayout) => {
|
|
const sizeMap = {
|
|
vertical: {
|
|
sm: { container: 'max-w-xs', image: 'h-32', padding: 'p-3' },
|
|
md: { container: 'max-w-sm', image: 'h-48', padding: 'p-4' },
|
|
lg: { container: 'max-w-md', image: 'h-64', padding: 'p-6' },
|
|
},
|
|
horizontal: {
|
|
sm: { container: 'max-w-sm', image: 'h-24 w-24', padding: 'p-3' },
|
|
md: { container: 'max-w-lg', image: 'h-32 w-32', padding: 'p-4' },
|
|
lg: { container: 'max-w-xl', image: 'h-40 w-40', padding: 'p-6' },
|
|
},
|
|
};
|
|
|
|
return sizeMap[layout][size];
|
|
};
|
|
|
|
// Helper function to get image height
|
|
const getImageHeight = (height: CardSize | 'sm' | 'md' | 'lg' | 'xl') => {
|
|
const heightMap = {
|
|
sm: 'h-32',
|
|
md: 'h-48',
|
|
lg: 'h-64',
|
|
xl: 'h-80',
|
|
};
|
|
return heightMap[height] || heightMap['md'];
|
|
};
|
|
|
|
// Skeleton loader component
|
|
const CardSkeleton = ({ layout, size }: { layout: CardLayout; size: CardSize }) => {
|
|
const sizeStyles = getSizeStyles(size, layout);
|
|
|
|
return (
|
|
<div className={cn('animate-pulse bg-gray-200 rounded-lg', sizeStyles.container)}>
|
|
<div className={cn('bg-gray-300 rounded-t-lg', sizeStyles.image)} />
|
|
<div className={sizeStyles.padding}>
|
|
<div className="h-6 bg-gray-300 rounded mb-2 w-3/4" />
|
|
<div className="h-4 bg-gray-300 rounded mb-1 w-full" />
|
|
<div className="h-4 bg-gray-300 rounded mb-1 w-5/6" />
|
|
<div className="h-4 bg-gray-300 rounded w-2/3 mt-3" />
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Main BaseCard Component
|
|
export const BaseCard = forwardRef<HTMLDivElement, BaseCardProps>(
|
|
(
|
|
{
|
|
title,
|
|
description,
|
|
image,
|
|
imageAlt = '',
|
|
size = 'md',
|
|
layout = 'vertical',
|
|
href,
|
|
badge,
|
|
footer,
|
|
header,
|
|
loading = false,
|
|
hoverable = true,
|
|
variant = 'elevated',
|
|
imageHeight,
|
|
className = '',
|
|
children,
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
const sizeStyles = getSizeStyles(size, layout);
|
|
|
|
// Loading state
|
|
if (loading) {
|
|
return <CardSkeleton layout={layout} size={size} />;
|
|
}
|
|
|
|
// Content sections
|
|
const renderImage = () => {
|
|
if (!image) return null;
|
|
|
|
const imageClasses = layout === 'horizontal'
|
|
? cn('flex-shrink-0 overflow-hidden rounded-lg', sizeStyles.image)
|
|
: cn('w-full overflow-hidden rounded-t-lg', imageHeight ? getImageHeight(imageHeight) : sizeStyles.image);
|
|
|
|
return (
|
|
<div className={imageClasses}>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={image}
|
|
alt={imageAlt}
|
|
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderHeader = () => {
|
|
if (!header && !badge) return null;
|
|
return (
|
|
<div className="flex items-start justify-between gap-2 mb-2">
|
|
{header}
|
|
{badge}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderTitle = () => {
|
|
if (!title) return null;
|
|
return (
|
|
<h3 className={cn(
|
|
'font-semibold text-gray-900 leading-tight',
|
|
size === 'sm' && 'text-base',
|
|
size === 'md' && 'text-lg',
|
|
size === 'lg' && 'text-xl'
|
|
)}>
|
|
{title}
|
|
</h3>
|
|
);
|
|
};
|
|
|
|
const renderDescription = () => {
|
|
if (!description) return null;
|
|
return (
|
|
<div className={cn(
|
|
'text-gray-600 mt-1',
|
|
size === 'sm' && 'text-sm',
|
|
size === 'md' && 'text-sm',
|
|
size === 'lg' && 'text-base'
|
|
)}>
|
|
{description}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderFooter = () => {
|
|
if (!footer) return null;
|
|
return (
|
|
<div className="mt-3 pt-3 border-t border-gray-100">
|
|
{footer}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Card content
|
|
const cardContent = (
|
|
<div className={cn(
|
|
'flex',
|
|
layout === 'horizontal' && 'flex-row',
|
|
layout === 'vertical' && 'flex-col',
|
|
sizeStyles.padding
|
|
)}>
|
|
{layout === 'horizontal' && renderImage()}
|
|
<div className={cn('flex-1', layout === 'horizontal' && 'ml-4')}>
|
|
{renderHeader()}
|
|
{renderTitle()}
|
|
{renderDescription()}
|
|
{children}
|
|
{renderFooter()}
|
|
</div>
|
|
{layout === 'vertical' && renderImage()}
|
|
</div>
|
|
);
|
|
|
|
// If href is provided, wrap in a Next.js Link
|
|
if (href) {
|
|
return (
|
|
<Link
|
|
href={href}
|
|
className={cn(
|
|
'group block',
|
|
'transition-all duration-200 ease-in-out',
|
|
hoverable && 'hover:-translate-y-1 hover:shadow-xl',
|
|
className
|
|
)}
|
|
>
|
|
<Card variant={variant} padding="none" className={sizeStyles.container}>
|
|
{cardContent}
|
|
</Card>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
// Otherwise, just return the card
|
|
return (
|
|
<Card
|
|
ref={ref}
|
|
variant={variant}
|
|
padding="none"
|
|
className={cn(sizeStyles.container, className)}
|
|
hoverable={hoverable}
|
|
{...props}
|
|
>
|
|
{cardContent}
|
|
</Card>
|
|
);
|
|
}
|
|
);
|
|
|
|
BaseCard.displayName = 'BaseCard'; |