chore: import codes

This commit is contained in:
2026-04-12 12:50:14 -04:00
parent 4bcb4898e8
commit 65ae3c6788
241 changed files with 48834 additions and 1 deletions
+74
View File
@@ -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;
+42
View File
@@ -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;
+76
View File
@@ -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;
+110
View File
@@ -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;
+34
View File
@@ -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;
+115
View File
@@ -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;
+24
View File
@@ -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;
+188
View File
@@ -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;
+245
View File
@@ -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;
+79
View File
@@ -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;
+152
View File
@@ -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>
);
}
+65
View File
@@ -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;
+81
View File
@@ -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;
+254
View File
@@ -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;
+56
View File
@@ -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;
+27
View File
@@ -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';