chore: import codes
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
const Badge = ({
|
||||
children,
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
const baseClassName = 'inline-flex items-center font-medium border';
|
||||
|
||||
const variants = {
|
||||
default: 'bg-neutral-200/80 text-neutral-700 border-neutral-300 dark:bg-neutral-500/10 dark:text-neutral-400 dark:border-neutral-500/20',
|
||||
primary: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-500/10 dark:text-blue-400 dark:border-blue-500/20',
|
||||
success: 'bg-green-100 text-green-700 border-green-200 dark:bg-green-500/10 dark:text-green-400 dark:border-green-500/20',
|
||||
warning: 'bg-yellow-100 text-yellow-700 border-yellow-200 dark:bg-yellow-500/10 dark:text-yellow-400 dark:border-yellow-500/20',
|
||||
danger: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-500/10 dark:text-red-400 dark:border-red-500/20',
|
||||
info: 'bg-cyan-100 text-cyan-700 border-cyan-200 dark:bg-cyan-500/10 dark:text-cyan-400 dark:border-cyan-500/20',
|
||||
purple: 'bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-500/10 dark:text-purple-400 dark:border-purple-500/20',
|
||||
pink: 'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-500/10 dark:text-pink-400 dark:border-pink-500/20',
|
||||
orange: 'bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-500/10 dark:text-orange-400 dark:border-orange-500/20'
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-2 py-0.5 rounded-full text-xs',
|
||||
md: 'px-2.5 py-0.5 rounded-full text-xs',
|
||||
lg: 'px-3 py-1 rounded-full text-sm'
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`${baseClassName} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Predefined badge types for common use cases
|
||||
export const StatusBadge = ({ status, ...props }) => {
|
||||
const statusConfig = {
|
||||
active: { variant: 'success', children: 'Active' },
|
||||
inactive: { variant: 'default', children: 'Inactive' },
|
||||
pending: { variant: 'warning', children: 'Pending' },
|
||||
draft: { variant: 'warning', children: 'Draft' },
|
||||
verified: { variant: 'success', children: 'Verified' },
|
||||
unverified: { variant: 'warning', children: 'Unverified' },
|
||||
admin: { variant: 'purple', children: 'Admin' },
|
||||
user: { variant: 'default', children: 'User' }
|
||||
};
|
||||
|
||||
const config = statusConfig[status] || { variant: 'default', children: status };
|
||||
|
||||
return <Badge {...config} {...props} />;
|
||||
};
|
||||
|
||||
export const TypeBadge = ({ type, ...props }) => {
|
||||
const typeConfig = {
|
||||
service: { variant: 'primary', children: 'Service' },
|
||||
physical: { variant: 'orange', children: 'Physical Product' },
|
||||
digital: { variant: 'purple', children: 'Digital Product' },
|
||||
hosting: { variant: 'info', children: 'Hosting' },
|
||||
domain: { variant: 'pink', children: 'Domain' }
|
||||
};
|
||||
|
||||
const config = typeConfig[type] || { variant: 'default', children: type };
|
||||
|
||||
return <Badge {...config} {...props} />;
|
||||
};
|
||||
|
||||
export default Badge;
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
const Breadcrumb = ({ items = [], className = '' }) => {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
className={`inline-flex self-start bg-white dark:bg-neutral-800/30 border border-neutral-200 dark:border-neutral-700/30 rounded-xl overflow-hidden ${className}`}
|
||||
>
|
||||
<ol className="flex items-center">
|
||||
{items.map((item, index) => (
|
||||
<React.Fragment key={item.key ?? index}>
|
||||
{index !== 0 && (
|
||||
<span className="text-neutral-300 dark:text-neutral-600 text-xs select-none px-0.5">
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
<li className="flex items-center">
|
||||
<button
|
||||
onClick={item.onClick}
|
||||
disabled={!item.onClick || item.active}
|
||||
className={[
|
||||
'px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
item.active
|
||||
? 'text-black dark:text-white cursor-default'
|
||||
: item.onClick
|
||||
? 'text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700/20 cursor-pointer'
|
||||
: 'text-neutral-400 cursor-default',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
</li>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Breadcrumb;
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
const Button = ({
|
||||
children,
|
||||
onClick,
|
||||
type = 'button',
|
||||
variant = 'primary',
|
||||
size = 'sm',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
icon,
|
||||
iconPosition = 'left',
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
const baseClassName = 'cursor-pointer inline-flex items-center justify-center font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-1 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-neutral-900 text-white hover:bg-neutral-800 focus:ring-neutral-900/20 dark:bg-white dark:text-black dark:hover:bg-neutral-100 dark:focus:ring-white/20',
|
||||
secondary: 'bg-transparent border border-neutral-300 text-neutral-700 hover:bg-neutral-100 focus:ring-neutral-500/20 dark:bg-neutral-800/60 dark:border-neutral-700/50 dark:text-white dark:hover:bg-neutral-800/80 dark:focus:ring-neutral-600/20 dark:backdrop-blur-sm',
|
||||
danger: 'bg-red-50 border border-red-200 text-red-700 hover:bg-red-100 focus:ring-red-500/20 dark:bg-red-500/20 dark:border-red-500/30 dark:text-red-400 dark:hover:bg-red-500/30 dark:focus:ring-red-500/20',
|
||||
ghost: 'text-neutral-600 hover:text-neutral-900 hover:bg-neutral-100 focus:ring-neutral-500/20 dark:text-neutral-400 dark:hover:text-white dark:hover:bg-neutral-700/30 dark:focus:ring-neutral-600/20',
|
||||
success: 'bg-green-50 border border-green-200 text-green-700 hover:bg-green-100 focus:ring-green-500/20 dark:bg-green-500/20 dark:border-green-500/30 dark:text-green-400 dark:hover:bg-green-500/30 dark:focus:ring-green-500/20',
|
||||
warning: 'bg-yellow-50 border border-yellow-200 text-yellow-700 hover:bg-yellow-100 focus:ring-yellow-500/20 dark:bg-yellow-500/20 dark:border-yellow-500/30 dark:text-yellow-400 dark:hover:bg-yellow-500/30 dark:focus:ring-yellow-500/20'
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-2 text-xs gap-1.5',
|
||||
md: 'px-4 py-2.5 text-sm gap-2',
|
||||
lg: 'px-6 py-3 text-base gap-2.5'
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'w-3.5 h-3.5',
|
||||
md: 'w-4 h-4',
|
||||
lg: 'w-5 h-5'
|
||||
};
|
||||
|
||||
const LoadingSpinner = () => (
|
||||
<div className={`border-2 border-current border-t-transparent rounded-full animate-spin ${iconSizes[size]}`} />
|
||||
);
|
||||
|
||||
const handleClick = (e) => {
|
||||
if (!disabled && !loading && onClick) {
|
||||
onClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
disabled={disabled || loading}
|
||||
className={`${baseClassName} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<>
|
||||
{icon && iconPosition === 'left' && (
|
||||
<span className={iconSizes[size]}>{icon}</span>
|
||||
)}
|
||||
{children}
|
||||
{icon && iconPosition === 'right' && (
|
||||
<span className={iconSizes[size]}>{icon}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
const Card = ({
|
||||
children,
|
||||
title,
|
||||
subtitle,
|
||||
header,
|
||||
footer,
|
||||
variant = 'default',
|
||||
padding = 'md',
|
||||
hover = true,
|
||||
spacing = 'md',
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
const baseClassName = 'border transition-all duration-300';
|
||||
const isLightDark = variant === 'lightDark';
|
||||
|
||||
const variants = {
|
||||
default: 'rounded-xl bg-white dark:bg-neutral-800/30 border-neutral-200 dark:border-neutral-700/30',
|
||||
elevated: 'rounded-xl bg-neutral-50/80 dark:bg-neutral-900/40 border-neutral-200 dark:border-neutral-800/50',
|
||||
outline: 'rounded-xl bg-transparent border-neutral-300 dark:border-neutral-700/50',
|
||||
solid: 'rounded-xl bg-neutral-100 dark:bg-neutral-800 border-neutral-200 dark:border-neutral-700',
|
||||
lightDark: 'rounded-2xl bg-white/80 dark:bg-neutral-900/40 border-neutral-200/80 dark:border-neutral-800/50',
|
||||
success: 'rounded-xl bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-900/50',
|
||||
info: 'rounded-xl bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-900/50',
|
||||
warning: 'rounded-xl bg-yellow-50 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-900/50',
|
||||
danger: 'rounded-xl bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-900/50'
|
||||
};
|
||||
|
||||
const variantsHover = {
|
||||
default: 'hover:bg-neutral-50 dark:hover:bg-neutral-800/40 hover:border-neutral-300 dark:hover:border-neutral-700/50',
|
||||
elevated: 'hover:bg-neutral-100 dark:hover:bg-neutral-900/50 hover:border-neutral-300 dark:hover:border-neutral-700/50',
|
||||
outline: 'hover:border-neutral-400 dark:hover:border-neutral-600/50',
|
||||
solid: 'hover:bg-neutral-200 dark:hover:bg-neutral-700/80',
|
||||
lightDark: 'hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50',
|
||||
success: 'hover:bg-green-100 dark:hover:bg-green-900/40 hover:border-green-300 dark:hover:border-green-900/50',
|
||||
info: 'hover:bg-blue-100 dark:hover:bg-blue-900/40 hover:border-blue-300 dark:hover:border-blue-900/50',
|
||||
warning: 'hover:bg-yellow-100 dark:hover:bg-yellow-900/40 hover:border-yellow-300 dark:hover:border-yellow-900/50',
|
||||
danger: 'hover:bg-red-100 dark:hover:bg-red-900/40 hover:border-red-300 dark:hover:border-red-900/50'
|
||||
};
|
||||
|
||||
const paddings = {
|
||||
none: '',
|
||||
sm: 'p-4',
|
||||
md: 'p-6',
|
||||
lg: 'p-8'
|
||||
};
|
||||
|
||||
const spacings = {
|
||||
none: '',
|
||||
sm: 'flex flex-col gap-2',
|
||||
md: 'flex flex-col gap-4',
|
||||
lg: 'flex flex-col gap-6'
|
||||
};
|
||||
|
||||
const headerBorderClass = 'border-neutral-200 dark:border-neutral-700/30';
|
||||
const footerBorderClass = 'border-neutral-200 dark:border-neutral-700/30';
|
||||
const titleClass = 'text-sm font-medium text-neutral-900 dark:text-white';
|
||||
const subtitleClass = 'text-xs text-neutral-500 dark:text-neutral-400 mt-1';
|
||||
|
||||
const CardHeader = () => {
|
||||
if (header) return header;
|
||||
if (!title && !subtitle) return null;
|
||||
|
||||
return (
|
||||
<div className={`border-b ${headerBorderClass} pb-4 mb-6`}>
|
||||
{title && (
|
||||
<h3 className={titleClass}>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className={subtitleClass}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CardFooter = () => {
|
||||
if (!footer) return null;
|
||||
|
||||
return (
|
||||
<div className={`border-t ${footerBorderClass} pt-4 mt-6`}>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${baseClassName} ${variants[variant] || variants.default} ${hover ? (variantsHover[variant] || variantsHover.default) : ''} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
<div className={paddings[padding]}>
|
||||
<CardHeader />
|
||||
<div className={spacings[spacing]}>
|
||||
{children}
|
||||
</div>
|
||||
<CardFooter />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
const FilterTabs = ({ tabs = [], value, onChange, className = ''}) => {
|
||||
return (
|
||||
<div className={`inline-flex self-start border border-neutral-200 dark:border-neutral-700/30 rounded-xl overflow-hidden ${className}`}>
|
||||
{tabs.map((tab, index) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => onChange(tab.key)}
|
||||
className={[
|
||||
'px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer',
|
||||
index !== 0 ? 'border-l border-neutral-200 dark:border-neutral-700/30' : '',
|
||||
value === tab.key
|
||||
? 'bg-neutral-100 dark:bg-neutral-700/40 text-neutral-900 dark:text-white'
|
||||
: 'bg-white dark:bg-neutral-800/30 text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700/20',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count !== undefined && (
|
||||
<span className={`ml-1.5 text-[10px] ${
|
||||
value === tab.key ? 'text-neutral-500 dark:text-neutral-400' : 'text-neutral-400'
|
||||
}`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterTabs;
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
const Input = ({
|
||||
type = 'text',
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '',
|
||||
label,
|
||||
error,
|
||||
required = false,
|
||||
disabled = false,
|
||||
className = '',
|
||||
description,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
...props
|
||||
}) => {
|
||||
const baseInputClassName = `w-full px-3 py-2.5 rounded-xl text-sm focus:outline-none transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed bg-white border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-1 focus:ring-neutral-500/20 dark:bg-neutral-900/60 dark:border-neutral-700/50 dark:text-white dark:placeholder-neutral-500 dark:focus:border-neutral-600 dark:focus:ring-neutral-600/20 dark:hover:bg-neutral-900/80 ${
|
||||
error ? 'border-red-500/50 dark:border-red-500/50' : ''
|
||||
} ${className}`;
|
||||
|
||||
const handleChange = (e) => {
|
||||
let newValue = e.target.value;
|
||||
|
||||
// Handle number type conversions
|
||||
if (type === 'number') {
|
||||
// Convert empty string to 0 for numeric inputs to prevent database errors
|
||||
if (newValue === '' || newValue === null || newValue === undefined) {
|
||||
newValue = 0;
|
||||
} else {
|
||||
newValue = parseFloat(newValue);
|
||||
// Handle NaN case
|
||||
if (isNaN(newValue)) {
|
||||
newValue = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onChange?.(newValue);
|
||||
};
|
||||
|
||||
// Enhanced color input renderer
|
||||
const renderColorInput = () => {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="color"
|
||||
className="absolute inset-0 w-12 h-10 opacity-0 cursor-pointer"
|
||||
value={value || '#000000'}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
className={`w-12 h-10 border rounded-xl cursor-pointer transition-all duration-200 ${
|
||||
error ? 'border-red-500/50' : 'border-neutral-300 dark:border-neutral-700/50 dark:hover:border-neutral-600'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
style={{ backgroundColor: value || '#000000' }}
|
||||
></div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className={`${baseInputClassName} flex-1 min-w-0`}
|
||||
value={value || ''}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder || 'Enter hex color'}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label className="block text-xs font-medium text-neutral-700 dark:text-white">
|
||||
{label}
|
||||
{required && <span className="text-red-500 dark:text-red-400 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{type === 'color' ? (
|
||||
renderColorInput()
|
||||
) : (
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
className={baseInputClassName}
|
||||
disabled={disabled}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-red-600 dark:text-red-400 text-xs">{error}</p>
|
||||
)}
|
||||
|
||||
{description && !error && (
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 opacity-75">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Input;
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Recycle03Icon } from '../Icons';
|
||||
|
||||
const Loading = ({ size = 'md' }) => {
|
||||
const sizes = {
|
||||
sm: 'w-6 h-6',
|
||||
md: 'w-10 h-10',
|
||||
lg: 'w-16 h-16'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 text-neutral-600 dark:text-neutral-400">
|
||||
<div className="animate-spin">
|
||||
<Recycle03Icon className={sizes[size]} />
|
||||
</div>
|
||||
<p className="text-sm">Loading....</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
// Base Skeleton component
|
||||
export const Skeleton = ({
|
||||
className = "",
|
||||
width = "100%",
|
||||
height = "h-4",
|
||||
animated = true
|
||||
}) => (
|
||||
<div
|
||||
className={`${animated ? 'bg-gradient-to-r from-neutral-200 via-neutral-100 to-neutral-200 dark:from-neutral-700/30 dark:via-neutral-600/30 dark:to-neutral-700/30 bg-[length:200%_100%] animate-shimmer' : 'bg-neutral-200 dark:bg-neutral-700/30'} rounded ${height} ${className}`}
|
||||
style={{ width }}
|
||||
/>
|
||||
);
|
||||
|
||||
// Loading Spinner component
|
||||
export const LoadingSpinner = ({
|
||||
size = 'md',
|
||||
className = '',
|
||||
color = 'white'
|
||||
}) => {
|
||||
const sizes = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-6 h-6',
|
||||
lg: 'w-8 h-8',
|
||||
xl: 'w-12 h-12'
|
||||
};
|
||||
|
||||
const colors = {
|
||||
white: 'border-neutral-300 dark:border-white/20 border-t-neutral-600 dark:border-t-white',
|
||||
neutral: 'border-neutral-300 dark:border-neutral-700/30 border-t-neutral-500 dark:border-t-neutral-400',
|
||||
primary: 'border-blue-500/20 border-t-blue-500'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border-2 rounded-full animate-spin ${sizes[size]} ${colors[color]} ${className}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Full page loading state
|
||||
export const PageLoading = ({ message = 'Loading...' }) => (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<LoadingSpinner size="lg" className="mx-auto mb-4" />
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Table skeleton
|
||||
export const TableSkeleton = ({ rows = 5, columns = 4 }) => (
|
||||
<div className="bg-neutral-100/80 dark:bg-neutral-800/30 border border-neutral-200 dark:border-neutral-700/30 rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-200 dark:border-neutral-700/30">
|
||||
{Array.from({ length: columns }).map((_, index) => (
|
||||
<th key={index} className="px-6 py-4 text-left">
|
||||
<Skeleton height="h-4" width="60%" />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-700/30">
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<tr key={rowIndex} className="hover:bg-neutral-50 dark:hover:bg-neutral-700/20 transition-colors">
|
||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||
<td key={colIndex} className="px-6 py-4 whitespace-nowrap">
|
||||
<Skeleton height="h-4" width={`${60 + Math.random() * 30}%`} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Card skeleton
|
||||
export const CardSkeleton = ({
|
||||
hasHeader = true,
|
||||
lines = 3,
|
||||
hasFooter = false
|
||||
}) => (
|
||||
<div className="bg-neutral-100/80 dark:bg-neutral-800/30 border border-neutral-200 dark:border-neutral-700/30 rounded-xl p-6">
|
||||
{hasHeader && (
|
||||
<div className="border-b border-neutral-200 dark:border-neutral-700/30 pb-4 mb-6">
|
||||
<Skeleton height="h-5" width="40%" className="mb-2" />
|
||||
<Skeleton height="h-3" width="60%" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: lines }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
height="h-4"
|
||||
width={`${70 + Math.random() * 20}%`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasFooter && (
|
||||
<div className="border-t border-neutral-200 dark:border-neutral-700/30 pt-4 mt-6">
|
||||
<Skeleton height="h-8" width="30%" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Form skeleton
|
||||
export const FormSkeleton = ({ fields = 4 }) => (
|
||||
<div className="flex flex-col gap-6">
|
||||
{Array.from({ length: fields }).map((_, index) => (
|
||||
<div key={index} className="space-y-2">
|
||||
<Skeleton height="h-4" width="20%" />
|
||||
<Skeleton height="h-10" width="100%" />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Skeleton height="h-10" width="100px" />
|
||||
<Skeleton height="h-10" width="80px" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Button skeleton
|
||||
export const ButtonSkeleton = ({
|
||||
size = 'md',
|
||||
width = 'auto',
|
||||
className = ''
|
||||
}) => {
|
||||
const heights = {
|
||||
sm: 'h-8',
|
||||
md: 'h-10',
|
||||
lg: 'h-12'
|
||||
};
|
||||
|
||||
const widths = {
|
||||
auto: 'w-24',
|
||||
full: 'w-full',
|
||||
fit: 'w-fit'
|
||||
};
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
height={heights[size]}
|
||||
width={typeof width === 'string' ? widths[width] || width : width}
|
||||
className={`rounded-xl ${className}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// List skeleton
|
||||
export const ListSkeleton = ({ items = 5, showAvatar = false }) => (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: items }).map((_, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
{showAvatar && (
|
||||
<Skeleton height="h-10" width="40px" className="rounded-full" />
|
||||
)}
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton height="h-4" width="60%" />
|
||||
<Skeleton height="h-3" width="40%" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Default export
|
||||
const LoadingState = {
|
||||
Skeleton,
|
||||
LoadingSpinner,
|
||||
PageLoading,
|
||||
TableSkeleton,
|
||||
CardSkeleton,
|
||||
FormSkeleton,
|
||||
ButtonSkeleton,
|
||||
ListSkeleton
|
||||
};
|
||||
|
||||
export default LoadingState;
|
||||
@@ -0,0 +1,245 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
|
||||
const baseInputClassName = 'w-full px-4 py-3 bg-white dark:bg-neutral-900/60 border rounded-xl text-sm text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:border-neutral-500 dark:focus:border-neutral-600 focus:ring-1 focus:ring-neutral-500/20 dark:focus:ring-neutral-600/20 hover:bg-neutral-50 dark:hover:bg-neutral-900/80 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed resize-y min-h-[200px] font-mono ';
|
||||
const toolbarBtnClassName = 'p-2 rounded-lg text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-200 dark:hover:bg-neutral-700/80 transition-colors disabled:opacity-50';
|
||||
|
||||
const UNDO_DEBOUNCE_MS = 600;
|
||||
const MAX_HISTORY = 50;
|
||||
|
||||
/**
|
||||
* Reliable markdown editor: toolbar + textarea only. Single source of truth (value/onChange).
|
||||
* No preview mode to avoid sync bugs and contentEditable issues.
|
||||
*/
|
||||
function MarkdownEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '',
|
||||
label,
|
||||
error,
|
||||
disabled = false,
|
||||
className = '',
|
||||
rows = 12,
|
||||
minHeight = '200px',
|
||||
...props
|
||||
}) {
|
||||
const textareaRef = useRef(null);
|
||||
const [undoStack, setUndoStack] = useState([]);
|
||||
const [redoStack, setRedoStack] = useState([]);
|
||||
const undoDebounceRef = useRef(null);
|
||||
|
||||
const contentAreaClass = 'px-4 py-3 text-sm overflow-auto break-words leading-relaxed';
|
||||
const contentStyle = { minHeight };
|
||||
|
||||
function pushUndo(prev) {
|
||||
if (prev === undefined || prev === null) return;
|
||||
setUndoStack(s => {
|
||||
const next = [...s, prev];
|
||||
return next.length > MAX_HISTORY ? next.slice(1) : next;
|
||||
});
|
||||
setRedoStack([]);
|
||||
}
|
||||
|
||||
function handleUndo() {
|
||||
if (undoStack.length === 0) return;
|
||||
const prev = undoStack[undoStack.length - 1];
|
||||
setRedoStack(r => [...r, value ?? '']);
|
||||
setUndoStack(s => s.slice(0, -1));
|
||||
onChange?.(prev);
|
||||
}
|
||||
|
||||
function handleRedo() {
|
||||
if (redoStack.length === 0) return;
|
||||
const next = redoStack[redoStack.length - 1];
|
||||
setUndoStack(u => [...u, value ?? '']);
|
||||
setRedoStack(r => r.slice(0, -1));
|
||||
onChange?.(next);
|
||||
}
|
||||
|
||||
function applyChange(newText) {
|
||||
pushUndo(value ?? '');
|
||||
onChange?.(newText);
|
||||
}
|
||||
|
||||
function handleTextareaChange(e) {
|
||||
const newText = e.target.value;
|
||||
const prev = value ?? '';
|
||||
if (undoDebounceRef.current) clearTimeout(undoDebounceRef.current);
|
||||
undoDebounceRef.current = setTimeout(() => {
|
||||
pushUndo(prev);
|
||||
undoDebounceRef.current = null;
|
||||
}, UNDO_DEBOUNCE_MS);
|
||||
onChange?.(newText);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (undoDebounceRef.current) clearTimeout(undoDebounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.key === 'z' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) handleRedo();
|
||||
else handleUndo();
|
||||
} else if (e.key === 'y' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleRedo();
|
||||
}
|
||||
}
|
||||
|
||||
function insertOrWrap(before, after = before) {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const text = value ?? '';
|
||||
const selected = text.slice(start, end);
|
||||
|
||||
const isWrapped = selected.length >= before.length + after.length &&
|
||||
selected.startsWith(before) &&
|
||||
selected.endsWith(after);
|
||||
|
||||
let newSelected, newStart, newEnd;
|
||||
if (isWrapped) {
|
||||
newSelected = selected.slice(before.length, selected.length - after.length);
|
||||
newStart = start;
|
||||
newEnd = start + newSelected.length;
|
||||
} else {
|
||||
newSelected = before + selected + after;
|
||||
newStart = start + before.length;
|
||||
newEnd = start + before.length + selected.length;
|
||||
}
|
||||
|
||||
const newText = text.slice(0, start) + newSelected + text.slice(end);
|
||||
applyChange(newText);
|
||||
setTimeout(() => {
|
||||
ta.focus();
|
||||
ta.setSelectionRange(newStart, newEnd);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function insertOrWrapLink() {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const text = value ?? '';
|
||||
const selected = text.slice(start, end);
|
||||
const linkMatch = selected.match(/^\[([^\]]*)\]\([^)]*\)$/);
|
||||
if (linkMatch) {
|
||||
const inner = linkMatch[1];
|
||||
const newText = text.slice(0, start) + inner + text.slice(end);
|
||||
applyChange(newText);
|
||||
setTimeout(() => {
|
||||
ta.focus();
|
||||
ta.setSelectionRange(start, start + inner.length);
|
||||
}, 0);
|
||||
} else {
|
||||
insertOrWrap('[', '](url)');
|
||||
}
|
||||
}
|
||||
|
||||
function insertLinePrefix(prefix) {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
const start = ta.selectionStart;
|
||||
const text = value ?? '';
|
||||
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
||||
const lineEnd = text.indexOf('\n', lineStart);
|
||||
const line = text.slice(lineStart, lineEnd === -1 ? text.length : lineEnd);
|
||||
const hasPrefix = line.startsWith(prefix);
|
||||
|
||||
let newText;
|
||||
let newCursor = start;
|
||||
if (hasPrefix) {
|
||||
newText = text.slice(0, lineStart) + line.slice(prefix.length) + text.slice(lineStart + line.length);
|
||||
newCursor = start <= lineStart + prefix.length ? lineStart : start - prefix.length;
|
||||
} else {
|
||||
newText = text.slice(0, lineStart) + prefix + text.slice(lineStart);
|
||||
newCursor = start + prefix.length;
|
||||
}
|
||||
applyChange(newText);
|
||||
setTimeout(() => {
|
||||
ta.focus();
|
||||
ta.setSelectionRange(newCursor, newCursor);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const toolbar = [
|
||||
{ label: 'B', title: 'Bold', action: () => insertOrWrap('**', '**') },
|
||||
{ label: 'I', title: 'Italic', action: () => insertOrWrap('_', '_') },
|
||||
{ label: 'Link', title: 'Link', action: () => insertOrWrapLink() },
|
||||
{ label: 'Code', title: 'Code', action: () => insertOrWrap('`', '`') },
|
||||
{ label: 'H1', title: 'Heading 1', action: () => insertLinePrefix('# ') },
|
||||
{ label: 'H2', title: 'Heading 2', action: () => insertLinePrefix('## ') },
|
||||
{ label: '•', title: 'Bullet list', action: () => insertLinePrefix('- ') },
|
||||
{ label: '"', title: 'Quote', action: () => insertLinePrefix('> ') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label className="block text-xs font-medium text-neutral-700 dark:text-neutral-400">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className={`border rounded-xl overflow-hidden ${error ? 'border-red-500/50' : 'border-neutral-300 dark:border-neutral-700/50'}`}>
|
||||
<div className="flex flex-wrap items-center gap-0.5 p-1.5 bg-neutral-100 dark:bg-neutral-800/60 border-b border-neutral-200 dark:border-neutral-700/50">
|
||||
<button
|
||||
type="button"
|
||||
title="Undo (Ctrl+Z)"
|
||||
onClick={handleUndo}
|
||||
disabled={disabled || undoStack.length === 0}
|
||||
className={toolbarBtnClassName}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Redo (Ctrl+Shift+Z)"
|
||||
onClick={handleRedo}
|
||||
disabled={disabled || redoStack.length === 0}
|
||||
className={toolbarBtnClassName}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
<span className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-0.5" aria-hidden />
|
||||
{toolbar.map((btn) => (
|
||||
<button
|
||||
key={btn.label}
|
||||
type="button"
|
||||
title={btn.title}
|
||||
onClick={btn.action}
|
||||
disabled={disabled}
|
||||
className={toolbarBtnClassName}
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value ?? ''}
|
||||
onChange={handleTextareaChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
className={baseInputClassName + contentAreaClass + ' ' + (error ? 'border-red-500/50' : 'border-0') + ' ' + className}
|
||||
style={contentStyle}
|
||||
spellCheck="true"
|
||||
data-gramm="false"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-red-600 dark:text-red-400 text-xs">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MarkdownEditor;
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { Dialog } from '@headlessui/react';
|
||||
import { Cancel01Icon } from '../Icons';
|
||||
import Button from './Button';
|
||||
|
||||
const Modal = ({
|
||||
isOpen = true,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
size = 'lg',
|
||||
closable = true
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
full: 'max-w-7xl'
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onClose={closable ? onClose : () => {}}
|
||||
className="relative z-50"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 bg-black/40" aria-hidden="true" />
|
||||
|
||||
{/* Container */}
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<Dialog.Panel
|
||||
className={`
|
||||
w-full ${sizeClasses[size]}
|
||||
bg-white dark:bg-neutral-900
|
||||
border border-neutral-200 dark:border-neutral-800
|
||||
rounded-xl
|
||||
shadow-xl
|
||||
max-h-[90vh]
|
||||
overflow-hidden
|
||||
flex flex-col
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-800">
|
||||
<Dialog.Title className="text-sm font-medium text-neutral-900 dark:text-white">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
{closable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
icon={<Cancel01Icon className="w-4 h-4" />}
|
||||
className="!p-1 -mr-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="px-4 py-3 border-t border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/30">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Button from './Button';
|
||||
import { Skeleton } from './LoadingState';
|
||||
|
||||
const Pagination = ({
|
||||
currentPage = 1,
|
||||
totalPages = 1,
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
limit = 20,
|
||||
total = 0,
|
||||
loading = false,
|
||||
showPerPage = true,
|
||||
showStats = true,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
// Generate page numbers with ellipsis
|
||||
const getPageNumbers = () => {
|
||||
const pages = [];
|
||||
const delta = 2;
|
||||
|
||||
if (totalPages > 0) pages.push(1);
|
||||
if (currentPage - delta > 2) pages.push('...');
|
||||
|
||||
for (let i = Math.max(2, currentPage - delta); i <= Math.min(totalPages - 1, currentPage + delta); i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (currentPage + delta < totalPages - 1) pages.push('...');
|
||||
if (totalPages > 1) pages.push(totalPages);
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
const PaginationButton = ({ onClick, disabled, children, isActive = false }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isActive
|
||||
? 'bg-green-500/20 text-green-600 dark:text-green-400 border border-green-500/30 dark:border-green-500/20'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-700/30 hover:text-neutral-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (totalPages <= 1 && !showPerPage && !loading) return null;
|
||||
|
||||
const from = total > 0 ? (currentPage - 1) * limit + 1 : 0;
|
||||
const to = Math.min(currentPage * limit, total);
|
||||
|
||||
return (
|
||||
<div className={`px-6 py-3 border-t border-neutral-200 dark:border-neutral-700/30 ${className}`} {...props}>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
|
||||
{/* Per Page Selector */}
|
||||
{showPerPage ? (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{loading ? (
|
||||
<Skeleton height="h-7" width="90px" />
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">Afficher</span>
|
||||
<select
|
||||
value={limit}
|
||||
onChange={(e) => onLimitChange?.(Number(e.target.value))}
|
||||
disabled={loading}
|
||||
className="h-7 px-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700/50 rounded-md text-xs text-neutral-900 dark:text-white focus:outline-none focus:border-neutral-400 dark:focus:border-neutral-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : <div />}
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<div className="flex items-center gap-1">
|
||||
{loading ? (
|
||||
<>
|
||||
<Skeleton height="h-7" width="64px" />
|
||||
<div className="hidden sm:flex items-center gap-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} height="h-7" width="28px" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton height="h-7" width="56px" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
Précédent
|
||||
</Button>
|
||||
|
||||
<div className="hidden sm:flex items-center gap-1">
|
||||
{getPageNumbers().map((page, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{page === '...' ? (
|
||||
<span className="px-2 text-xs text-neutral-400 dark:text-neutral-500">…</span>
|
||||
) : (
|
||||
<PaginationButton
|
||||
onClick={() => onPageChange(page)}
|
||||
isActive={currentPage === page}
|
||||
>
|
||||
{page}
|
||||
</PaginationButton>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Suivant
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{showStats ? (
|
||||
<div className="text-xs text-neutral-400 dark:text-neutral-500 shrink-0 text-right">
|
||||
{loading ? (
|
||||
<Skeleton height="h-4" width="100px" />
|
||||
) : (
|
||||
`${from}–${to} sur ${total}`
|
||||
)}
|
||||
</div>
|
||||
) : <div />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pagination;
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Password Strength Indicator Component
|
||||
* Shows password strength and validation requirements
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export default function PasswordStrengthIndicator({ password = '', showRequirements = true }) {
|
||||
const [strength, setStrength] = useState(0);
|
||||
const [requirements, setRequirements] = useState({
|
||||
length: false,
|
||||
maxLength: false,
|
||||
uppercase: false,
|
||||
lowercase: false,
|
||||
number: false
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!password) {
|
||||
setStrength(0);
|
||||
setRequirements({
|
||||
length: false,
|
||||
uppercase: false,
|
||||
lowercase: false,
|
||||
number: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newRequirements = {
|
||||
length: password.length >= 8,
|
||||
maxLength: password.length <= 128,
|
||||
uppercase: /[A-Z]/.test(password),
|
||||
lowercase: /[a-z]/.test(password),
|
||||
number: /\d/.test(password)
|
||||
};
|
||||
|
||||
setRequirements(newRequirements);
|
||||
|
||||
// Calculate strength based on both requirements and length
|
||||
const requirementsMet = Object.values(newRequirements).filter(Boolean).length;
|
||||
let strengthValue = requirementsMet;
|
||||
|
||||
// Adjust strength based on password length
|
||||
if (password.length >= 12) {
|
||||
strengthValue = Math.min(5, strengthValue + 1); // Bonus for long passwords
|
||||
} else if (password.length >= 8 && password.length < 10) {
|
||||
strengthValue = Math.max(1, strengthValue - 1); // Penalty for short passwords
|
||||
} else if (password.length < 8) {
|
||||
strengthValue = Math.max(0, strengthValue - 2); // Big penalty for very short passwords
|
||||
}
|
||||
|
||||
// Ensure strength is between 0 and 5
|
||||
strengthValue = Math.max(0, Math.min(5, strengthValue));
|
||||
|
||||
setStrength(strengthValue);
|
||||
}, [password]);
|
||||
|
||||
const getStrengthColor = () => {
|
||||
switch (strength) {
|
||||
case 0:
|
||||
case 1:
|
||||
return 'bg-red-500';
|
||||
case 2:
|
||||
return 'bg-orange-500';
|
||||
case 3:
|
||||
return 'bg-yellow-500';
|
||||
case 4:
|
||||
return 'bg-blue-500';
|
||||
case 5:
|
||||
return 'bg-green-500';
|
||||
default:
|
||||
return 'bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getStrengthText = () => {
|
||||
switch (strength) {
|
||||
case 0:
|
||||
return 'Très faible';
|
||||
case 1:
|
||||
return 'Faible';
|
||||
case 2:
|
||||
return 'Moyen';
|
||||
case 3:
|
||||
return 'Bon';
|
||||
case 4:
|
||||
return 'Très bon';
|
||||
case 5:
|
||||
return 'Excellent';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getStrengthTextColor = () => {
|
||||
switch (strength) {
|
||||
case 0:
|
||||
case 1:
|
||||
return 'text-red-700 dark:text-red-400';
|
||||
case 2:
|
||||
return 'text-orange-700 dark:text-orange-400';
|
||||
case 3:
|
||||
return 'text-yellow-700 dark:text-yellow-500';
|
||||
case 4:
|
||||
return 'text-blue-700 dark:text-blue-400';
|
||||
case 5:
|
||||
return 'text-green-700 dark:text-green-400';
|
||||
default:
|
||||
return 'text-neutral-600 dark:text-neutral-400';
|
||||
}
|
||||
};
|
||||
|
||||
if (!password && !showRequirements) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-2">
|
||||
{/* Strength Bar */}
|
||||
{password && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-neutral-600 dark:text-neutral-400">Force du mot de passe :</span>
|
||||
<span className={`text-xs font-medium ${getStrengthTextColor()}`}>
|
||||
{getStrengthText()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-1.5">
|
||||
<div
|
||||
className={`h-1.5 rounded-full transition-all duration-300 ${getStrengthColor()}`}
|
||||
style={{ width: `${(strength / 5) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Requirements - Only show if not all requirements are met */}
|
||||
{showRequirements && password && !Object.values(requirements).every(Boolean) && (
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1">
|
||||
{!requirements.length && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-red-500" />
|
||||
<span className="text-xs text-red-700 dark:text-red-400">
|
||||
Au moins 8 caractères
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!requirements.maxLength && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-red-500" />
|
||||
<span className="text-xs text-red-700 dark:text-red-400">
|
||||
Maximum 128 caractères
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!requirements.uppercase && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-red-500" />
|
||||
<span className="text-xs text-red-700 dark:text-red-400">
|
||||
Au moins une majuscule
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!requirements.lowercase && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-red-500" />
|
||||
<span className="text-xs text-red-700 dark:text-red-400">
|
||||
Au moins une minuscule
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!requirements.number && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-red-500" />
|
||||
<span className="text-xs text-red-700 dark:text-red-400">
|
||||
Au moins un chiffre
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
const Select = ({
|
||||
value,
|
||||
onChange,
|
||||
options = [],
|
||||
placeholder = 'Select an option...',
|
||||
label,
|
||||
error,
|
||||
required = false,
|
||||
disabled = false,
|
||||
className = '',
|
||||
description,
|
||||
...props
|
||||
}) => {
|
||||
const baseSelectClassName = `w-full cursor-pointer px-3 py-2.5 rounded-xl text-sm focus:outline-none transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed bg-white border border-neutral-300 text-neutral-900 focus:border-neutral-500 focus:ring-1 focus:ring-neutral-500/20 dark:bg-neutral-900/60 dark:border-neutral-700/50 dark:text-white dark:focus:border-neutral-600 dark:focus:ring-neutral-600/20 dark:hover:bg-neutral-900/80 dark:backdrop-blur-sm ${
|
||||
error ? 'border-red-500/50 dark:border-red-500/50' : ''
|
||||
} ${className}`;
|
||||
|
||||
const handleChange = (e) => {
|
||||
onChange?.(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label className="block text-xs font-medium text-neutral-700 dark:text-white">
|
||||
{label}
|
||||
{required && <span className="text-red-500 dark:text-red-400 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<select
|
||||
value={value ?? ''}
|
||||
onChange={handleChange}
|
||||
className={baseSelectClassName}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{placeholder && (
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{error && (
|
||||
<p className="text-red-600 dark:text-red-400 text-xs">{error}</p>
|
||||
)}
|
||||
|
||||
{description && !error && (
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 opacity-75">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Select;
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Skeleton } from './LoadingState';
|
||||
|
||||
const StatCard = ({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
changeType = 'increase', // 'increase' | 'decrease'
|
||||
icon: Icon,
|
||||
color = 'text-blue-400',
|
||||
bgColor = 'bg-blue-500/10',
|
||||
loading = false,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
const TrendIcon = ({ type }) => (
|
||||
<svg className="h-3 w-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d={type === 'increase'
|
||||
? "M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
|
||||
: "M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"
|
||||
}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group bg-white dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800/50 rounded-2xl p-4 sm:p-6 hover:bg-neutral-50 dark:hover:bg-neutral-900/50 hover:border-neutral-300 dark:hover:border-neutral-700/50 transition-all duration-300 hover:transform hover:-translate-y-1 ${className}`}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-2 sm:mb-3 truncate">
|
||||
{title}
|
||||
</p>
|
||||
|
||||
<div className="text-xl sm:text-2xl font-bold text-neutral-900 dark:text-white mb-1 sm:mb-2 truncate">
|
||||
{loading ? (
|
||||
<Skeleton height="h-6" width="60%" />
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</div>
|
||||
|
||||
{change && !loading && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`flex items-center space-x-1 ${
|
||||
changeType === 'increase'
|
||||
? 'text-green-400'
|
||||
: 'text-red-400'
|
||||
}`}>
|
||||
<TrendIcon type={changeType} />
|
||||
<span className="text-xs font-medium">{change}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && change && (
|
||||
<Skeleton height="h-3" width="40%" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`${bgColor} ${color} p-2.5 sm:p-3 rounded-xl flex-shrink-0 ml-3`}>
|
||||
{loading ? (
|
||||
<Skeleton height="h-5 sm:h-6" width="w-5 sm:w-6" />
|
||||
) : (
|
||||
Icon && <Icon className="h-5 w-5 sm:h-6 sm:w-6" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatCard;
|
||||
@@ -0,0 +1,254 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Badge from './Badge';
|
||||
import { TorriGateIcon } from '../Icons';
|
||||
|
||||
const ROW_SIZE = {
|
||||
sm: { cell: 'px-4 py-3', header: 'px-6 py-4', mobile: 'p-4' },
|
||||
md: { cell: 'px-6 py-4', header: 'px-6 py-4', mobile: 'p-6' },
|
||||
lg: { cell: 'px-6 py-5', header: 'px-6 py-5', mobile: 'p-8' },
|
||||
};
|
||||
|
||||
const Table = ({
|
||||
columns = [],
|
||||
data = [],
|
||||
loading = false,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onSort,
|
||||
onRowClick,
|
||||
getRowProps,
|
||||
emptyMessage = 'Aucune donnée',
|
||||
emptyDescription = 'Aucun élément à afficher',
|
||||
size = 'md',
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
const sizeClasses = ROW_SIZE[size] ?? ROW_SIZE.md;
|
||||
const SortIcon = ({ column }) => {
|
||||
const isActive = sortBy === column.key;
|
||||
const isDesc = isActive && sortOrder === 'desc';
|
||||
|
||||
return (
|
||||
<span className="ml-1">
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${isActive ? (isDesc ? 'rotate-180' : '') : 'text-neutral-500 dark:text-neutral-400'}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const Skeleton = ({ className = "", width = "100%", height = "h-4" }) => (
|
||||
<div
|
||||
className={`bg-gradient-to-r from-neutral-200 via-neutral-100 to-neutral-200 dark:from-neutral-700/30 dark:via-neutral-600/30 dark:to-neutral-700/30 bg-[length:200%_100%] animate-shimmer rounded ${height} ${className}`}
|
||||
style={{ width }}
|
||||
/>
|
||||
);
|
||||
|
||||
const SkeletonRow = () => (
|
||||
<tr className="hover:bg-neutral-100 dark:hover:bg-neutral-700/20 transition-colors">
|
||||
{columns.map((column, index) => (
|
||||
<td key={index} className={`${sizeClasses.cell} ${column.noWrap !== false ? '' : 'whitespace-nowrap'}`}>
|
||||
{column.skeleton ? (
|
||||
column.skeleton.secondary ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton
|
||||
className={column.skeleton.className}
|
||||
height={column.skeleton.height}
|
||||
width={column.skeleton.width}
|
||||
/>
|
||||
<Skeleton
|
||||
height={column.skeleton.secondary.height}
|
||||
width={column.skeleton.secondary.width}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton
|
||||
className={column.skeleton.className}
|
||||
height={column.skeleton.height}
|
||||
width={column.skeleton.width}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Skeleton height="h-4" width="60%" />
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
|
||||
const EmptyState = () => (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-6 py-12 text-center">
|
||||
<div className="text-neutral-500 dark:text-neutral-400 mb-2">
|
||||
<TorriGateIcon className="mx-auto h-12 w-12 mb-4" />
|
||||
</div>
|
||||
<p className="text-lg font-medium text-neutral-700 dark:text-neutral-300 mb-1">{emptyMessage}</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">{emptyDescription}</p>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
const renderCellContent = (item, column) => {
|
||||
if (column.render) {
|
||||
return column.render(item);
|
||||
}
|
||||
|
||||
const value = column.key.split('.').reduce((obj, key) => obj?.[key], item);
|
||||
|
||||
if (column.type === 'badge') {
|
||||
return <Badge variant={column.badgeVariant}>{value}</Badge>;
|
||||
}
|
||||
|
||||
if (column.type === 'date') {
|
||||
return new Date(value).toLocaleDateString();
|
||||
}
|
||||
|
||||
if (column.type === 'currency') {
|
||||
return `$${parseFloat(value || 0).toFixed(2)}`;
|
||||
}
|
||||
|
||||
return value || '-';
|
||||
};
|
||||
|
||||
const MobileCard = ({ item }) => (
|
||||
<div className={`${sizeClasses.mobile} space-y-3`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
{columns.slice(0, 2).map((column) => (
|
||||
<div key={column.key} className={column.key === columns[0]?.key ? 'mb-2' : ''}>
|
||||
{renderCellContent(item, column)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 ml-4">
|
||||
{columns.slice(2, 4).map((column) => (
|
||||
<div key={column.key}>
|
||||
{renderCellContent(item, column)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{columns.length > 4 && (
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{columns.slice(4).map((column) => (
|
||||
<div key={column.key} className="mb-1">
|
||||
<span className="font-medium">{column.label}:</span> {renderCellContent(item, column)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const MobileSkeletonCard = () => (
|
||||
<div className={`${sizeClasses.mobile} space-y-3`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton height="h-4" width="70%" />
|
||||
<Skeleton height="h-3" width="40%" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 ml-4">
|
||||
<Skeleton className="rounded-full" height="h-6" width="80px" />
|
||||
<Skeleton className="rounded-full" height="h-6" width="90px" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton height="h-3" width="50%" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className} {...props}>
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden lg:block overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-200 dark:border-neutral-700/30">
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={`${sizeClasses.header} ${column.headerAlign === 'right' ? 'text-right' : 'text-left'} text-xs font-medium text-neutral-600 dark:text-neutral-300 uppercase tracking-wider ${
|
||||
column.sortable ? 'cursor-pointer hover:text-neutral-900 dark:hover:text-white transition-colors' : ''
|
||||
}`}
|
||||
onClick={column.sortable && onSort ? () => onSort(column.key) : undefined}
|
||||
>
|
||||
<div className={`flex items-center ${column.headerAlign === 'right' ? 'justify-end' : ''}`}>
|
||||
{column.label}
|
||||
{column.sortable && onSort && <SortIcon column={column} />}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-700/30">
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }).map((_, index) => (
|
||||
<SkeletonRow key={index} />
|
||||
))
|
||||
) : data.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
data.map((item, index) => {
|
||||
const { className: extraClassName, ...rowExtraProps } = getRowProps ? getRowProps(item) : {};
|
||||
return (
|
||||
<tr
|
||||
key={item.id || index}
|
||||
className={`hover:bg-neutral-100 dark:hover:bg-neutral-700/20 transition-colors ${onRowClick ? 'cursor-pointer' : ''} ${extraClassName || ''}`}
|
||||
onClick={onRowClick ? () => onRowClick(item) : undefined}
|
||||
{...rowExtraProps}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} className={`${sizeClasses.cell} ${column.noWrap !== false ? '' : 'whitespace-nowrap'}`}>
|
||||
{renderCellContent(item, column)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards */}
|
||||
<div className="lg:hidden divide-y divide-neutral-200 dark:divide-neutral-700/30">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, index) => (
|
||||
<MobileSkeletonCard key={index} />
|
||||
))
|
||||
) : data.length === 0 ? (
|
||||
<div className={`${sizeClasses.mobile} py-12 text-center`}>
|
||||
<div className="text-neutral-500 dark:text-neutral-400 mb-2">
|
||||
<svg className="mx-auto h-12 w-12 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2M4 13h2m13-8V4a1 1 0 00-1-1H7a1 1 0 00-1 1v1m8 0V4.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-lg font-medium text-neutral-700 dark:text-neutral-300 mb-1">{emptyMessage}</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">{emptyDescription}</p>
|
||||
</div>
|
||||
) : (
|
||||
data.map((item, index) => {
|
||||
const { className: extraClassName, ...rowExtraProps } = getRowProps ? getRowProps(item) : {};
|
||||
return (
|
||||
<div
|
||||
key={item.id || index}
|
||||
className={`${onRowClick ? 'cursor-pointer' : ''} ${extraClassName || ''}`}
|
||||
onClick={onRowClick ? () => onRowClick(item) : undefined}
|
||||
{...rowExtraProps}
|
||||
>
|
||||
<MobileCard item={item} />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Table;
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
const Textarea = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '',
|
||||
label,
|
||||
error,
|
||||
required = false,
|
||||
disabled = false,
|
||||
className = '',
|
||||
description,
|
||||
rows = 4,
|
||||
...props
|
||||
}) => {
|
||||
const baseTextareaClassName = `w-full px-3 py-2.5 bg-white dark:bg-neutral-900/60 border rounded-xl text-sm text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:border-neutral-500 dark:focus:border-neutral-600 focus:ring-1 focus:ring-neutral-500/20 dark:focus:ring-neutral-600/20 hover:bg-neutral-50 dark:hover:bg-neutral-900/80 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed resize-y ${
|
||||
error ? 'border-red-500/50' : 'border-neutral-300 dark:border-neutral-700/50'
|
||||
} ${className}`;
|
||||
|
||||
const handleChange = (e) => {
|
||||
onChange?.(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label className="block text-xs font-medium text-neutral-700 dark:text-neutral-400">
|
||||
{label}
|
||||
{required && <span className="text-red-600 dark:text-red-400 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
className={baseTextareaClassName}
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-red-600 dark:text-red-400 text-xs">{error}</p>
|
||||
)}
|
||||
|
||||
{description && !error && (
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 opacity-75">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Textarea;
|
||||
@@ -0,0 +1,27 @@
|
||||
// Template components exports
|
||||
export { default as Badge, StatusBadge, TypeBadge } from './Badge';
|
||||
export { default as Button } from './Button';
|
||||
export { default as Card } from './Card';
|
||||
export { default as Input } from './Input';
|
||||
export {
|
||||
default as LoadingState,
|
||||
Skeleton,
|
||||
LoadingSpinner,
|
||||
PageLoading,
|
||||
TableSkeleton,
|
||||
CardSkeleton,
|
||||
FormSkeleton,
|
||||
ButtonSkeleton,
|
||||
ListSkeleton
|
||||
} from './LoadingState';
|
||||
export { default as Loading } from './Loading';
|
||||
export { default as Modal } from './Modal';
|
||||
export { default as Pagination } from './Pagination';
|
||||
export { default as Select } from './Select';
|
||||
export { default as StatCard } from './StatCard';
|
||||
export { default as Table } from './Table';
|
||||
export { default as Textarea } from './Textarea';
|
||||
export { default as MarkdownEditor } from './MarkdownEditor';
|
||||
export { default as PasswordStrengthIndicator } from './PasswordStrengthIndicator';
|
||||
export { default as FilterTabs } from './FilterTabs';
|
||||
export { default as Breadcrumb } from './Breadcrumb';
|
||||
Reference in New Issue
Block a user