initial: standalone repo from monorepo split
This commit is contained in:
178
src/components/ui/action-icon.tsx
Normal file
178
src/components/ui/action-icon.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import { ButtonHTMLAttributes, useState, useRef, useEffect } from 'react';
|
||||
|
||||
export type IconName =
|
||||
| 'eye'
|
||||
| 'credit-card'
|
||||
| 'x-circle'
|
||||
| 'check'
|
||||
| 'x'
|
||||
| 'edit'
|
||||
| 'copy'
|
||||
| 'pause'
|
||||
| 'play'
|
||||
| 'user-x'
|
||||
| 'user-check'
|
||||
| 'external-link'
|
||||
| 'more'
|
||||
| 'trash';
|
||||
|
||||
interface ActionIconProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'> {
|
||||
icon: IconName;
|
||||
variant?: 'primary' | 'ghost' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
label: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
const ICONS: Record<IconName, (size: number) => React.ReactNode> = {
|
||||
eye: (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8s-2.5 4.5-6.5 4.5S1.5 8 1.5 8z" />
|
||||
<circle cx="8" cy="8" r="2" />
|
||||
</svg>
|
||||
),
|
||||
'credit-card': (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="1.5" y="3" width="13" height="10" rx="1.5" />
|
||||
<path d="M1.5 6.5h13M4.5 10h2" />
|
||||
</svg>
|
||||
),
|
||||
'x-circle': (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="8" cy="8" r="6" />
|
||||
<path d="M10 6l-4 4M6 6l4 4" />
|
||||
</svg>
|
||||
),
|
||||
check: (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 8.5l3.5 3.5 6.5-7" />
|
||||
</svg>
|
||||
),
|
||||
x: (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
),
|
||||
edit: (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M11.5 2.5l2 2-8.5 8.5H3v-2l8.5-8.5z" />
|
||||
</svg>
|
||||
),
|
||||
copy: (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="5" y="5" width="8" height="8" rx="1" />
|
||||
<path d="M3 11V3.5A1.5 1.5 0 014.5 2H11" />
|
||||
</svg>
|
||||
),
|
||||
pause: (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="4" y="3" width="2.5" height="10" rx="0.5" />
|
||||
<rect x="9.5" y="3" width="2.5" height="10" rx="0.5" />
|
||||
</svg>
|
||||
),
|
||||
play: (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4.5 2.5l9 5.5-9 5.5V2.5z" />
|
||||
</svg>
|
||||
),
|
||||
'user-x': (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="6.5" cy="5" r="2.5" />
|
||||
<path d="M2 14c0-2.5 2-4.5 4.5-4.5S11 11.5 11 14" />
|
||||
<path d="M12 5l3 3M15 5l-3 3" />
|
||||
</svg>
|
||||
),
|
||||
'user-check': (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="6.5" cy="5" r="2.5" />
|
||||
<path d="M2 14c0-2.5 2-4.5 4.5-4.5S11 11.5 11 14" />
|
||||
<path d="M12 6l2 2 3.5-3.5" />
|
||||
</svg>
|
||||
),
|
||||
'external-link': (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 2h6v6M14 2L7 9" />
|
||||
<path d="M6 3H3v10h10v-3" />
|
||||
</svg>
|
||||
),
|
||||
more: (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 16 16" fill="currentColor">
|
||||
<circle cx="8" cy="3.5" r="1.5" />
|
||||
<circle cx="8" cy="8" r="1.5" />
|
||||
<circle cx="8" cy="12.5" r="1.5" />
|
||||
</svg>
|
||||
),
|
||||
trash: (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 4.5h10M6.5 4.5V3a1 1 0 011-1h1a1 1 0 011 1v1.5M5 4.5l.5 8.5h5l.5-8.5" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
const VARIANT_STYLES: Record<string, string> = {
|
||||
primary:
|
||||
'text-primary-600 hover:bg-primary-50 hover:text-primary-700 focus-visible:ring-primary-500/30',
|
||||
ghost:
|
||||
'text-surface-400 hover:bg-surface-100 hover:text-surface-700 focus-visible:ring-surface-500/30',
|
||||
danger:
|
||||
'text-surface-400 hover:bg-red-50 hover:text-red-600 focus-visible:ring-red-500/30',
|
||||
};
|
||||
|
||||
const SIZE_MAP = { sm: 32, md: 36, lg: 40 } as const;
|
||||
const ICON_SIZE_MAP = { sm: 15, md: 16, lg: 18 } as const;
|
||||
|
||||
export function ActionIcon({
|
||||
icon,
|
||||
variant = 'ghost',
|
||||
size = 'sm',
|
||||
label,
|
||||
onClick,
|
||||
className = '',
|
||||
disabled,
|
||||
...rest
|
||||
}: ActionIconProps) {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const btnSize = SIZE_MAP[size];
|
||||
const iconSize = ICON_SIZE_MAP[size];
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`relative inline-flex items-center justify-center rounded-lg transition-all duration-150 cursor-pointer focus:outline-none focus-visible:ring-2 disabled:opacity-40 disabled:cursor-not-allowed ${VARIANT_STYLES[variant]} ${className}`}
|
||||
style={{ width: btnSize, height: btnSize }}
|
||||
onMouseEnter={() => setShowTooltip(true)}
|
||||
onMouseLeave={() => {
|
||||
timeoutRef.current = setTimeout(() => setShowTooltip(false), 100);
|
||||
}}
|
||||
onFocus={() => setShowTooltip(true)}
|
||||
onBlur={() => setShowTooltip(false)}
|
||||
{...rest}
|
||||
>
|
||||
{ICONS[icon](iconSize)}
|
||||
{showTooltip && (
|
||||
<span
|
||||
role="tooltip"
|
||||
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 px-2 py-1 text-[11px] font-medium text-white bg-surface-800 rounded-md whitespace-nowrap pointer-events-none z-50 shadow-sm"
|
||||
>
|
||||
{label}
|
||||
<span className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-surface-800" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
90
src/components/ui/action-menu.tsx
Normal file
90
src/components/ui/action-menu.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { ActionIcon } from './action-icon';
|
||||
import type { IconName } from './action-icon';
|
||||
|
||||
export interface ActionMenuItem {
|
||||
icon: IconName;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: 'default' | 'danger';
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ActionMenuProps {
|
||||
items: ActionMenuItem[];
|
||||
}
|
||||
|
||||
export function ActionMenu({ items }: ActionMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') close();
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [open, close]);
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="relative inline-block">
|
||||
<ActionIcon
|
||||
icon="more"
|
||||
variant="ghost"
|
||||
label="Actions"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen((prev) => !prev);
|
||||
}}
|
||||
/>
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-full mt-1 min-w-[160px] bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-lg py-1 z-50 animate-in fade-in"
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<button
|
||||
key={i}
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
item.onClick();
|
||||
close();
|
||||
}}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||
item.variant === 'danger'
|
||||
? 'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20'
|
||||
: 'text-surface-700 dark:text-surface-300 hover:bg-surface-50 dark:hover:bg-surface-700'
|
||||
}`}
|
||||
>
|
||||
<ActionIcon
|
||||
icon={item.icon}
|
||||
variant={item.variant === 'danger' ? 'danger' : 'ghost'}
|
||||
label={item.label}
|
||||
size="sm"
|
||||
onClick={() => {}}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
src/components/ui/badge.tsx
Normal file
49
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
interface BadgeProps {
|
||||
label: string;
|
||||
variant?: 'default' | 'success' | 'warning' | 'error' | 'info' | 'purple';
|
||||
}
|
||||
|
||||
const variantStyles: Record<string, string> = {
|
||||
default: 'bg-surface-100 dark:bg-surface-700 text-surface-600 dark:text-surface-300',
|
||||
success: 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400',
|
||||
warning: 'bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400',
|
||||
error: 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400',
|
||||
info: 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400',
|
||||
purple: 'bg-violet-50 dark:bg-violet-900/30 text-violet-700 dark:text-violet-400',
|
||||
};
|
||||
|
||||
export function Badge({ label, variant = 'default' }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 text-[11px] font-medium rounded-full ${variantStyles[variant]}`}
|
||||
role="status"
|
||||
aria-label={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function statusBadgeVariant(status: string): BadgeProps['variant'] {
|
||||
const map: Record<string, BadgeProps['variant']> = {
|
||||
active: 'success',
|
||||
resolved: 'success',
|
||||
paid: 'success',
|
||||
confirmed: 'success',
|
||||
open: 'info',
|
||||
sent: 'info',
|
||||
pending: 'warning',
|
||||
in_progress: 'warning',
|
||||
partial: 'warning',
|
||||
suspended: 'warning',
|
||||
overdue: 'error',
|
||||
cancelled: 'error',
|
||||
inactive: 'error',
|
||||
rejected: 'error',
|
||||
void: 'default',
|
||||
draft: 'default',
|
||||
postpaid: 'info',
|
||||
prepaid: 'purple',
|
||||
};
|
||||
return map[status] || 'default';
|
||||
}
|
||||
42
src/components/ui/button.tsx
Normal file
42
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
size?: 'sm' | 'md';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const variantStyles: Record<string, string> = {
|
||||
primary: 'bg-primary-600 text-white shadow-sm hover:bg-primary-700 focus:ring-primary-500/50',
|
||||
secondary: 'bg-white dark:bg-surface-800 text-surface-700 dark:text-surface-200 border border-surface-200 dark:border-surface-600 hover:bg-surface-50 dark:hover:bg-surface-700 focus:ring-surface-300/50',
|
||||
danger: 'bg-red-600 text-white shadow-sm hover:bg-red-700 focus:ring-red-500/50',
|
||||
ghost: 'text-surface-500 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 hover:bg-surface-50 dark:hover:bg-surface-700',
|
||||
};
|
||||
|
||||
const sizeStyles: Record<string, string> = {
|
||||
sm: 'px-3 py-1.5 text-xs',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ variant = 'primary', size = 'md', loading, children, disabled, className = '', ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
className={`inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-all duration-200 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-offset-2 ${variantStyles[variant]} ${sizeStyles[size]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<svg className="animate-spin h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" className="opacity-25" />
|
||||
<path d="M4 12a8 8 0 018-8" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
238
src/components/ui/data-table.tsx
Normal file
238
src/components/ui/data-table.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { EmptyState } from './empty-state';
|
||||
import { TableSkeleton } from './skeleton';
|
||||
|
||||
interface Column<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
align?: 'left' | 'right' | 'center';
|
||||
render: (item: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
interface FilterOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface QuickFilter {
|
||||
key: string;
|
||||
label: string;
|
||||
options: FilterOption[];
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
loading?: boolean;
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
keyExtractor: (item: T) => string;
|
||||
searchPlaceholder?: string;
|
||||
searchValue?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
quickFilters?: QuickFilter[];
|
||||
activeFilters?: Record<string, string>;
|
||||
onFilterChange?: (key: string, value: string) => void;
|
||||
pageSize?: number;
|
||||
onRowClick?: (item: T) => void;
|
||||
maxHeight?: string;
|
||||
headerActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const PAGE_SIZES = [10, 20, 50, 100];
|
||||
|
||||
export function DataTable<T>({
|
||||
columns,
|
||||
data,
|
||||
loading,
|
||||
emptyTitle = 'No data',
|
||||
emptyDescription,
|
||||
keyExtractor,
|
||||
searchPlaceholder,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
quickFilters,
|
||||
activeFilters,
|
||||
onFilterChange,
|
||||
pageSize: initialPageSize = 20,
|
||||
onRowClick,
|
||||
maxHeight,
|
||||
headerActions,
|
||||
}: DataTableProps<T>) {
|
||||
const [sortKey, setSortKey] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(initialPageSize);
|
||||
|
||||
function handleSort(key: string) {
|
||||
if (sortKey === key) setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
|
||||
else { setSortKey(key); setSortDir('asc'); }
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sortKey) return data;
|
||||
return [...data].sort((a, b) => {
|
||||
const aVal = (a as any)[sortKey];
|
||||
const bVal = (b as any)[sortKey];
|
||||
if (aVal == null) return 1;
|
||||
if (bVal == null) return -1;
|
||||
const cmp = typeof aVal === 'string' ? aVal.localeCompare(bVal) : aVal - bVal;
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}, [data, sortKey, sortDir]);
|
||||
|
||||
const totalPages = Math.ceil(sortedData.length / perPage);
|
||||
const paginatedData = sortedData.slice((page - 1) * perPage, page * perPage);
|
||||
const startItem = sortedData.length === 0 ? 0 : (page - 1) * perPage + 1;
|
||||
const endItem = Math.min(page * perPage, sortedData.length);
|
||||
|
||||
// Reset page when data changes
|
||||
if (page > totalPages && totalPages > 0) setPage(totalPages);
|
||||
|
||||
function getPageNumbers(): (number | '...')[] {
|
||||
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
const pages: (number | '...')[] = [1];
|
||||
if (page > 3) pages.push('...');
|
||||
for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) pages.push(i);
|
||||
if (page < totalPages - 2) pages.push('...');
|
||||
if (totalPages > 1) pages.push(totalPages);
|
||||
return pages;
|
||||
}
|
||||
|
||||
if (loading) return <TableSkeleton rows={6} cols={columns.length} />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* Search + Quick Filters + Header Actions row */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{onSearchChange && (
|
||||
<div className="relative flex-shrink-0">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-surface-400" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<circle cx="7" cy="7" r="5" /><path d="M11 11l3.5 3.5" />
|
||||
</svg>
|
||||
<input type="text" value={searchValue} onChange={(e) => { onSearchChange(e.target.value); setPage(1); }}
|
||||
placeholder={searchPlaceholder || 'Search...'} aria-label={searchPlaceholder || 'Search'}
|
||||
className="w-64 pl-9 pr-3 py-2 rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 text-sm text-surface-900 dark:text-surface-200 placeholder:text-surface-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick filter chips */}
|
||||
{quickFilters && onFilterChange && quickFilters.map((filter) => (
|
||||
<div key={filter.key} className="flex items-center gap-1">
|
||||
<span className="text-xs text-surface-400 dark:text-surface-500 mr-1">{filter.label}:</span>
|
||||
<button onClick={() => onFilterChange(filter.key, '')}
|
||||
className={`px-2.5 py-1 text-xs rounded-full font-medium transition-all duration-150 cursor-pointer ${
|
||||
!activeFilters?.[filter.key] ? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-400' : 'bg-surface-100 dark:bg-surface-700 text-surface-500 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-600'
|
||||
}`}>All</button>
|
||||
{filter.options.map((opt) => (
|
||||
<button key={opt.value} onClick={() => onFilterChange(filter.key, opt.value)}
|
||||
className={`px-2.5 py-1 text-xs rounded-full font-medium transition-all duration-150 cursor-pointer ${
|
||||
activeFilters?.[filter.key] === opt.value ? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-400' : 'bg-surface-100 dark:bg-surface-700 text-surface-500 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-600'
|
||||
}`}>{opt.label}</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{headerActions && <div className="flex-shrink-0">{headerActions}</div>}
|
||||
</div>
|
||||
|
||||
{sortedData.length === 0 ? (
|
||||
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700">
|
||||
<EmptyState title={emptyTitle} description={emptyDescription}
|
||||
icon={<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="3" y="3" width="18" height="18" rx="3" /><path d="M9 9h6M9 13h4" /></svg>} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 flex flex-col">
|
||||
{/* Scrollable table body with sticky header */}
|
||||
<div className="overflow-x-auto" style={{ maxHeight: maxHeight ?? 'calc(100vh - 300px)' }}>
|
||||
<table className="min-w-full divide-y divide-surface-200 dark:divide-surface-700" role="grid">
|
||||
<thead className="bg-surface-50/50 dark:bg-surface-800/80 sticky top-0 z-10">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={col.key}
|
||||
className={`px-5 py-3 text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider bg-surface-50/80 dark:bg-surface-800/90 backdrop-blur-sm ${
|
||||
col.align === 'right' ? 'text-right' : 'text-left'
|
||||
} ${col.sortable ? 'cursor-pointer select-none hover:text-surface-700 dark:hover:text-surface-300 transition-colors' : ''}`}
|
||||
onClick={col.sortable ? () => handleSort(col.key) : undefined}
|
||||
aria-sort={sortKey === col.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}>
|
||||
<span className="flex items-center gap-1">
|
||||
{col.label}
|
||||
{col.sortable && sortKey === col.key && (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||
{sortDir === 'asc' ? <path d="M6 3l4 6H2z" /> : <path d="M6 9l4-6H2z" />}
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-surface-100 dark:divide-surface-700">
|
||||
{paginatedData.map((item) => (
|
||||
<tr key={keyExtractor(item)}
|
||||
className={`group transition-colors duration-100 ${
|
||||
onRowClick
|
||||
? 'cursor-pointer hover:bg-primary-50/40 dark:hover:bg-primary-900/20 border-l-2 border-l-transparent hover:border-l-primary-400'
|
||||
: 'hover:bg-surface-50/50 dark:hover:bg-surface-700/50'
|
||||
}`}
|
||||
onClick={onRowClick ? () => onRowClick(item) : undefined}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={`px-5 py-3.5 text-sm text-surface-700 dark:text-surface-300 ${col.align === 'right' ? 'text-right' : 'text-left'}`}>
|
||||
{col.render(item)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination controls — always visible */}
|
||||
<div className="mt-3 flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center gap-2 text-sm text-surface-500 dark:text-surface-400">
|
||||
<span>Showing {startItem}-{endItem} of {sortedData.length}</span>
|
||||
<select value={perPage} onChange={(e) => { setPerPage(Number(e.target.value)); setPage(1); }}
|
||||
aria-label="Items per page"
|
||||
className="ml-2 rounded-md border border-surface-200 dark:border-surface-700 px-2 py-1 text-xs bg-white dark:bg-surface-800 text-surface-700 dark:text-surface-300 cursor-pointer focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500/20">
|
||||
{PAGE_SIZES.map((s) => <option key={s} value={s}>{s}/page</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav className="flex items-center gap-1" aria-label="Pagination">
|
||||
<button onClick={() => setPage(Math.max(1, page - 1))} disabled={page === 1}
|
||||
className="p-1.5 rounded-md text-surface-400 hover:text-surface-700 dark:hover:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-700 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer transition-colors"
|
||||
aria-label="Previous page">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M10 4l-4 4 4 4" /></svg>
|
||||
</button>
|
||||
{getPageNumbers().map((p, i) =>
|
||||
p === '...' ? (
|
||||
<span key={`dots-${i}`} className="px-1 text-surface-300 dark:text-surface-500">...</span>
|
||||
) : (
|
||||
<button key={p} onClick={() => setPage(p as number)}
|
||||
className={`min-w-[32px] h-8 rounded-md text-sm font-medium transition-colors cursor-pointer ${
|
||||
page === p ? 'bg-primary-600 text-white' : 'text-surface-600 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-700'
|
||||
}`}>{p}</button>
|
||||
),
|
||||
)}
|
||||
<button onClick={() => setPage(Math.min(totalPages, page + 1))} disabled={page === totalPages}
|
||||
className="p-1.5 rounded-md text-surface-400 hover:text-surface-700 dark:hover:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-700 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer transition-colors"
|
||||
aria-label="Next page">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M6 4l4 4-4 4" /></svg>
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/components/ui/empty-state.tsx
Normal file
23
src/components/ui/empty-state.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
interface EmptyStateProps {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center" role="status">
|
||||
{icon && (
|
||||
<div className="w-12 h-12 rounded-full bg-surface-100 flex items-center justify-center text-surface-400 mb-4">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-sm font-semibold text-surface-700">{title}</h3>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-surface-400 max-w-sm">{description}</p>
|
||||
)}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
src/components/ui/form-modal.tsx
Normal file
55
src/components/ui/form-modal.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface FormModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
wide?: boolean;
|
||||
}
|
||||
|
||||
export function FormModal({ open, onClose, title, description, children, wide }: FormModalProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && open) onClose();
|
||||
}
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] px-4 bg-surface-900/40 backdrop-blur-sm overflow-y-auto"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="form-modal-title"
|
||||
onClick={(e) => { if (e.target === overlayRef.current) onClose(); }}
|
||||
>
|
||||
<div className={`bg-white dark:bg-surface-800 rounded-xl shadow-xl w-full p-6 mb-10 animate-in fade-in slide-in-from-top-4 duration-200 ${wide ? 'max-w-2xl' : 'max-w-lg'}`}>
|
||||
<div className="flex items-start justify-between mb-5">
|
||||
<div>
|
||||
<h2 id="form-modal-title" className="text-lg font-semibold text-surface-900 dark:text-surface-200">{title}</h2>
|
||||
{description && <p className="mt-1 text-sm text-surface-500 dark:text-surface-400">{description}</p>}
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 text-surface-400 hover:text-surface-600 dark:hover:text-surface-300 transition-colors cursor-pointer rounded-lg hover:bg-surface-50 dark:hover:bg-surface-700" aria-label="Close">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M6 6l8 8M14 6l-8 8" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
src/components/ui/modal.tsx
Normal file
122
src/components/ui/modal.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
|
||||
const SIZE_CLASSES: Record<ModalSize, string> = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-2xl',
|
||||
lg: 'max-w-4xl',
|
||||
xl: 'max-w-5xl',
|
||||
full: 'max-w-7xl',
|
||||
};
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
variant?: 'default' | 'danger';
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm?: () => void | Promise<void>;
|
||||
loading?: boolean;
|
||||
size?: ModalSize;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
variant = 'default',
|
||||
confirmLabel,
|
||||
cancelLabel = 'Cancel',
|
||||
onConfirm,
|
||||
loading,
|
||||
size = 'sm',
|
||||
}: ModalProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const firstFocusRef = useRef<HTMLButtonElement>(null);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!onConfirm || processing) return;
|
||||
setProcessing(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
} catch {
|
||||
// Let consumer handle errors via their own toast — just stop processing
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
firstFocusRef.current?.focus();
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && open) onClose();
|
||||
}
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-surface-900/40 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
onClick={(e) => { if (e.target === overlayRef.current) onClose(); }}
|
||||
>
|
||||
<div className={`bg-white dark:bg-surface-800 rounded-xl shadow-xl ${SIZE_CLASSES[size]} w-full p-6 animate-in fade-in zoom-in duration-200`}>
|
||||
<h2 id="modal-title" className="text-lg font-semibold text-surface-900 dark:text-surface-200">
|
||||
{title}
|
||||
</h2>
|
||||
{description && (
|
||||
<p className="mt-2 text-sm text-surface-500 dark:text-surface-400">{description}</p>
|
||||
)}
|
||||
{children && <div className="mt-4">{children}</div>}
|
||||
{(onConfirm || cancelLabel) && (
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
ref={firstFocusRef}
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-surface-600 dark:text-surface-300 bg-surface-100 dark:bg-surface-700 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-600 transition-colors duration-200 cursor-pointer"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
{onConfirm && (
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={loading || processing}
|
||||
className={`px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors duration-200 cursor-pointer disabled:opacity-50 ${
|
||||
variant === 'danger'
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: 'bg-primary-600 hover:bg-primary-700'
|
||||
}`}
|
||||
>
|
||||
{loading || processing ? 'Processing...' : confirmLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
src/components/ui/page-header.tsx
Normal file
19
src/components/ui/page-header.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, action }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-surface-900 dark:text-surface-100">{title}</h1>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-surface-500 dark:text-surface-400">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
src/components/ui/select.tsx
Normal file
130
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import { useState, forwardRef, useRef, useEffect } from 'react';
|
||||
|
||||
interface SelectProps {
|
||||
value?: string | string[];
|
||||
onChange: (value: string | string[]) => void;
|
||||
options: { label: string; value: string }[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
({ value, onChange, options, placeholder, disabled = false, multiple = false }, ref) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const selectRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isMulti = multiple;
|
||||
const currentValue = value;
|
||||
|
||||
const selectedLabels = isMulti
|
||||
? (Array.isArray(currentValue) ? currentValue : [])
|
||||
: options.find((o) => o.value === currentValue)?.label || '';
|
||||
|
||||
function handleClick() {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
|
||||
function handleSelect(optionValue: string) {
|
||||
if (isMulti) {
|
||||
const newValues = Array.isArray(currentValue) ? [...currentValue] : [];
|
||||
if (newValues.includes(optionValue)) {
|
||||
onChange(newValues.filter((v) => v !== optionValue));
|
||||
} else {
|
||||
onChange([...newValues, optionValue]);
|
||||
}
|
||||
} else {
|
||||
onChange(optionValue);
|
||||
}
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (triggerRef.current && !triggerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<div
|
||||
ref={triggerRef}
|
||||
onClick={handleClick}
|
||||
className={`
|
||||
flex items-center justify-between px-3 py-2 rounded-lg border
|
||||
${disabled ? 'bg-surface-50 dark:bg-surface-800 border-surface-200 dark:border-surface-700 text-surface-300' : 'bg-white dark:bg-surface-800 border-surface-300 dark:border-surface-600 cursor-pointer hover:border-surface-400 dark:hover:border-surface-500'}
|
||||
transition-all duration-200
|
||||
min-w-[200px] max-w-full
|
||||
`}
|
||||
>
|
||||
<span className={selectedLabels.length === 0 ? 'text-surface-400' : 'text-surface-900 dark:text-surface-200 truncate'}>
|
||||
{placeholder || 'Select...'}
|
||||
{isMulti && Array.isArray(selectedLabels) && selectedLabels.length > 0 && <span className="text-surface-400"> ({selectedLabels.length})</span>}
|
||||
{!isMulti && selectedLabels && <span className="text-surface-900 dark:text-surface-200"> {selectedLabels}</span>}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="4 8 8 12" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-1 w-full bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-lg max-h-60 overflow-y-auto">
|
||||
{options.map((option) => {
|
||||
const isSelected = isMulti
|
||||
? Array.isArray(currentValue) && currentValue.includes(option.value)
|
||||
: currentValue === option.value;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
className={`
|
||||
px-3 py-2 cursor-pointer hover:bg-surface-50 dark:hover:bg-surface-700
|
||||
${isSelected ? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400' : 'text-surface-800 dark:text-surface-300'}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isMulti && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => {}}
|
||||
className="w-4 h-4"
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
<span className="flex-1">{option.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Select.displayName = 'Select';
|
||||
|
||||
export { Select };
|
||||
38
src/components/ui/skeleton.tsx
Normal file
38
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
export function Skeleton({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`animate-pulse bg-surface-200 rounded ${className}`}
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableSkeleton({ rows = 5, cols = 4 }: { rows?: number; cols?: number }) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 overflow-hidden">
|
||||
<div className="p-4 space-y-3">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div key={i} className="flex gap-4">
|
||||
{Array.from({ length: cols }).map((_, j) => (
|
||||
<Skeleton key={j} className={`h-5 ${j === 0 ? 'w-24' : 'flex-1'}`} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardSkeleton() {
|
||||
return (
|
||||
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
</div>
|
||||
<Skeleton className="mt-3 h-8 w-16" />
|
||||
<Skeleton className="mt-2 h-3 w-20" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
src/components/ui/toast.tsx
Normal file
93
src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, createContext, useContext, useCallback } from 'react';
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
toast: (message: string, type?: Toast['type']) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType>({ toast: () => {} });
|
||||
|
||||
export function useToast() {
|
||||
return useContext(ToastContext);
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const addToast = useCallback((message: string, type: Toast['type'] = 'info') => {
|
||||
const id = crypto.randomUUID();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast: addToast }}>
|
||||
{children}
|
||||
<div className="fixed bottom-4 right-4 z-50 space-y-2" aria-live="polite">
|
||||
{toasts.map((t) => (
|
||||
<ToastItem key={t.id} toast={t} onDismiss={() => removeToast(t.id)} />
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onDismiss, 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onDismiss]);
|
||||
|
||||
const styles: Record<string, string> = {
|
||||
success: 'bg-emerald-600',
|
||||
error: 'bg-red-600',
|
||||
info: 'bg-surface-800',
|
||||
};
|
||||
|
||||
const icons: Record<string, React.ReactNode> = {
|
||||
success: (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M4 8.5l3 3 5-6" />
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<circle cx="8" cy="8" r="6" /><path d="M8 5v3M8 10v.5" />
|
||||
</svg>
|
||||
),
|
||||
info: (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<circle cx="8" cy="8" r="6" /><path d="M8 7v4M8 5v.5" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles[toast.type]} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 text-sm font-medium min-w-[280px] animate-in slide-in-from-right duration-300`}
|
||||
role="alert"
|
||||
>
|
||||
{icons[toast.type]}
|
||||
<span className="flex-1">{toast.message}</span>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="text-white/70 hover:text-white cursor-pointer"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M3 3l8 8M11 3l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user