Files
klz-cables.com/components/cards/BaseCard.tsx
2025-12-29 18:18:48 +01:00

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';