mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-24 07:15:44 +00:00
feat(FE-Storyless): add collapsible functionality and improve image handling
This commit is contained in:
+127
-33
@@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes, ReactNode } from 'react';
|
import { HTMLAttributes, ReactNode, useState } from 'react';
|
||||||
|
|
||||||
import { cn } from '@/lib/helper';
|
import { cn } from '@/lib/helper';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import Collapse from './Collapse';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
|
||||||
export interface CardProps
|
export interface CardProps
|
||||||
extends Omit<HTMLAttributes<HTMLDivElement>, 'className'> {
|
extends Omit<HTMLAttributes<HTMLDivElement>, 'className'> {
|
||||||
@@ -11,8 +13,13 @@ export interface CardProps
|
|||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
imageAlt?: string;
|
imageAlt?: string;
|
||||||
|
imageWidth?: number;
|
||||||
|
imageHeight?: number;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
|
collapsible?: boolean;
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
|
onCollapsedChange?: (collapsed: boolean) => void;
|
||||||
className?: {
|
className?: {
|
||||||
wrapper?: string;
|
wrapper?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
@@ -21,6 +28,7 @@ export interface CardProps
|
|||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
actions?: string;
|
actions?: string;
|
||||||
footer?: string;
|
footer?: string;
|
||||||
|
collapsible?: string;
|
||||||
};
|
};
|
||||||
variant?: 'default' | 'compact' | 'bordered' | 'shadow' | 'image-full';
|
variant?: 'default' | 'compact' | 'bordered' | 'shadow' | 'image-full';
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'sm' | 'md' | 'lg';
|
||||||
@@ -31,14 +39,27 @@ const Card = ({
|
|||||||
subtitle,
|
subtitle,
|
||||||
image,
|
image,
|
||||||
imageAlt,
|
imageAlt,
|
||||||
|
imageWidth,
|
||||||
|
imageHeight,
|
||||||
actions,
|
actions,
|
||||||
footer,
|
footer,
|
||||||
|
collapsible,
|
||||||
|
defaultCollapsed = false,
|
||||||
|
onCollapsedChange,
|
||||||
className,
|
className,
|
||||||
variant = 'default',
|
variant = 'default',
|
||||||
size = 'md',
|
size = 'md',
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: CardProps) => {
|
}: CardProps) => {
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
|
||||||
|
|
||||||
|
const handleCollapsedChange = (open: boolean) => {
|
||||||
|
const collapsed = !open;
|
||||||
|
setIsCollapsed(collapsed);
|
||||||
|
onCollapsedChange?.(collapsed);
|
||||||
|
};
|
||||||
|
|
||||||
const getCardClasses = () => {
|
const getCardClasses = () => {
|
||||||
const baseClasses = 'card bg-base-100';
|
const baseClasses = 'card bg-base-100';
|
||||||
|
|
||||||
@@ -64,11 +85,31 @@ const Card = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = () => {
|
const getImageClasses = () => {
|
||||||
if (variant === 'image-full') {
|
if (variant === 'image-full') {
|
||||||
return cn('w-32 h-32 object-cover', className?.image);
|
return cn('object-cover', className?.image);
|
||||||
}
|
}
|
||||||
return cn('h-48 object-cover', className?.image);
|
return cn('w-full object-cover', className?.image);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBodyClasses = () => {
|
const getBodyClasses = () => {
|
||||||
@@ -103,45 +144,98 @@ const Card = ({
|
|||||||
return cn('border-t border-base-300 mt-4 pt-4', className?.footer);
|
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'>
|
||||||
|
<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'
|
||||||
|
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) {
|
if (variant === 'image-full' && image) {
|
||||||
return (
|
return (
|
||||||
<div className={getCardClasses()} {...props}>
|
<div className={getCardClasses()} {...props}>
|
||||||
<figure>
|
{renderCardContent()}
|
||||||
<Image
|
|
||||||
src={image}
|
|
||||||
alt={imageAlt || title || 'Card image'}
|
|
||||||
className={getImageClasses()}
|
|
||||||
/>
|
|
||||||
</figure>
|
|
||||||
<div className={getBodyClasses()}>
|
|
||||||
{title && <h2 className={getTitleClasses()}>{title}</h2>}
|
|
||||||
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
|
|
||||||
{children}
|
|
||||||
{actions && <div className={getActionsClasses()}>{actions}</div>}
|
|
||||||
</div>
|
|
||||||
{footer && <div className={getFooterClasses()}>{footer}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={getCardClasses()} {...props}>
|
<div className={getCardClasses()} {...props}>
|
||||||
{image && (
|
{renderCardContent()}
|
||||||
<figure>
|
|
||||||
<Image
|
|
||||||
src={image}
|
|
||||||
alt={imageAlt || title || 'Card image'}
|
|
||||||
className={getImageClasses()}
|
|
||||||
/>
|
|
||||||
</figure>
|
|
||||||
)}
|
|
||||||
<div className={getBodyClasses()}>
|
|
||||||
{title && <h2 className={getTitleClasses()}>{title}</h2>}
|
|
||||||
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
|
|
||||||
{children}
|
|
||||||
{actions && <div className={getActionsClasses()}>{actions}</div>}
|
|
||||||
</div>
|
|
||||||
{footer && <div className={getFooterClasses()}>{footer}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export type CollapseProps = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** Allow only one open at a time by switching to radio input */
|
/** Allow only one open at a time by switching to radio input */
|
||||||
asRadio?: boolean;
|
asRadio?: boolean;
|
||||||
|
/** Force full width instead of auto-fit when collapsed
|
||||||
|
* (Khusus justify-between dan justify-end) */
|
||||||
|
fullWidth?: boolean;
|
||||||
/** Extra classnames */
|
/** Extra classnames */
|
||||||
className?: string;
|
className?: string;
|
||||||
titleClassName?: string;
|
titleClassName?: string;
|
||||||
@@ -44,6 +47,7 @@ export const Collapse = ({
|
|||||||
bordered,
|
bordered,
|
||||||
disabled,
|
disabled,
|
||||||
asRadio = false,
|
asRadio = false,
|
||||||
|
fullWidth,
|
||||||
className,
|
className,
|
||||||
titleClassName,
|
titleClassName,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
@@ -68,9 +72,9 @@ export const Collapse = ({
|
|||||||
'collapse',
|
'collapse',
|
||||||
variant === 'arrow' && 'collapse-arrow',
|
variant === 'arrow' && 'collapse-arrow',
|
||||||
variant === 'plus' && 'collapse-plus',
|
variant === 'plus' && 'collapse-plus',
|
||||||
bordered && 'border base-content/20 border-opacity-20 rounded',
|
bordered && 'border base-content/20 border-opacity-20 rounded-box',
|
||||||
disabled && 'opacity-60 pointer-events-none',
|
disabled && 'opacity-60 pointer-events-none',
|
||||||
!open && 'w-fit',
|
!fullWidth && !open && 'w-fit',
|
||||||
className
|
className
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user