diff --git a/LIBRARY_EXTENSIONS.md b/LIBRARY_EXTENSIONS.md new file mode 100644 index 0000000..136f160 --- /dev/null +++ b/LIBRARY_EXTENSIONS.md @@ -0,0 +1,375 @@ +# Extended Hooks and Components Library + +This document lists all the newly added custom hooks and UI components for the WorkForce Pro platform. + +## New Custom Hooks + +### useBulkOperations +**Purpose**: Handle bulk operations on multiple items with progress tracking, error handling, and batch processing. + +**Features**: +- Item selection with multi-select support +- Batch processing with configurable batch size +- Progress tracking +- Error handling and retry logic +- Range selection support + +**Usage**: +```typescript +const { selectedItems, processBulk, progress, errors } = useBulkOperations() + +// Process selected items in batches +await processBulk(async (id) => { + await approveTimesheet(id) +}, { batchSize: 5, continueOnError: true }) +``` + +--- + +### useOptimisticUpdate +**Purpose**: Apply optimistic UI updates with automatic rollback on failure. + +**Features**: +- Immediate UI updates +- Automatic rollback on error +- Configurable timeout +- Track pending updates + +**Usage**: +```typescript +const { executeOptimistic } = useOptimisticUpdate() + +await executeOptimistic( + 'timesheet-123', + currentData, + updatedData, + async () => await updateTimesheetAPI(updatedData) +) +``` + +--- + +### usePolling +**Purpose**: Poll an API endpoint at regular intervals with automatic retry and backoff. + +**Features**: +- Configurable polling interval +- Automatic retry with exponential backoff +- Start/stop/refresh controls +- Error handling + +**Usage**: +```typescript +const { data, start, stop, refresh } = usePolling( + fetchMissingTimesheets, + { interval: 30000, maxRetries: 3 } +) +``` + +--- + +### useVirtualScroll +**Purpose**: Efficiently render large lists using virtual scrolling. + +**Features**: +- Only renders visible items +- Configurable overscan +- Scroll-to-index functionality +- Performance optimized for 1000+ items + +**Usage**: +```typescript +const { containerRef, visibleItems, scrollToIndex } = useVirtualScroll( + allTimesheets, + { itemHeight: 60, containerHeight: 600 } +) +``` + +--- + +### useQueue +**Purpose**: Process tasks in a queue with concurrency control and retry logic. + +**Features**: +- Configurable concurrency +- Priority queue support +- Automatic retry on failure +- Queue statistics + +**Usage**: +```typescript +const { enqueue, stats } = useQueue(processInvoice, { + concurrency: 3, + maxRetries: 2 +}) + +enqueue(invoiceData, priority) +``` + +--- + +### useDragAndDrop +**Purpose**: Add drag-and-drop functionality to components. + +**Features**: +- Type-safe drag and drop +- Drop zone validation +- Custom drag preview +- Drag state tracking + +**Usage**: +```typescript +const { getDragHandlers, getDropHandlers, isDragging } = useDragAndDrop() + +
Draggable
+
Drop Zone
+``` + +--- + +### useCache +**Purpose**: In-memory caching with TTL and size limits. + +**Features**: +- Automatic expiration (TTL) +- LRU eviction when max size reached +- Hit rate tracking +- Import/export cache state + +**Usage**: +```typescript +const { get, set, getOrSet, hitRate } = useCache({ + ttl: 300000, + maxSize: 100 +}) + +const data = await getOrSet('workers', fetchWorkers) +``` + +--- + +### useWebSocket +**Purpose**: WebSocket connection with automatic reconnection and heartbeat. + +**Features**: +- Automatic reconnection +- Configurable heartbeat +- Connection state tracking +- JSON message support + +**Usage**: +```typescript +const { sendJson, isOpen, lastMessage } = useWebSocket( + 'wss://api.example.com/ws', + { reconnectAttempts: 5, heartbeatInterval: 30000 } +) +``` + +--- + +## New UI Components + +### MultiSelect +**Purpose**: Multi-selection dropdown with search and tag display. + +**Features**: +- Searchable options +- Tag display with remove +- Maximum selection limit +- Clear all functionality + +**Usage**: +```tsx + +``` + +--- + +### Timeline (Enhanced) +**Purpose**: Display chronological events with status indicators. + +**Features**: +- Vertical and horizontal orientation +- Status indicators (completed, current, upcoming, error) +- Metadata display +- Custom icons +- Clickable items + +**Usage**: +```tsx + console.log(item)} +/> +``` + +--- + +### ValidationIndicator & Banner +**Purpose**: Display validation rules and contextual banners. + +**Features**: +- Real-time validation feedback +- Rule-based validation display +- Contextual banners (info, success, warning, error) +- Dismissible banners + +**Usage**: +```tsx + + + + Your compliance document expires in 3 days + +``` + +--- + +### Stepper (Enhanced) +**Purpose**: Step-by-step navigation with multiple variants. + +**Features**: +- Horizontal and vertical orientation +- Multiple variants (default, compact, dots) +- Optional steps +- Click navigation +- Step status tracking + +**Usage**: +```tsx + + + +``` + +--- + +### ComboBox +**Purpose**: Searchable dropdown with grouping and descriptions. + +**Features**: +- Searchable options +- Group support +- Option descriptions +- Empty state handling + +**Usage**: +```tsx + +``` + +--- + +### TreeView +**Purpose**: Hierarchical data display with expand/collapse. + +**Features**: +- Nested data support +- Expand/collapse nodes +- Custom icons +- Selection support +- Disabled nodes +- Connection lines + +**Usage**: +```tsx + +``` + +--- + +### DataPill & DataGroup +**Purpose**: Display tags and grouped data elements. + +**Features**: +- Multiple variants (default, primary, success, warning, error, info) +- Removable pills +- Size variants (sm, md, lg) +- Optional icons +- Collapsible groups + +**Usage**: +```tsx + + + JavaScript + + React + +``` + +--- + +### AdvancedTable +**Purpose**: Feature-rich table with sorting, styling options, and loading states. + +**Features**: +- Column sorting +- Custom cell rendering +- Sticky headers and columns +- Row selection +- Loading states +- Empty states +- Striped and bordered variants + +**Usage**: +```tsx + row.id} + onRowClick={handleRowClick} + hoverable + striped + stickyHeader +/> +``` + +--- + +## Integration Notes + +All hooks are exported from `@/hooks` and all components are available in `@/components/ui`. + +These additions significantly expand the platform's capabilities for: +- **Bulk operations** - Approve/reject multiple timesheets, invoices +- **Real-time updates** - WebSocket connections, polling for new data +- **Performance** - Virtual scrolling for large datasets, caching +- **User experience** - Drag-and-drop, multi-select, advanced steppers +- **Data display** - Tree views, timelines, advanced tables + +All components follow the existing design system and are fully typed with TypeScript. diff --git a/src/components/ComponentShowcase.tsx b/src/components/ComponentShowcase.tsx index 5087578..bbc738a 100644 --- a/src/components/ComponentShowcase.tsx +++ b/src/components/ComponentShowcase.tsx @@ -67,9 +67,9 @@ export function ComponentShowcase() { useWizard(wizardSteps) const stepperSteps = [ - { id: '1', label: 'Start', description: 'Getting started' }, - { id: '2', label: 'Configure', description: 'Setup options' }, - { id: '3', label: 'Complete', description: 'Finish up' } + { id: '1', label: 'Start', description: 'Getting started', status: 'completed' as const }, + { id: '2', label: 'Configure', description: 'Setup options', status: 'current' as const }, + { id: '3', label: 'Complete', description: 'Finish up', status: 'pending' as const } ] const timelineItems = [ @@ -191,8 +191,7 @@ export function ComponentShowcase() { console.log('Go to step', index)} + orientation="horizontal" />
diff --git a/src/components/ui/advanced-table.tsx b/src/components/ui/advanced-table.tsx new file mode 100644 index 0000000..3fa5960 --- /dev/null +++ b/src/components/ui/advanced-table.tsx @@ -0,0 +1,165 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +export interface Column { + key: string + header: string + render?: (row: T, index: number) => React.ReactNode + width?: string + align?: 'left' | 'center' | 'right' + sortable?: boolean + sticky?: boolean +} + +export interface AdvancedTableProps { + data: T[] + columns: Column[] + keyExtractor: (row: T) => string + className?: string + hoverable?: boolean + striped?: boolean + bordered?: boolean + compact?: boolean + onRowClick?: (row: T, index: number) => void + emptyMessage?: string + loading?: boolean + stickyHeader?: boolean +} + +export function AdvancedTable({ + data, + columns, + keyExtractor, + className, + hoverable = true, + striped = false, + bordered = true, + compact = false, + onRowClick, + emptyMessage = 'No data available', + loading = false, + stickyHeader = false +}: AdvancedTableProps) { + const [sortKey, setSortKey] = React.useState(null) + const [sortDirection, setSortDirection] = React.useState<'asc' | 'desc'>('asc') + + const handleSort = (key: string) => { + if (sortKey === key) { + setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc') + } else { + setSortKey(key) + setSortDirection('asc') + } + } + + const sortedData = React.useMemo(() => { + if (!sortKey) return data + + return [...data].sort((a, b) => { + const aVal = (a as any)[sortKey] + const bVal = (b as any)[sortKey] + + if (aVal === bVal) return 0 + + const comparison = aVal > bVal ? 1 : -1 + return sortDirection === 'asc' ? comparison : -comparison + }) + }, [data, sortKey, sortDirection]) + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+ + + + {columns.map((col) => ( + + ))} + + + + {sortedData.length > 0 ? ( + sortedData.map((row, index) => ( + onRowClick?.(row, index)} + > + {columns.map((col) => ( + + ))} + + )) + ) : ( + + + + )} + +
col.sortable && handleSort(col.key)} + > +
+ {col.header} + {col.sortable && sortKey === col.key && ( + + + + )} +
+
+ {col.render ? col.render(row, index) : (row as any)[col.key]} +
+ {emptyMessage} +
+
+ ) +} diff --git a/src/components/ui/combo-box.tsx b/src/components/ui/combo-box.tsx new file mode 100644 index 0000000..ee92867 --- /dev/null +++ b/src/components/ui/combo-box.tsx @@ -0,0 +1,190 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +export interface ComboBoxOption { + value: string + label: string + description?: string + disabled?: boolean + group?: string +} + +export interface ComboBoxProps { + options: ComboBoxOption[] + value?: string + onChange: (value: string) => void + placeholder?: string + emptyMessage?: string + searchPlaceholder?: string + disabled?: boolean + className?: string + showGroups?: boolean +} + +export function ComboBox({ + options, + value, + onChange, + placeholder = 'Select an option...', + emptyMessage = 'No options found', + searchPlaceholder = 'Search...', + disabled = false, + showGroups = true, + className +}: ComboBoxProps) { + const [isOpen, setIsOpen] = React.useState(false) + const [searchQuery, setSearchQuery] = React.useState('') + const containerRef = React.useRef(null) + const searchInputRef = React.useRef(null) + + const selectedOption = options.find(opt => opt.value === value) + + const filteredOptions = React.useMemo(() => { + if (!searchQuery) return options + const query = searchQuery.toLowerCase() + return options.filter(opt => + opt.label.toLowerCase().includes(query) || + opt.value.toLowerCase().includes(query) || + opt.description?.toLowerCase().includes(query) + ) + }, [options, searchQuery]) + + const groupedOptions = React.useMemo(() => { + if (!showGroups) return { '': filteredOptions } + + return filteredOptions.reduce((acc, opt) => { + const group = opt.group || '' + if (!acc[group]) acc[group] = [] + acc[group].push(opt) + return acc + }, {} as Record) + }, [filteredOptions, showGroups]) + + const handleSelect = (optionValue: string) => { + onChange(optionValue) + setIsOpen(false) + setSearchQuery('') + } + + React.useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false) + setSearchQuery('') + } + } + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + } + }, [isOpen]) + + React.useEffect(() => { + if (isOpen && searchInputRef.current) { + searchInputRef.current.focus() + } + }, [isOpen]) + + return ( +
+ + + {isOpen && !disabled && ( +
+
+ setSearchQuery(e.target.value)} + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2" + /> +
+ +
+ {Object.keys(groupedOptions).length > 0 ? ( + Object.entries(groupedOptions).map(([group, opts]) => ( +
+ {group && ( +
+ {group} +
+ )} + {opts.map(opt => { + const isSelected = opt.value === value + + return ( + + ) + })} +
+ )) + ) : ( +
+ {emptyMessage} +
+ )} +
+
+ )} +
+ ) +} diff --git a/src/components/ui/data-pill.tsx b/src/components/ui/data-pill.tsx new file mode 100644 index 0000000..fde88cc --- /dev/null +++ b/src/components/ui/data-pill.tsx @@ -0,0 +1,110 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' +import { X } from '@phosphor-icons/react' + +export interface DataPillProps { + children: React.ReactNode + variant?: 'default' | 'primary' | 'success' | 'warning' | 'error' | 'info' + size?: 'sm' | 'md' | 'lg' + removable?: boolean + onRemove?: () => void + icon?: React.ReactNode + className?: string +} + +export function DataPill({ + children, + variant = 'default', + size = 'md', + removable = false, + onRemove, + icon, + className +}: DataPillProps) { + const variantStyles = { + default: 'bg-secondary text-secondary-foreground border-secondary', + primary: 'bg-primary/10 text-primary border-primary/20', + success: 'bg-success/10 text-success border-success/20', + warning: 'bg-warning/10 text-warning border-warning/20', + error: 'bg-destructive/10 text-destructive border-destructive/20', + info: 'bg-info/10 text-info border-info/20' + } + + const sizeStyles = { + sm: 'px-2 py-0.5 text-xs', + md: 'px-2.5 py-1 text-sm', + lg: 'px-3 py-1.5 text-base' + } + + return ( + + {icon && {icon}} + {children} + {removable && onRemove && ( + + )} + + ) +} + +export interface DataGroupProps { + label?: string + children: React.ReactNode + collapsible?: boolean + defaultCollapsed?: boolean + className?: string +} + +export function DataGroup({ + label, + children, + collapsible = false, + defaultCollapsed = false, + className +}: DataGroupProps) { + const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed) + + return ( +
+ {label && ( +
collapsible && setIsCollapsed(!isCollapsed)} + > + {collapsible && ( + + + + )} + {label} +
+ )} + {(!collapsible || !isCollapsed) && ( +
+ {children} +
+ )} +
+ ) +} diff --git a/src/components/ui/multi-select.tsx b/src/components/ui/multi-select.tsx new file mode 100644 index 0000000..25ef3d9 --- /dev/null +++ b/src/components/ui/multi-select.tsx @@ -0,0 +1,186 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' +import { X } from '@phosphor-icons/react' + +export interface MultiSelectOption { + value: string + label: string + disabled?: boolean +} + +export interface MultiSelectProps { + options: MultiSelectOption[] + value: string[] + onChange: (value: string[]) => void + placeholder?: string + disabled?: boolean + maxSelections?: number + searchable?: boolean + className?: string +} + +export function MultiSelect({ + options, + value, + onChange, + placeholder = 'Select items...', + disabled = false, + maxSelections, + searchable = true, + className +}: MultiSelectProps) { + const [isOpen, setIsOpen] = React.useState(false) + const [searchQuery, setSearchQuery] = React.useState('') + const containerRef = React.useRef(null) + + const filteredOptions = React.useMemo(() => { + if (!searchQuery) return options + const query = searchQuery.toLowerCase() + return options.filter(opt => + opt.label.toLowerCase().includes(query) || + opt.value.toLowerCase().includes(query) + ) + }, [options, searchQuery]) + + const selectedOptions = React.useMemo(() => { + return options.filter(opt => value.includes(opt.value)) + }, [options, value]) + + const handleToggle = (optionValue: string) => { + if (value.includes(optionValue)) { + onChange(value.filter(v => v !== optionValue)) + } else { + if (maxSelections && value.length >= maxSelections) return + onChange([...value, optionValue]) + } + } + + const handleRemove = (optionValue: string, e: React.MouseEvent) => { + e.stopPropagation() + onChange(value.filter(v => v !== optionValue)) + } + + const handleClearAll = (e: React.MouseEvent) => { + e.stopPropagation() + onChange([]) + } + + React.useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + } + }, [isOpen]) + + return ( +
+
!disabled && setIsOpen(!isOpen)} + > + {selectedOptions.length > 0 ? ( + <> + {selectedOptions.map(opt => ( + + {opt.label} + + + ))} + {selectedOptions.length > 0 && ( + + )} + + ) : ( + {placeholder} + )} +
+ + {isOpen && !disabled && ( +
+ {searchable && ( +
+ setSearchQuery(e.target.value)} + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2" + onClick={(e) => e.stopPropagation()} + /> +
+ )} +
+ {filteredOptions.length > 0 ? ( + filteredOptions.map(opt => { + const isSelected = value.includes(opt.value) + const isDisabled = opt.disabled || (maxSelections ? value.length >= maxSelections && !isSelected : false) + + return ( +
!isDisabled && handleToggle(opt.value)} + > +
+ {isSelected && ( + + + + )} +
+ {opt.label} +
+ ) + }) + ) : ( +
+ No options found +
+ )} +
+
+ )} +
+ ) +} diff --git a/src/components/ui/stepper-enhanced.tsx b/src/components/ui/stepper-enhanced.tsx new file mode 100644 index 0000000..0c52983 --- /dev/null +++ b/src/components/ui/stepper-enhanced.tsx @@ -0,0 +1,261 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' +import { CaretLeft, CaretRight } from '@phosphor-icons/react' + +export interface Step { + id: string + label: string + description?: string + optional?: boolean +} + +export interface StepperProps { + steps: Step[] + currentStep: number + onStepClick?: (stepIndex: number) => void + orientation?: 'horizontal' | 'vertical' + variant?: 'default' | 'compact' | 'dots' + className?: string + allowSkip?: boolean +} + +export function Stepper({ + steps, + currentStep, + onStepClick, + orientation = 'horizontal', + variant = 'default', + allowSkip = false, + className +}: StepperProps) { + const isVertical = orientation === 'vertical' + const isDots = variant === 'dots' + const isCompact = variant === 'compact' + + const handleStepClick = (index: number) => { + if (!onStepClick) return + if (!allowSkip && index > currentStep) return + onStepClick(index) + } + + if (isDots) { + return ( +
+ {steps.map((step, index) => { + const isActive = index === currentStep + const isCompleted = index < currentStep + const isClickable = allowSkip || index <= currentStep + + return ( +
+ ) + } + + return ( +
+ {steps.map((step, index) => { + const isActive = index === currentStep + const isCompleted = index < currentStep + const isLast = index === steps.length - 1 + const isClickable = allowSkip || index <= currentStep + + return ( + +
+ + + {!isCompact && ( +
+ + {step.label} + + {step.description && ( + + {step.description} + + )} + {step.optional && ( + + Optional + + )} +
+ )} +
+ + {!isLast && ( +
+ {isVertical ? ( +
+ ) : ( +
+ )} +
+ )} + + ) + })} +
+ ) +} + +export interface StepperNavProps { + currentStep: number + totalSteps: number + onNext?: () => void + onPrevious?: () => void + onComplete?: () => void + nextLabel?: string + previousLabel?: string + completeLabel?: string + disableNext?: boolean + disablePrevious?: boolean + className?: string +} + +export function StepperNav({ + currentStep, + totalSteps, + onNext, + onPrevious, + onComplete, + nextLabel = 'Next', + previousLabel = 'Previous', + completeLabel = 'Complete', + disableNext = false, + disablePrevious = false, + className +}: StepperNavProps) { + const isFirst = currentStep === 0 + const isLast = currentStep === totalSteps - 1 + + return ( +
+ + +
+ Step {currentStep + 1} of {totalSteps} +
+ + {isLast ? ( + + ) : ( + + )} +
+ ) +} diff --git a/src/components/ui/timeline-enhanced.tsx b/src/components/ui/timeline-enhanced.tsx new file mode 100644 index 0000000..af4a269 --- /dev/null +++ b/src/components/ui/timeline-enhanced.tsx @@ -0,0 +1,117 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +export interface TimelineItem { + id: string + title: string + description?: string + timestamp: string | Date + status?: 'completed' | 'current' | 'upcoming' | 'error' + icon?: React.ReactNode + metadata?: Record +} + +export interface TimelineProps { + items: TimelineItem[] + orientation?: 'vertical' | 'horizontal' + className?: string + onItemClick?: (item: TimelineItem) => void +} + +export function Timeline({ + items, + orientation = 'vertical', + className, + onItemClick +}: TimelineProps) { + const isVertical = orientation === 'vertical' + + return ( +
+ {items.map((item, index) => { + const isLast = index === items.length - 1 + const timestamp = typeof item.timestamp === 'string' + ? item.timestamp + : item.timestamp.toLocaleString() + + return ( +
onItemClick?.(item)} + > +
+
+ {item.icon || ( + {index + 1} + )} +
+ + {!isLast && ( +
+ )} +
+ +
+
+

{item.title}

+ +
+ + {item.description && ( +

+ {item.description} +

+ )} + + {item.metadata && Object.keys(item.metadata).length > 0 && ( +
+ {Object.entries(item.metadata).map(([key, value]) => ( +
+ {key}: + {String(value)} +
+ ))} +
+ )} +
+
+ ) + })} +
+ ) +} diff --git a/src/components/ui/tree-view.tsx b/src/components/ui/tree-view.tsx new file mode 100644 index 0000000..d251a8f --- /dev/null +++ b/src/components/ui/tree-view.tsx @@ -0,0 +1,117 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +export interface TreeNode { + id: string + label: string + children?: TreeNode[] + icon?: React.ReactNode + metadata?: Record + disabled?: boolean +} + +export interface TreeViewProps { + data: TreeNode[] + selectedId?: string + onSelect?: (node: TreeNode) => void + expandedByDefault?: boolean + showLines?: boolean + className?: string +} + +export function TreeView({ + data, + selectedId, + onSelect, + expandedByDefault = false, + showLines = true, + className +}: TreeViewProps) { + const [expandedNodes, setExpandedNodes] = React.useState>( + () => { + if (!expandedByDefault) return new Set() + const expanded = new Set() + const collectIds = (nodes: TreeNode[]) => { + nodes.forEach(node => { + if (node.children && node.children.length > 0) { + expanded.add(node.id) + collectIds(node.children) + } + }) + } + collectIds(data) + return expanded + } + ) + + const toggleExpand = (nodeId: string) => { + setExpandedNodes(prev => { + const next = new Set(prev) + if (next.has(nodeId)) { + next.delete(nodeId) + } else { + next.add(nodeId) + } + return next + }) + } + + const renderNode = (node: TreeNode, level: number = 0) => { + const hasChildren = node.children && node.children.length > 0 + const isExpanded = expandedNodes.has(node.id) + const isSelected = selectedId === node.id + + return ( +
+
!node.disabled && onSelect?.(node)} + > + {hasChildren && ( + + )} + {!hasChildren && showLines &&
} + {node.icon &&
{node.icon}
} + {node.label} + {node.metadata && Object.keys(node.metadata).length > 0 && ( + + {Object.values(node.metadata)[0]} + + )} +
+ {hasChildren && isExpanded && ( +
+ {node.children!.map(child => renderNode(child, level + 1))} +
+ )} +
+ ) + } + + return ( +
+ {data.map(node => renderNode(node))} +
+ ) +} diff --git a/src/components/ui/validation-banner.tsx b/src/components/ui/validation-banner.tsx new file mode 100644 index 0000000..cbf9935 --- /dev/null +++ b/src/components/ui/validation-banner.tsx @@ -0,0 +1,141 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' +import { Check, X, Info, Warning } from '@phosphor-icons/react' + +export interface ValidationRule { + id: string + label: string + validate: (value: any) => boolean + message?: string +} + +export interface ValidationIndicatorProps { + rules: ValidationRule[] + value: any + showOnlyFailed?: boolean + className?: string +} + +export function ValidationIndicator({ + rules, + value, + showOnlyFailed = false, + className +}: ValidationIndicatorProps) { + const results = React.useMemo(() => { + return rules.map(rule => ({ + ...rule, + passed: rule.validate(value) + })) + }, [rules, value]) + + const displayedRules = showOnlyFailed + ? results.filter(r => !r.passed) + : results + + if (displayedRules.length === 0 && showOnlyFailed) { + return null + } + + return ( +
+ {displayedRules.map(result => ( +
+ {result.passed ? ( + + ) : ( + + )} + {result.message || result.label} +
+ ))} +
+ ) +} + +export interface BannerProps { + variant?: 'info' | 'success' | 'warning' | 'error' + title?: string + children: React.ReactNode + icon?: React.ReactNode + actions?: React.ReactNode + dismissible?: boolean + onDismiss?: () => void + className?: string +} + +export function Banner({ + variant = 'info', + title, + children, + icon, + actions, + dismissible = false, + onDismiss, + className +}: BannerProps) { + const [isDismissed, setIsDismissed] = React.useState(false) + + const handleDismiss = () => { + setIsDismissed(true) + onDismiss?.() + } + + if (isDismissed) return null + + const variantStyles = { + info: 'bg-info/10 border-info/20 text-info-foreground', + success: 'bg-success/10 border-success/20 text-success-foreground', + warning: 'bg-warning/10 border-warning/20 text-warning-foreground', + error: 'bg-destructive/10 border-destructive/20 text-destructive-foreground' + } + + const defaultIcons = { + info: , + success: , + warning: , + error: + } + + return ( +
+
+ {icon || defaultIcons[variant]} +
+ +
+ {title && ( +

{title}

+ )} +
{children}
+
+ + {(actions || dismissible) && ( +
+ {actions} + {dismissible && ( + + )} +
+ )} +
+ ) +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index cd12d8e..2c69742 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -49,6 +49,15 @@ export { useAutoSave } from './use-auto-save' export { useMultiSelect } from './use-multi-select' export { useColumnVisibility } from './use-column-visibility' export { useValidation } from './use-validation' +export { useBulkOperations } from './use-bulk-operations' +export { useOptimisticUpdate } from './use-optimistic-update' +export { usePolling } from './use-polling' +export { useVirtualScroll } from './use-virtual-scroll' +export { useQueue } from './use-queue' +export { useDragAndDrop } from './use-drag-and-drop' +export { useCache } from './use-cache' +export { useWebSocket } from './use-websocket' +export { useEventBus } from './use-event-bus' export type { AsyncState } from './use-async' export type { FormErrors } from './use-form-validation' @@ -73,4 +82,12 @@ export type { DataGridColumn, DataGridOptions } from './use-data-grid' export type { HotkeyConfig } from './use-hotkeys' export type { ColumnConfig } from './use-column-visibility' export type { ValidationRule, FieldConfig } from './use-validation' +export type { BulkOperationState, BulkOperationOptions } from './use-bulk-operations' +export type { PollingOptions } from './use-polling' +export type { VirtualScrollOptions } from './use-virtual-scroll' +export type { QueueItem, QueueOptions } from './use-queue' +export type { DragItem, DropZone, DragState } from './use-drag-and-drop' +export type { CacheOptions } from './use-cache' +export type { WebSocketOptions } from './use-websocket' +export type { EventBusEvent, EventHandler } from './use-event-bus' diff --git a/src/hooks/use-bulk-operations.ts b/src/hooks/use-bulk-operations.ts new file mode 100644 index 0000000..88611ba --- /dev/null +++ b/src/hooks/use-bulk-operations.ts @@ -0,0 +1,151 @@ +import { useState, useCallback } from 'react' + +export interface BulkOperationState { + selectedItems: Set + isProcessing: boolean + progress: number + errors: Array<{ id: string; error: string }> + results: Array<{ id: string; success: boolean; data?: any }> +} + +export interface BulkOperationOptions { + batchSize?: number + delayBetweenBatches?: number + continueOnError?: boolean +} + +export function useBulkOperations() { + const [selectedItems, setSelectedItems] = useState>(new Set()) + const [isProcessing, setIsProcessing] = useState(false) + const [progress, setProgress] = useState(0) + const [errors, setErrors] = useState>([]) + const [results, setResults] = useState>([]) + + const toggleSelection = useCallback((id: string) => { + setSelectedItems(prev => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + }, []) + + const selectAll = useCallback((ids: string[]) => { + setSelectedItems(new Set(ids)) + }, []) + + const clearSelection = useCallback(() => { + setSelectedItems(new Set()) + }, []) + + const isSelected = useCallback((id: string) => { + return selectedItems.has(id) + }, [selectedItems]) + + const selectRange = useCallback((startId: string, endId: string, allIds: string[]) => { + const startIndex = allIds.indexOf(startId) + const endIndex = allIds.indexOf(endId) + if (startIndex === -1 || endIndex === -1) return + + const [start, end] = startIndex < endIndex ? [startIndex, endIndex] : [endIndex, startIndex] + const rangeIds = allIds.slice(start, end + 1) + + setSelectedItems(prev => { + const next = new Set(prev) + rangeIds.forEach(id => next.add(id)) + return next + }) + }, []) + + const processBulk = useCallback(async ( + operation: (id: string) => Promise, + options: BulkOperationOptions = {} + ): Promise => { + const { + batchSize = 5, + delayBetweenBatches = 100, + continueOnError = true + } = options + + setIsProcessing(true) + setProgress(0) + setErrors([]) + setResults([]) + + const itemIds = Array.from(selectedItems) + const totalItems = itemIds.length + let processed = 0 + const newErrors: Array<{ id: string; error: string }> = [] + const newResults: Array<{ id: string; success: boolean; data?: any }> = [] + + for (let i = 0; i < itemIds.length; i += batchSize) { + const batch = itemIds.slice(i, i + batchSize) + + const batchPromises = batch.map(async (id) => { + try { + const result = await operation(id) + newResults.push({ id, success: true, data: result }) + return { id, success: true } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + newErrors.push({ id, error: errorMessage }) + newResults.push({ id, success: false }) + if (!continueOnError) { + throw error + } + return { id, success: false } + } + }) + + try { + await Promise.all(batchPromises) + } catch (error) { + if (!continueOnError) { + setIsProcessing(false) + setErrors(newErrors) + setResults(newResults) + throw error + } + } + + processed += batch.length + setProgress(Math.round((processed / totalItems) * 100)) + setErrors([...newErrors]) + setResults([...newResults]) + + if (i + batchSize < itemIds.length && delayBetweenBatches > 0) { + await new Promise(resolve => setTimeout(resolve, delayBetweenBatches)) + } + } + + setIsProcessing(false) + setProgress(100) + }, [selectedItems]) + + const reset = useCallback(() => { + setSelectedItems(new Set()) + setIsProcessing(false) + setProgress(0) + setErrors([]) + setResults([]) + }, []) + + return { + selectedItems: Array.from(selectedItems), + selectedCount: selectedItems.size, + isProcessing, + progress, + errors, + results, + toggleSelection, + selectAll, + clearSelection, + isSelected, + selectRange, + processBulk, + reset + } +} diff --git a/src/hooks/use-cache.ts b/src/hooks/use-cache.ts new file mode 100644 index 0000000..116ad0e --- /dev/null +++ b/src/hooks/use-cache.ts @@ -0,0 +1,183 @@ +import { useState, useCallback, useEffect } from 'react' + +export interface CacheOptions { + ttl?: number + maxSize?: number + serialize?: (data: T) => string + deserialize?: (data: string) => T +} + +interface CacheEntry { + data: T + timestamp: number +} + +export function useCache(options: CacheOptions = {}) { + const { + ttl = 5 * 60 * 1000, + maxSize = 100, + serialize = JSON.stringify, + deserialize = JSON.parse + } = options + + const [cache, setCache] = useState>>(new Map()) + const [hits, setHits] = useState(0) + const [misses, setMisses] = useState(0) + + const isExpired = useCallback((entry: CacheEntry): boolean => { + return Date.now() - entry.timestamp > ttl + }, [ttl]) + + const get = useCallback((key: string): T | undefined => { + const entry = cache.get(key) + + if (!entry) { + setMisses(prev => prev + 1) + return undefined + } + + if (isExpired(entry)) { + setCache(prev => { + const next = new Map(prev) + next.delete(key) + return next + }) + setMisses(prev => prev + 1) + return undefined + } + + setHits(prev => prev + 1) + return entry.data + }, [cache, isExpired]) + + const set = useCallback((key: string, data: T) => { + setCache(prev => { + const next = new Map(prev) + + if (next.size >= maxSize && !next.has(key)) { + const oldestKey = Array.from(next.entries()) + .sort((a, b) => a[1].timestamp - b[1].timestamp)[0]?.[0] + + if (oldestKey) { + next.delete(oldestKey) + } + } + + next.set(key, { + data, + timestamp: Date.now() + }) + + return next + }) + }, [maxSize]) + + const remove = useCallback((key: string) => { + setCache(prev => { + const next = new Map(prev) + next.delete(key) + return next + }) + }, []) + + const clear = useCallback(() => { + setCache(new Map()) + setHits(0) + setMisses(0) + }, []) + + const has = useCallback((key: string): boolean => { + const entry = cache.get(key) + if (!entry) return false + if (isExpired(entry)) { + remove(key) + return false + } + return true + }, [cache, isExpired, remove]) + + const prune = useCallback(() => { + setCache(prev => { + const next = new Map(prev) + const now = Date.now() + + for (const [key, entry] of next.entries()) { + if (now - entry.timestamp > ttl) { + next.delete(key) + } + } + + return next + }) + }, [ttl]) + + useEffect(() => { + const interval = setInterval(prune, ttl) + return () => clearInterval(interval) + }, [prune, ttl]) + + const getOrSet = useCallback(async ( + key: string, + fetcher: () => Promise + ): Promise => { + const cached = get(key) + if (cached !== undefined) { + return cached + } + + const data = await fetcher() + set(key, data) + return data + }, [get, set]) + + const exportCache = useCallback((): string => { + const entries = Array.from(cache.entries()).map(([key, entry]) => ({ + key, + data: serialize(entry.data), + timestamp: entry.timestamp + })) + return JSON.stringify(entries) + }, [cache, serialize]) + + const importCache = useCallback((exported: string) => { + try { + const entries = JSON.parse(exported) as Array<{ + key: string + data: string + timestamp: number + }> + + const newCache = new Map>() + const now = Date.now() + + entries.forEach(({ key, data, timestamp }) => { + if (now - timestamp <= ttl) { + newCache.set(key, { + data: deserialize(data), + timestamp + }) + } + }) + + setCache(newCache) + } catch (error) { + console.error('Failed to import cache:', error) + } + }, [ttl, deserialize]) + + return { + get, + set, + remove, + clear, + has, + prune, + getOrSet, + size: cache.size, + hits, + misses, + hitRate: hits + misses > 0 ? hits / (hits + misses) : 0, + exportCache, + importCache + } +} diff --git a/src/hooks/use-drag-and-drop.ts b/src/hooks/use-drag-and-drop.ts new file mode 100644 index 0000000..120b0ab --- /dev/null +++ b/src/hooks/use-drag-and-drop.ts @@ -0,0 +1,123 @@ +import { useState, useCallback, useRef } from 'react' + +export interface DragItem { + id: string + data: T + type?: string +} + +export interface DropZone { + id: string + accepts?: string[] +} + +export interface DragState { + isDragging: boolean + draggedItem: DragItem | null + draggedOver: string | null +} + +export function useDragAndDrop() { + const [dragState, setDragState] = useState>({ + isDragging: false, + draggedItem: null, + draggedOver: null + }) + + const dragImageRef = useRef(null) + + const startDrag = useCallback((item: DragItem, event?: React.DragEvent) => { + setDragState({ + isDragging: true, + draggedItem: item, + draggedOver: null + }) + + if (event && dragImageRef.current) { + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setDragImage(dragImageRef.current, 0, 0) + } + }, []) + + const endDrag = useCallback(() => { + setDragState({ + isDragging: false, + draggedItem: null, + draggedOver: null + }) + }, []) + + const dragOver = useCallback((zoneId: string) => { + setDragState(prev => ({ + ...prev, + draggedOver: zoneId + })) + }, []) + + const dragLeave = useCallback(() => { + setDragState(prev => ({ + ...prev, + draggedOver: null + })) + }, []) + + const canDrop = useCallback((zone: DropZone): boolean => { + if (!dragState.draggedItem) return false + if (!zone.accepts || zone.accepts.length === 0) return true + + return zone.accepts.includes(dragState.draggedItem.type || '') + }, [dragState.draggedItem]) + + const getDragHandlers = useCallback((item: DragItem) => ({ + draggable: true, + onDragStart: (e: React.DragEvent) => { + e.dataTransfer.effectAllowed = 'move' + startDrag(item, e) + }, + onDragEnd: () => { + endDrag() + } + }), [startDrag, endDrag]) + + const getDropHandlers = useCallback((zone: DropZone, onDrop: (item: DragItem) => void) => ({ + onDragOver: (e: React.DragEvent) => { + if (canDrop(zone)) { + e.preventDefault() + dragOver(zone.id) + } + }, + onDragEnter: (e: React.DragEvent) => { + if (canDrop(zone)) { + e.preventDefault() + dragOver(zone.id) + } + }, + onDragLeave: () => { + dragLeave() + }, + onDrop: (e: React.DragEvent) => { + e.preventDefault() + if (dragState.draggedItem && canDrop(zone)) { + onDrop(dragState.draggedItem) + } + endDrag() + } + }), [dragState.draggedItem, canDrop, dragOver, dragLeave, endDrag]) + + const setDragImage = useCallback((element: HTMLElement | null) => { + dragImageRef.current = element + }, []) + + const reset = useCallback(() => { + endDrag() + }, [endDrag]) + + return { + ...dragState, + getDragHandlers, + getDropHandlers, + setDragImage, + canDrop, + reset + } +} diff --git a/src/hooks/use-event-bus.ts b/src/hooks/use-event-bus.ts new file mode 100644 index 0000000..8d12e98 --- /dev/null +++ b/src/hooks/use-event-bus.ts @@ -0,0 +1,99 @@ +import { useState, useCallback, useRef, useEffect } from 'react' + +export interface EventBusEvent { + type: string + payload?: any + timestamp: number +} + +export type EventHandler = (payload: T) => void + +export function useEventBus() { + const listenersRef = useRef>>(new Map()) + const [events, setEvents] = useState([]) + + const emit = useCallback((type: string, payload?: T) => { + const event: EventBusEvent = { + type, + payload, + timestamp: Date.now() + } + + setEvents(prev => [...prev, event]) + + const listeners = listenersRef.current.get(type) + if (listeners) { + listeners.forEach(handler => { + try { + handler(payload) + } catch (error) { + console.error(`Error in event handler for ${type}:`, error) + } + }) + } + }, []) + + const on = useCallback((type: string, handler: EventHandler) => { + if (!listenersRef.current.has(type)) { + listenersRef.current.set(type, new Set()) + } + listenersRef.current.get(type)!.add(handler as EventHandler) + + return () => { + const listeners = listenersRef.current.get(type) + if (listeners) { + listeners.delete(handler as EventHandler) + if (listeners.size === 0) { + listenersRef.current.delete(type) + } + } + } + }, []) + + const off = useCallback((type: string, handler?: EventHandler) => { + if (!handler) { + listenersRef.current.delete(type) + return + } + + const listeners = listenersRef.current.get(type) + if (listeners) { + listeners.delete(handler) + if (listeners.size === 0) { + listenersRef.current.delete(type) + } + } + }, []) + + const once = useCallback((type: string, handler: EventHandler) => { + const wrappedHandler: EventHandler = (payload) => { + handler(payload) + off(type, wrappedHandler as EventHandler) + } + return on(type, wrappedHandler) + }, [on, off]) + + const clear = useCallback(() => { + listenersRef.current.clear() + setEvents([]) + }, []) + + const getListenerCount = useCallback((type?: string): number => { + if (type) { + return listenersRef.current.get(type)?.size || 0 + } + return Array.from(listenersRef.current.values()) + .reduce((sum, listeners) => sum + listeners.size, 0) + }, []) + + return { + emit, + on, + off, + once, + clear, + events, + getListenerCount, + eventTypes: Array.from(listenersRef.current.keys()) + } +} diff --git a/src/hooks/use-optimistic-update.ts b/src/hooks/use-optimistic-update.ts new file mode 100644 index 0000000..5cffd49 --- /dev/null +++ b/src/hooks/use-optimistic-update.ts @@ -0,0 +1,117 @@ +import { useState, useCallback, useRef } from 'react' + +interface OptimisticUpdate { + id: string + previousValue: T + newValue: T + timestamp: number +} + +export function useOptimisticUpdate() { + const [pendingUpdates, setPendingUpdates] = useState>>(new Map()) + const rollbackTimers = useRef>(new Map()) + + const applyOptimistic = useCallback((id: string, previousValue: T, newValue: T) => { + setPendingUpdates(prev => { + const next = new Map(prev) + next.set(id, { + id, + previousValue, + newValue, + timestamp: Date.now() + }) + return next + }) + }, []) + + const commitUpdate = useCallback((id: string) => { + setPendingUpdates(prev => { + const next = new Map(prev) + next.delete(id) + return next + }) + + const timer = rollbackTimers.current.get(id) + if (timer) { + clearTimeout(timer) + rollbackTimers.current.delete(id) + } + }, []) + + const rollbackUpdate = useCallback((id: string) => { + const update = pendingUpdates.get(id) + if (!update) return null + + setPendingUpdates(prev => { + const next = new Map(prev) + next.delete(id) + return next + }) + + const timer = rollbackTimers.current.get(id) + if (timer) { + clearTimeout(timer) + rollbackTimers.current.delete(id) + } + + return update.previousValue + }, [pendingUpdates]) + + const executeOptimistic = useCallback(async ( + id: string, + previousValue: T, + newValue: T, + operation: () => Promise, + options: { timeout?: number; onSuccess?: (result: R) => void; onError?: (error: Error) => void } = {} + ): Promise => { + const { timeout = 30000, onSuccess, onError } = options + + applyOptimistic(id, previousValue, newValue) + + const timeoutTimer = setTimeout(() => { + rollbackUpdate(id) + onError?.(new Error('Operation timed out')) + }, timeout) + + rollbackTimers.current.set(id, timeoutTimer) + + try { + const result = await operation() + clearTimeout(timeoutTimer) + commitUpdate(id) + onSuccess?.(result) + return result + } catch (error) { + clearTimeout(timeoutTimer) + rollbackUpdate(id) + onError?.(error as Error) + return null + } + }, [applyOptimistic, commitUpdate, rollbackUpdate]) + + const getOptimisticValue = useCallback((id: string, currentValue: T): T => { + const update = pendingUpdates.get(id) + return update ? update.newValue : currentValue + }, [pendingUpdates]) + + const hasPendingUpdate = useCallback((id: string): boolean => { + return pendingUpdates.has(id) + }, [pendingUpdates]) + + const clearAll = useCallback(() => { + rollbackTimers.current.forEach(timer => clearTimeout(timer)) + rollbackTimers.current.clear() + setPendingUpdates(new Map()) + }, []) + + return { + applyOptimistic, + commitUpdate, + rollbackUpdate, + executeOptimistic, + getOptimisticValue, + hasPendingUpdate, + pendingCount: pendingUpdates.size, + clearAll + } +} diff --git a/src/hooks/use-polling.ts b/src/hooks/use-polling.ts new file mode 100644 index 0000000..435adb5 --- /dev/null +++ b/src/hooks/use-polling.ts @@ -0,0 +1,124 @@ +import { useState, useEffect, useRef, useCallback } from 'react' + +export interface PollingOptions { + interval: number + enabled?: boolean + onSuccess?: (data: T) => void + onError?: (error: Error) => void + maxRetries?: number + backoffMultiplier?: number + shouldRetry?: (error: Error, retryCount: number) => boolean +} + +export function usePolling( + fetchFn: () => Promise, + options: PollingOptions +) { + const { + interval, + enabled = true, + onSuccess, + onError, + maxRetries = 3, + backoffMultiplier = 1.5, + shouldRetry = () => true + } = options + + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [isPolling, setIsPolling] = useState(enabled) + const [retryCount, setRetryCount] = useState(0) + + const timerRef = useRef | undefined>(undefined) + const isMountedRef = useRef(true) + + const poll = useCallback(async () => { + try { + const result = await fetchFn() + + if (!isMountedRef.current) return + + setData(result) + setError(null) + void setRetryCount(0) + onSuccess?.(result) + + if (isPolling && isMountedRef.current) { + timerRef.current = setTimeout(() => { void poll() }, interval) + } + } catch (err) { + if (!isMountedRef.current) return + + const error = err instanceof Error ? err : new Error('Polling failed') + setError(error) + onError?.(error) + + const currentRetry = retryCount + 1 + + if (currentRetry < maxRetries && shouldRetry(error, currentRetry)) { + void setRetryCount(currentRetry) + const backoffDelay = interval * Math.pow(backoffMultiplier, currentRetry) + + if (isPolling && isMountedRef.current) { + timerRef.current = setTimeout(() => { void poll() }, backoffDelay) + } + } else { + setIsPolling(false) + } + } + }, [fetchFn, interval, isPolling, retryCount, maxRetries, backoffMultiplier, shouldRetry, onSuccess, onError]) + + const start = useCallback(() => { + setIsPolling(true) + void setRetryCount(0) + void poll() + }, [poll]) + + const stop = useCallback(() => { + setIsPolling(false) + if (timerRef.current) { + clearTimeout(timerRef.current) + } + }, []) + + const reset = useCallback(() => { + stop() + setData(null) + setError(null) + setRetryCount(0) + }, [stop]) + + const refresh = useCallback(async () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + } + await poll() + }, [poll]) + + useEffect(() => { + if (enabled && isPolling) { + const startPolling = async () => { + await poll() + } + void startPolling() + } + + return () => { + isMountedRef.current = false + if (timerRef.current) { + clearTimeout(timerRef.current) + } + } + }, [enabled, isPolling, poll]) + + return { + data, + error, + isPolling, + retryCount, + start, + stop, + reset, + refresh + } +} diff --git a/src/hooks/use-queue.ts b/src/hooks/use-queue.ts new file mode 100644 index 0000000..03cc518 --- /dev/null +++ b/src/hooks/use-queue.ts @@ -0,0 +1,207 @@ +import { useState, useCallback, useRef, useEffect } from 'react' + +export interface QueueItem { + id: string + data: T + priority?: number + addedAt: number + startedAt?: number + completedAt?: number + status: 'pending' | 'processing' | 'completed' | 'failed' + error?: Error + retries: number +} + +export interface QueueOptions { + concurrency?: number + maxRetries?: number + retryDelay?: number + autoStart?: boolean +} + +export function useQueue( + processor: (data: T) => Promise, + options: QueueOptions = {} +) { + const { + concurrency = 1, + maxRetries = 3, + retryDelay = 1000, + autoStart = true + } = options + + const [queue, setQueue] = useState[]>([]) + const [processing, setProcessing] = useState[]>([]) + const [completed, setCompleted] = useState[]>([]) + const [failed, setFailed] = useState[]>([]) + const [isRunning, setIsRunning] = useState(autoStart) + + const processingRef = useRef>(new Set()) + const mountedRef = useRef(true) + + const enqueue = useCallback((data: T, priority: number = 0) => { + const item: QueueItem = { + id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + data, + priority, + addedAt: Date.now(), + status: 'pending', + retries: 0 + } + + setQueue(prev => { + const next = [...prev, item] + next.sort((a, b) => (b.priority || 0) - (a.priority || 0)) + return next + }) + + return item.id + }, []) + + const processItem = useCallback(async (item: QueueItem) => { + if (!mountedRef.current) return + + processingRef.current.add(item.id) + + setQueue(prev => prev.filter(i => i.id !== item.id)) + setProcessing(prev => [...prev, { ...item, status: 'processing', startedAt: Date.now() }]) + + try { + await processor(item.data) + + if (!mountedRef.current) return + + const completedItem = { ...item, status: 'completed' as const, completedAt: Date.now() } + + setProcessing(prev => prev.filter(i => i.id !== item.id)) + setCompleted(prev => [...prev, completedItem]) + } catch (error) { + if (!mountedRef.current) return + + const err = error instanceof Error ? error : new Error('Processing failed') + + if (item.retries < maxRetries) { + await new Promise(resolve => setTimeout(resolve, retryDelay)) + + if (!mountedRef.current) return + + const retryItem = { ...item, retries: item.retries + 1 } + setProcessing(prev => prev.filter(i => i.id !== item.id)) + setQueue(prev => { + const next = [...prev, retryItem] + next.sort((a, b) => (b.priority || 0) - (a.priority || 0)) + return next + }) + } else { + const failedItem = { + ...item, + status: 'failed' as const, + completedAt: Date.now(), + error: err + } + + setProcessing(prev => prev.filter(i => i.id !== item.id)) + setFailed(prev => [...prev, failedItem]) + } + } finally { + processingRef.current.delete(item.id) + } + }, [processor, maxRetries, retryDelay]) + + const processNext = useCallback(async () => { + if (!isRunning || processingRef.current.size >= concurrency) return + + setQueue(prev => { + if (prev.length === 0) return prev + + const nextItem = prev[0] + processItem(nextItem) + + return prev + }) + }, [isRunning, concurrency, processItem]) + + useEffect(() => { + if (!isRunning) return + + const interval = setInterval(() => { + if (processingRef.current.size < concurrency && queue.length > 0) { + processNext() + } + }, 100) + + return () => clearInterval(interval) + }, [isRunning, concurrency, queue.length, processNext]) + + useEffect(() => { + return () => { + mountedRef.current = false + } + }, []) + + const start = useCallback(() => { + setIsRunning(true) + }, []) + + const pause = useCallback(() => { + setIsRunning(false) + }, []) + + const clear = useCallback(() => { + setQueue([]) + }, []) + + const clearCompleted = useCallback(() => { + setCompleted([]) + }, []) + + const clearFailed = useCallback(() => { + setFailed([]) + }, []) + + const retryFailed = useCallback(() => { + setFailed(prev => { + const items = prev.map(item => ({ + ...item, + status: 'pending' as const, + retries: 0, + error: undefined + })) + + setQueue(q => { + const next = [...q, ...items] + next.sort((a, b) => (b.priority || 0) - (a.priority || 0)) + return next + }) + + return [] + }) + }, []) + + const remove = useCallback((id: string) => { + setQueue(prev => prev.filter(item => item.id !== id)) + }, []) + + return { + queue, + processing, + completed, + failed, + isRunning, + stats: { + pending: queue.length, + processing: processing.length, + completed: completed.length, + failed: failed.length, + total: queue.length + processing.length + completed.length + failed.length + }, + enqueue, + start, + pause, + clear, + clearCompleted, + clearFailed, + retryFailed, + remove + } +} diff --git a/src/hooks/use-virtual-scroll.ts b/src/hooks/use-virtual-scroll.ts new file mode 100644 index 0000000..af277bb --- /dev/null +++ b/src/hooks/use-virtual-scroll.ts @@ -0,0 +1,96 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react' + +export interface VirtualScrollOptions { + itemHeight: number + overscan?: number + containerHeight?: number +} + +export function useVirtualScroll( + items: T[], + options: VirtualScrollOptions +) { + const { + itemHeight, + overscan = 3, + containerHeight = 600 + } = options + + const [scrollTop, setScrollTop] = useState(0) + const containerRef = useRef(null) + + const totalHeight = items.length * itemHeight + + const visibleRange = useMemo(() => { + const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan) + const endIndex = Math.min( + items.length, + Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan + ) + + return { startIndex, endIndex } + }, [scrollTop, itemHeight, containerHeight, items.length, overscan]) + + const visibleItems = useMemo(() => { + return items.slice(visibleRange.startIndex, visibleRange.endIndex).map((item, index) => ({ + item, + index: visibleRange.startIndex + index, + offsetTop: (visibleRange.startIndex + index) * itemHeight + })) + }, [items, visibleRange, itemHeight]) + + const handleScroll = useCallback((e: Event) => { + const target = e.target as HTMLDivElement + setScrollTop(target.scrollTop) + }, []) + + useEffect(() => { + const container = containerRef.current + if (!container) return + + container.addEventListener('scroll', handleScroll, { passive: true }) + return () => container.removeEventListener('scroll', handleScroll) + }, [handleScroll]) + + const scrollToIndex = useCallback((index: number, align: 'start' | 'center' | 'end' = 'start') => { + const container = containerRef.current + if (!container) return + + let scrollPosition: number + + switch (align) { + case 'center': + scrollPosition = index * itemHeight - containerHeight / 2 + itemHeight / 2 + break + case 'end': + scrollPosition = index * itemHeight - containerHeight + itemHeight + break + default: + scrollPosition = index * itemHeight + } + + container.scrollTo({ + top: Math.max(0, Math.min(scrollPosition, totalHeight - containerHeight)), + behavior: 'smooth' + }) + }, [itemHeight, containerHeight, totalHeight]) + + const scrollToTop = useCallback(() => { + containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }) + }, []) + + const scrollToBottom = useCallback(() => { + containerRef.current?.scrollTo({ top: totalHeight, behavior: 'smooth' }) + }, [totalHeight]) + + return { + containerRef, + totalHeight, + visibleItems, + visibleRange, + scrollToIndex, + scrollToTop, + scrollToBottom, + scrollTop + } +} diff --git a/src/hooks/use-websocket.ts b/src/hooks/use-websocket.ts new file mode 100644 index 0000000..075b5d6 --- /dev/null +++ b/src/hooks/use-websocket.ts @@ -0,0 +1,138 @@ +import { useState, useCallback, useRef, useEffect } from 'react' + +export interface WebSocketOptions { + reconnect?: boolean + reconnectAttempts?: number + reconnectInterval?: number + heartbeatInterval?: number + heartbeatMessage?: string + onOpen?: (event: Event) => void + onClose?: (event: CloseEvent) => void + onError?: (event: Event) => void + onMessage?: (event: MessageEvent) => void +} + +export function useWebSocket(url: string | null, options: WebSocketOptions = {}) { + const { + reconnect = true, + reconnectAttempts = 5, + reconnectInterval = 3000, + heartbeatInterval = 30000, + heartbeatMessage = 'ping', + onOpen, + onClose, + onError, + onMessage + } = options + + const [readyState, setReadyState] = useState(WebSocket.CONNECTING) + const [lastMessage, setLastMessage] = useState(null) + const [reconnectCount, setReconnectCount] = useState(0) + + const wsRef = useRef(null) + const reconnectTimeoutRef = useRef | undefined>(undefined) + const heartbeatIntervalRef = useRef | undefined>(undefined) + const mountedRef = useRef(true) + + const connect = useCallback(() => { + if (!url || !mountedRef.current) return + + try { + const ws = new WebSocket(url) + + ws.onopen = (event) => { + setReadyState(WebSocket.OPEN) + void setReconnectCount(0) + onOpen?.(event) + + if (heartbeatInterval > 0) { + heartbeatIntervalRef.current = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(heartbeatMessage) + } + }, heartbeatInterval) + } + } + + ws.onclose = (event) => { + setReadyState(WebSocket.CLOSED) + onClose?.(event) + + if (heartbeatIntervalRef.current) { + clearInterval(heartbeatIntervalRef.current) + } + + if (reconnect && reconnectCount < reconnectAttempts && mountedRef.current) { + reconnectTimeoutRef.current = setTimeout(() => { + void setReconnectCount(prev => prev + 1) + void connect() + }, reconnectInterval) + } + } + + ws.onerror = (event) => { + setReadyState(WebSocket.CLOSED) + onError?.(event) + } + + ws.onmessage = (event) => { + setLastMessage(event) + onMessage?.(event) + } + + wsRef.current = ws + } catch (error) { + console.error('WebSocket connection error:', error) + } + }, [url, reconnect, reconnectAttempts, reconnectInterval, reconnectCount, heartbeatInterval, heartbeatMessage, onOpen, onClose, onError, onMessage]) + + const disconnect = useCallback(() => { + if (wsRef.current) { + wsRef.current.close() + wsRef.current = null + } + + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + } + + if (heartbeatIntervalRef.current) { + clearInterval(heartbeatIntervalRef.current) + } + }, []) + + const send = useCallback((data: string | ArrayBuffer | Blob) => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(data) + return true + } + return false + }, []) + + const sendJson = useCallback((data: any) => { + return send(JSON.stringify(data)) + }, [send]) + + useEffect(() => { + void connect() + + return () => { + mountedRef.current = false + disconnect() + } + }, [connect, disconnect]) + + return { + readyState, + lastMessage, + send, + sendJson, + connect, + disconnect, + reconnectCount, + isConnecting: readyState === WebSocket.CONNECTING, + isOpen: readyState === WebSocket.OPEN, + isClosing: readyState === WebSocket.CLOSING, + isClosed: readyState === WebSocket.CLOSED + } +}