This commit is contained in:
2026-03-16 09:59:56 -04:00
parent ecf77fd105
commit 5cb17bace7
34 changed files with 4104 additions and 153 deletions

View File

@@ -0,0 +1,208 @@
# UI Components
This directory contains reusable UI components for the AnthoLume application.
## Toast Notifications
### Usage
The toast system provides info, warning, and error notifications that respect the current theme and dark/light mode.
```tsx
import { useToasts } from './components/ToastContext';
function MyComponent() {
const { showInfo, showWarning, showError, showToast } = useToasts();
const handleAction = async () => {
try {
// Do something
showInfo('Operation completed successfully!');
} catch (error) {
showError('An error occurred while processing your request.');
}
};
return <button onClick={handleAction}>Click me</button>;
}
```
### API
- `showToast(message: string, type?: 'info' | 'warning' | 'error', duration?: number): string`
- Shows a toast notification
- Returns the toast ID for manual removal
- Default type: 'info'
- Default duration: 5000ms (0 = no auto-dismiss)
- `showInfo(message: string, duration?: number): string`
- Shortcut for showing an info toast
- `showWarning(message: string, duration?: number): string`
- Shortcut for showing a warning toast
- `showError(message: string, duration?: number): string`
- Shortcut for showing an error toast
- `removeToast(id: string): void`
- Manually remove a toast by ID
- `clearToasts(): void`
- Clear all active toasts
### Examples
```tsx
// Info toast (auto-dismisses after 5 seconds)
showInfo('Document saved successfully!');
// Warning toast (auto-dismisses after 10 seconds)
showWarning('Low disk space warning', 10000);
// Error toast (no auto-dismiss)
showError('Failed to load data', 0);
// Generic toast
showToast('Custom message', 'warning', 3000);
```
## Skeleton Loading
### Usage
Skeleton components provide placeholder content while data is loading. They automatically adapt to dark/light mode.
### Components
#### `Skeleton`
Basic skeleton element with various variants:
```tsx
import { Skeleton } from './components/Skeleton';
// Default (rounded rectangle)
<Skeleton className="w-full h-8" />
// Text variant
<Skeleton variant="text" className="w-3/4" />
// Circular variant (for avatars)
<Skeleton variant="circular" width={40} height={40} />
// Rectangular variant
<Skeleton variant="rectangular" width="100%" height={200} />
```
#### `SkeletonText`
Multiple lines of text skeleton:
```tsx
<SkeletonText lines={3} />
<SkeletonText lines={5} className="max-w-md" />
```
#### `SkeletonAvatar`
Avatar placeholder:
```tsx
<SkeletonAvatar size="md" />
<SkeletonAvatar size={56} />
```
#### `SkeletonCard`
Card placeholder with optional elements:
```tsx
// Default card
<SkeletonCard />
// With avatar
<SkeletonCard showAvatar />
// Custom configuration
<SkeletonCard
showAvatar
showTitle
showText
textLines={4}
className="max-w-sm"
/>
```
#### `SkeletonTable`
Table placeholder:
```tsx
<SkeletonTable rows={5} columns={4} />
<SkeletonTable rows={10} columns={6} showHeader={false} />
```
#### `SkeletonButton`
Button placeholder:
```tsx
<SkeletonButton width={120} />
<SkeletonButton className="w-full" />
```
#### `PageLoader`
Full-page loading indicator:
```tsx
<PageLoader message="Loading your documents..." />
```
#### `InlineLoader`
Small inline loading spinner:
```tsx
<InlineLoader size="sm" />
<InlineLoader size="md" />
<InlineLoader size="lg" />
```
## Integration with Table Component
The Table component now supports skeleton loading:
```tsx
import { Table, SkeletonTable } from './components/Table';
function DocumentList() {
const { data, isLoading } = useGetDocuments();
if (isLoading) {
return <SkeletonTable rows={10} columns={5} />;
}
return (
<Table
columns={columns}
data={data?.documents || []}
/>
);
}
```
## Theme Support
All components automatically adapt to the current theme:
- **Light mode**: Uses gray tones for skeletons, appropriate colors for toasts
- **Dark mode**: Uses darker gray tones for skeletons, adjusted colors for toasts
The theme is controlled via Tailwind's `dark:` classes, which respond to the system preference or manual theme toggles.
## Dependencies
- `clsx` - Utility for constructing className strings
- `tailwind-merge` - Merges Tailwind CSS classes intelligently
- `lucide-react` - Icon library used by Toast component

View File

@@ -0,0 +1,230 @@
import { cn } from '../utils/cn';
interface SkeletonProps {
className?: string;
variant?: 'default' | 'text' | 'circular' | 'rectangular';
width?: string | number;
height?: string | number;
animation?: 'pulse' | 'wave' | 'none';
}
export function Skeleton({
className = '',
variant = 'default',
width,
height,
animation = 'pulse',
}: SkeletonProps) {
const baseClasses = 'bg-gray-200 dark:bg-gray-600';
const variantClasses = {
default: 'rounded',
text: 'rounded-md h-4',
circular: 'rounded-full',
rectangular: 'rounded-none',
};
const animationClasses = {
pulse: 'animate-pulse',
wave: 'animate-wave',
none: '',
};
const style = {
width: width !== undefined ? (typeof width === 'number' ? `${width}px` : width) : undefined,
height: height !== undefined ? (typeof height === 'number' ? `${height}px` : height) : undefined,
};
return (
<div
className={cn(
baseClasses,
variantClasses[variant],
animationClasses[animation],
className
)}
style={style}
/>
);
}
interface SkeletonTextProps {
lines?: number;
className?: string;
lineClassName?: string;
}
export function SkeletonText({ lines = 3, className = '', lineClassName = '' }: SkeletonTextProps) {
return (
<div className={cn('space-y-2', className)}>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
variant="text"
className={cn(
lineClassName,
i === lines - 1 && lines > 1 ? 'w-3/4' : 'w-full'
)}
/>
))}
</div>
);
}
interface SkeletonAvatarProps {
size?: number | 'sm' | 'md' | 'lg';
className?: string;
}
export function SkeletonAvatar({ size = 'md', className = '' }: SkeletonAvatarProps) {
const sizeMap = {
sm: 32,
md: 40,
lg: 56,
};
const pixelSize = typeof size === 'number' ? size : sizeMap[size];
return (
<Skeleton
variant="circular"
width={pixelSize}
height={pixelSize}
className={className}
/>
);
}
interface SkeletonCardProps {
className?: string;
showAvatar?: boolean;
showTitle?: boolean;
showText?: boolean;
textLines?: number;
}
export function SkeletonCard({
className = '',
showAvatar = false,
showTitle = true,
showText = true,
textLines = 3,
}: SkeletonCardProps) {
return (
<div className={cn('bg-white dark:bg-gray-700 rounded-lg p-4 border dark:border-gray-600', className)}>
{showAvatar && (
<div className="flex items-start gap-4 mb-4">
<SkeletonAvatar />
<div className="flex-1">
<Skeleton variant="text" className="w-3/4 mb-2" />
<Skeleton variant="text" className="w-1/2" />
</div>
</div>
)}
{showTitle && (
<Skeleton variant="text" className="w-1/2 mb-4 h-6" />
)}
{showText && (
<SkeletonText lines={textLines} />
)}
</div>
);
}
interface SkeletonTableProps {
rows?: number;
columns?: number;
className?: string;
showHeader?: boolean;
}
export function SkeletonTable({
rows = 5,
columns = 4,
className = '',
showHeader = true,
}: SkeletonTableProps) {
return (
<div className={cn('bg-white dark:bg-gray-700 rounded-lg overflow-hidden', className)}>
<table className="min-w-full">
{showHeader && (
<thead>
<tr className="border-b dark:border-gray-600">
{Array.from({ length: columns }).map((_, i) => (
<th key={i} className="p-3">
<Skeleton variant="text" className="w-3/4 h-5" />
</th>
))}
</tr>
</thead>
)}
<tbody>
{Array.from({ length: rows }).map((_, rowIndex) => (
<tr key={rowIndex} className="border-b dark:border-gray-600 last:border-0">
{Array.from({ length: columns }).map((_, colIndex) => (
<td key={colIndex} className="p-3">
<Skeleton variant="text" className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
interface SkeletonButtonProps {
className?: string;
width?: string | number;
}
export function SkeletonButton({ className = '', width }: SkeletonButtonProps) {
return (
<Skeleton
variant="rectangular"
height={36}
width={width || '100%'}
className={cn('rounded', className)}
/>
);
}
interface PageLoaderProps {
message?: string;
className?: string;
}
export function PageLoader({ message = 'Loading...', className = '' }: PageLoaderProps) {
return (
<div className={cn('flex flex-col items-center justify-center min-h-[400px] gap-4', className)}>
<div className="relative">
<div className="w-12 h-12 border-4 border-gray-200 dark:border-gray-600 border-t-blue-500 rounded-full animate-spin" />
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm font-medium">{message}</p>
</div>
);
}
interface InlineLoaderProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function InlineLoader({ size = 'md', className = '' }: InlineLoaderProps) {
const sizeMap = {
sm: 'w-4 h-4 border-2',
md: 'w-6 h-6 border-3',
lg: 'w-8 h-8 border-4',
};
return (
<div className={cn('flex items-center justify-center', className)}>
<div className={`${sizeMap[size]} border-gray-200 dark:border-gray-600 border-t-blue-500 rounded-full animate-spin`} />
</div>
);
}
// Re-export SkeletonTable for backward compatibility
export { SkeletonTable as SkeletonTableExport };

View File

@@ -1,4 +1,6 @@
import React from 'react';
import { Skeleton } from './Skeleton';
import { cn } from '../utils/cn';
export interface Column<T> {
key: keyof T;
@@ -32,9 +34,39 @@ export function Table<T extends Record<string, any>>({
return `row-${index}`;
};
// Skeleton table component for loading state
function SkeletonTable({ rows = 5, columns = 4, className = '' }: { rows?: number; columns?: number; className?: string }) {
return (
<div className={cn('bg-white dark:bg-gray-700 rounded-lg overflow-hidden', className)}>
<table className="min-w-full">
<thead>
<tr className="border-b dark:border-gray-600">
{Array.from({ length: columns }).map((_, i) => (
<th key={i} className="p-3">
<Skeleton variant="text" className="w-3/4 h-5" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, rowIndex) => (
<tr key={rowIndex} className="border-b dark:border-gray-600 last:border-0">
{Array.from({ length: columns }).map((_, colIndex) => (
<td key={colIndex} className="p-3">
<Skeleton variant="text" className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
if (loading) {
return (
<div className="text-gray-500 dark:text-white p-4">Loading...</div>
<SkeletonTable rows={5} columns={columns.length} />
);
}

View File

@@ -0,0 +1,86 @@
import { useEffect, useState } from 'react';
import { Info, AlertTriangle, XCircle, X } from 'lucide-react';
export type ToastType = 'info' | 'warning' | 'error';
export interface ToastProps {
id: string;
type: ToastType;
message: string;
duration?: number;
onClose?: (id: string) => void;
}
const getToastStyles = (type: ToastType) => {
const baseStyles = 'flex items-center gap-3 p-4 rounded-lg shadow-lg border-l-4 transition-all duration-300';
const typeStyles = {
info: 'bg-blue-50 dark:bg-blue-900/30 border-blue-500 dark:border-blue-400',
warning: 'bg-yellow-50 dark:bg-yellow-900/30 border-yellow-500 dark:border-yellow-400',
error: 'bg-red-50 dark:bg-red-900/30 border-red-500 dark:border-red-400',
};
const iconStyles = {
info: 'text-blue-600 dark:text-blue-400',
warning: 'text-yellow-600 dark:text-yellow-400',
error: 'text-red-600 dark:text-red-400',
};
const textStyles = {
info: 'text-blue-800 dark:text-blue-200',
warning: 'text-yellow-800 dark:text-yellow-200',
error: 'text-red-800 dark:text-red-200',
};
return { baseStyles, typeStyles, iconStyles, textStyles };
};
export function Toast({ id, type, message, duration = 5000, onClose }: ToastProps) {
const [isVisible, setIsVisible] = useState(true);
const [isAnimatingOut, setIsAnimatingOut] = useState(false);
const { baseStyles, typeStyles, iconStyles, textStyles } = getToastStyles(type);
const handleClose = () => {
setIsAnimatingOut(true);
setTimeout(() => {
setIsVisible(false);
onClose?.(id);
}, 300);
};
useEffect(() => {
if (duration > 0) {
const timer = setTimeout(handleClose, duration);
return () => clearTimeout(timer);
}
}, [duration]);
if (!isVisible) {
return null;
}
const icons = {
info: <Info size={20} className={iconStyles[type]} />,
warning: <AlertTriangle size={20} className={iconStyles[type]} />,
error: <XCircle size={20} className={iconStyles[type]} />,
};
return (
<div
className={`${baseStyles} ${typeStyles[type]} ${isAnimatingOut ? 'opacity-0 translate-x-full' : 'opacity-100 translate-x-0'}`}
>
{icons[type]}
<p className={`flex-1 text-sm font-medium ${textStyles[type]}`}>
{message}
</p>
<button
onClick={handleClose}
className={`ml-2 opacity-70 hover:opacity-100 transition-opacity ${textStyles[type]}`}
aria-label="Close"
>
<X size={18} />
</button>
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import { Toast, ToastType, ToastProps } from './Toast';
interface ToastContextType {
showToast: (message: string, type?: ToastType, duration?: number) => string;
showInfo: (message: string, duration?: number) => string;
showWarning: (message: string, duration?: number) => string;
showError: (message: string, duration?: number) => string;
removeToast: (id: string) => void;
clearToasts: () => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<(ToastProps & { id: string })[]>([]);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const showToast = useCallback((message: string, type: ToastType = 'info', duration?: number): string => {
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
setToasts((prev) => [...prev, { id, type, message, duration, onClose: removeToast }]);
return id;
}, [removeToast]);
const showInfo = useCallback((message: string, duration?: number) => {
return showToast(message, 'info', duration);
}, [showToast]);
const showWarning = useCallback((message: string, duration?: number) => {
return showToast(message, 'warning', duration);
}, [showToast]);
const showError = useCallback((message: string, duration?: number) => {
return showToast(message, 'error', duration);
}, [showToast]);
const clearToasts = useCallback(() => {
setToasts([]);
}, []);
return (
<ToastContext.Provider value={{ showToast, showInfo, showWarning, showError, removeToast, clearToasts }}>
{children}
<ToastContainer toasts={toasts} />
</ToastContext.Provider>
);
}
interface ToastContainerProps {
toasts: (ToastProps & { id: string })[];
}
function ToastContainer({ toasts }: ToastContainerProps) {
if (toasts.length === 0) {
return null;
}
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
<div className="pointer-events-auto">
{toasts.map((toast) => (
<Toast key={toast.id} {...toast} />
))}
</div>
</div>
);
}
export function useToasts() {
const context = useContext(ToastContext);
if (context === undefined) {
throw new Error('useToasts must be used within a ToastProvider');
}
return context;
}

View File

@@ -0,0 +1,16 @@
// Toast components
export { Toast } from './Toast';
export { ToastProvider, useToasts } from './ToastContext';
export type { ToastType, ToastProps } from './Toast';
// Skeleton components
export {
Skeleton,
SkeletonText,
SkeletonAvatar,
SkeletonCard,
SkeletonTable,
SkeletonButton,
PageLoader,
InlineLoader
} from './Skeleton';