mirror of
https://github.com/johndoe6345789/workforce-pay-bill-p.git
synced 2026-04-24 13:24:57 +00:00
Generated by Spark: Expand custom hook library, expand ui component library
This commit is contained in:
375
LIBRARY_EXTENSIONS.md
Normal file
375
LIBRARY_EXTENSIONS.md
Normal file
@@ -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()
|
||||
|
||||
<div {...getDragHandlers({ id: '1', data: item })}>Draggable</div>
|
||||
<div {...getDropHandlers({ id: 'zone1' }, handleDrop)}>Drop Zone</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
<MultiSelect
|
||||
options={workers}
|
||||
value={selectedWorkers}
|
||||
onChange={setSelectedWorkers}
|
||||
maxSelections={5}
|
||||
searchable
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
<Timeline
|
||||
items={auditLogItems}
|
||||
orientation="vertical"
|
||||
onItemClick={(item) => 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
|
||||
<ValidationIndicator
|
||||
rules={passwordRules}
|
||||
value={password}
|
||||
showOnlyFailed
|
||||
/>
|
||||
|
||||
<Banner variant="warning" dismissible>
|
||||
Your compliance document expires in 3 days
|
||||
</Banner>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
<Stepper
|
||||
steps={onboardingSteps}
|
||||
currentStep={currentIndex}
|
||||
onStepClick={goToStep}
|
||||
variant="default"
|
||||
allowSkip
|
||||
/>
|
||||
|
||||
<StepperNav
|
||||
currentStep={step}
|
||||
totalSteps={5}
|
||||
onNext={handleNext}
|
||||
onComplete={handleComplete}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ComboBox
|
||||
**Purpose**: Searchable dropdown with grouping and descriptions.
|
||||
|
||||
**Features**:
|
||||
- Searchable options
|
||||
- Group support
|
||||
- Option descriptions
|
||||
- Empty state handling
|
||||
|
||||
**Usage**:
|
||||
```tsx
|
||||
<ComboBox
|
||||
options={clientOptions}
|
||||
value={selectedClient}
|
||||
onChange={setSelectedClient}
|
||||
showGroups
|
||||
searchPlaceholder="Search clients..."
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
<TreeView
|
||||
data={organizationStructure}
|
||||
selectedId={selectedNode}
|
||||
onSelect={handleSelect}
|
||||
expandedByDefault
|
||||
showLines
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
<DataGroup label="Skills" collapsible>
|
||||
<DataPill variant="primary" removable onRemove={removeSkill}>
|
||||
JavaScript
|
||||
</DataPill>
|
||||
<DataPill variant="success">React</DataPill>
|
||||
</DataGroup>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
<AdvancedTable
|
||||
data={timesheets}
|
||||
columns={columns}
|
||||
keyExtractor={(row) => 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.
|
||||
@@ -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() {
|
||||
<CardContent className="space-y-6">
|
||||
<Stepper
|
||||
steps={stepperSteps}
|
||||
currentStep={currentStepIndex}
|
||||
onStepClick={(index) => console.log('Go to step', index)}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<div>
|
||||
|
||||
165
src/components/ui/advanced-table.tsx
Normal file
165
src/components/ui/advanced-table.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface Column<T> {
|
||||
key: string
|
||||
header: string
|
||||
render?: (row: T, index: number) => React.ReactNode
|
||||
width?: string
|
||||
align?: 'left' | 'center' | 'right'
|
||||
sortable?: boolean
|
||||
sticky?: boolean
|
||||
}
|
||||
|
||||
export interface AdvancedTableProps<T> {
|
||||
data: T[]
|
||||
columns: Column<T>[]
|
||||
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<T>({
|
||||
data,
|
||||
columns,
|
||||
keyExtractor,
|
||||
className,
|
||||
hoverable = true,
|
||||
striped = false,
|
||||
bordered = true,
|
||||
compact = false,
|
||||
onRowClick,
|
||||
emptyMessage = 'No data available',
|
||||
loading = false,
|
||||
stickyHeader = false
|
||||
}: AdvancedTableProps<T>) {
|
||||
const [sortKey, setSortKey] = React.useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('w-full overflow-auto', className)}>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead
|
||||
className={cn(
|
||||
'bg-muted/50',
|
||||
stickyHeader && 'sticky top-0 z-10 bg-muted'
|
||||
)}
|
||||
>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={cn(
|
||||
'px-4 py-3 text-left font-semibold text-muted-foreground',
|
||||
bordered && 'border-b border-border',
|
||||
compact && 'px-2 py-2',
|
||||
col.align === 'center' && 'text-center',
|
||||
col.align === 'right' && 'text-right',
|
||||
col.sortable && 'cursor-pointer select-none hover:text-foreground',
|
||||
col.sticky && 'sticky left-0 bg-muted z-20'
|
||||
)}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
onClick={() => col.sortable && handleSort(col.key)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{col.header}
|
||||
{col.sortable && sortKey === col.key && (
|
||||
<svg
|
||||
className={cn(
|
||||
'h-4 w-4 transition-transform',
|
||||
sortDirection === 'desc' && 'rotate-180'
|
||||
)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedData.length > 0 ? (
|
||||
sortedData.map((row, index) => (
|
||||
<tr
|
||||
key={keyExtractor(row)}
|
||||
className={cn(
|
||||
hoverable && 'hover:bg-muted/50 transition-colors',
|
||||
striped && index % 2 === 1 && 'bg-muted/20',
|
||||
onRowClick && 'cursor-pointer'
|
||||
)}
|
||||
onClick={() => onRowClick?.(row, index)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={cn(
|
||||
'px-4 py-3',
|
||||
bordered && 'border-b border-border',
|
||||
compact && 'px-2 py-2',
|
||||
col.align === 'center' && 'text-center',
|
||||
col.align === 'right' && 'text-right',
|
||||
col.sticky && 'sticky left-0 bg-background'
|
||||
)}
|
||||
>
|
||||
{col.render ? col.render(row, index) : (row as any)[col.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
className="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
190
src/components/ui/combo-box.tsx
Normal file
190
src/components/ui/combo-box.tsx
Normal file
@@ -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<HTMLDivElement>(null)
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(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<string, ComboBoxOption[]>)
|
||||
}, [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 (
|
||||
<div ref={containerRef} className={cn('relative', className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm',
|
||||
'ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
<span className={selectedOption ? 'text-foreground' : 'text-muted-foreground'}>
|
||||
{selectedOption?.label || placeholder}
|
||||
</span>
|
||||
<svg
|
||||
className={cn('h-4 w-4 transition-transform', isOpen && 'rotate-180')}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isOpen && !disabled && (
|
||||
<div className="absolute z-50 mt-2 w-full rounded-md border bg-popover shadow-lg">
|
||||
<div className="border-b p-2">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto p-1">
|
||||
{Object.keys(groupedOptions).length > 0 ? (
|
||||
Object.entries(groupedOptions).map(([group, opts]) => (
|
||||
<div key={group}>
|
||||
{group && (
|
||||
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground">
|
||||
{group}
|
||||
</div>
|
||||
)}
|
||||
{opts.map(opt => {
|
||||
const isSelected = opt.value === value
|
||||
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => !opt.disabled && handleSelect(opt.value)}
|
||||
disabled={opt.disabled}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer flex-col items-start gap-0.5 rounded-sm px-3 py-2 text-sm outline-none transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isSelected && 'bg-accent text-accent-foreground',
|
||||
opt.disabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
{isSelected && (
|
||||
<svg
|
||||
className="h-4 w-4 flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<span className={!isSelected ? 'ml-6' : ''}>{opt.label}</span>
|
||||
</div>
|
||||
{opt.description && (
|
||||
<span className={cn(
|
||||
'text-xs text-muted-foreground',
|
||||
isSelected ? 'ml-6' : 'ml-6'
|
||||
)}>
|
||||
{opt.description}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-2 text-center text-sm text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
src/components/ui/data-pill.tsx
Normal file
110
src/components/ui/data-pill.tsx
Normal file
@@ -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 (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border font-medium whitespace-nowrap',
|
||||
variantStyles[variant],
|
||||
sizeStyles[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{icon && <span className="flex-shrink-0">{icon}</span>}
|
||||
<span>{children}</span>
|
||||
{removable && onRemove && (
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="flex-shrink-0 hover:opacity-70 transition-opacity"
|
||||
aria-label="Remove"
|
||||
>
|
||||
<X size={size === 'sm' ? 12 : size === 'lg' ? 16 : 14} weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{label && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm font-semibold text-muted-foreground',
|
||||
collapsible && 'cursor-pointer select-none'
|
||||
)}
|
||||
onClick={() => collapsible && setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
{collapsible && (
|
||||
<svg
|
||||
className={cn('h-4 w-4 transition-transform', !isCollapsed && 'rotate-90')}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)}
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
{(!collapsible || !isCollapsed) && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
186
src/components/ui/multi-select.tsx
Normal file
186
src/components/ui/multi-select.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||
<div ref={containerRef} className={cn('relative', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-10 w-full flex-wrap gap-1 rounded-md border border-input bg-background px-3 py-2 text-sm cursor-pointer',
|
||||
'ring-offset-background focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
|
||||
disabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
>
|
||||
{selectedOptions.length > 0 ? (
|
||||
<>
|
||||
{selectedOptions.map(opt => (
|
||||
<span
|
||||
key={opt.value}
|
||||
className="inline-flex items-center gap-1 rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground"
|
||||
>
|
||||
{opt.label}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleRemove(opt.value, e)}
|
||||
className="hover:text-destructive"
|
||||
disabled={disabled}
|
||||
>
|
||||
<X size={12} weight="bold" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{selectedOptions.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearAll}
|
||||
className="ml-auto text-xs text-muted-foreground hover:text-foreground"
|
||||
disabled={disabled}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && !disabled && (
|
||||
<div className="absolute z-50 mt-2 w-full rounded-md border bg-popover shadow-lg">
|
||||
{searchable && (
|
||||
<div className="border-b p-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-60 overflow-y-auto p-1">
|
||||
{filteredOptions.length > 0 ? (
|
||||
filteredOptions.map(opt => {
|
||||
const isSelected = value.includes(opt.value)
|
||||
const isDisabled = opt.disabled || (maxSelections ? value.length >= maxSelections && !isSelected : false)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={opt.value}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm outline-none transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isSelected && 'bg-accent text-accent-foreground',
|
||||
isDisabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
onClick={() => !isDisabled && handleToggle(opt.value)}
|
||||
>
|
||||
<div className={cn(
|
||||
'h-4 w-4 rounded border',
|
||||
isSelected ? 'border-primary bg-primary' : 'border-input'
|
||||
)}>
|
||||
{isSelected && (
|
||||
<svg
|
||||
className="h-full w-full text-primary-foreground"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M3 8l3 3 7-7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span>{opt.label}</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="px-3 py-2 text-center text-sm text-muted-foreground">
|
||||
No options found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
261
src/components/ui/stepper-enhanced.tsx
Normal file
261
src/components/ui/stepper-enhanced.tsx
Normal file
@@ -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 (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
{steps.map((step, index) => {
|
||||
const isActive = index === currentStep
|
||||
const isCompleted = index < currentStep
|
||||
const isClickable = allowSkip || index <= currentStep
|
||||
|
||||
return (
|
||||
<button
|
||||
key={step.id}
|
||||
onClick={() => handleStepClick(index)}
|
||||
disabled={!isClickable || !onStepClick}
|
||||
className={cn(
|
||||
'h-2 rounded-full transition-all',
|
||||
isActive && 'w-8 bg-primary',
|
||||
isCompleted && 'w-2 bg-success',
|
||||
!isActive && !isCompleted && 'w-2 bg-muted',
|
||||
isClickable && 'cursor-pointer hover:opacity-70',
|
||||
!isClickable && 'cursor-not-allowed'
|
||||
)}
|
||||
aria-label={step.label}
|
||||
aria-current={isActive ? 'step' : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex',
|
||||
isVertical ? 'flex-col' : 'flex-row items-start',
|
||||
isCompact && !isVertical && 'items-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{steps.map((step, index) => {
|
||||
const isActive = index === currentStep
|
||||
const isCompleted = index < currentStep
|
||||
const isLast = index === steps.length - 1
|
||||
const isClickable = allowSkip || index <= currentStep
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.id}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex',
|
||||
isVertical ? 'flex-row gap-3' : 'flex-col items-center gap-2',
|
||||
isCompact && 'gap-1'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleStepClick(index)}
|
||||
disabled={!isClickable || !onStepClick}
|
||||
className={cn(
|
||||
'flex h-10 w-10 items-center justify-center rounded-full border-2 font-semibold transition-colors',
|
||||
isActive && 'border-primary bg-primary text-primary-foreground',
|
||||
isCompleted && 'border-success bg-success text-success-foreground',
|
||||
!isActive && !isCompleted && 'border-muted bg-background text-muted-foreground',
|
||||
isClickable && 'cursor-pointer hover:opacity-80',
|
||||
!isClickable && 'cursor-not-allowed',
|
||||
isCompact && 'h-8 w-8 text-sm'
|
||||
)}
|
||||
aria-label={step.label}
|
||||
aria-current={isActive ? 'step' : undefined}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<span className={isCompact ? 'text-xs' : 'text-sm'}>
|
||||
{index + 1}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!isCompact && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col',
|
||||
isVertical ? 'flex-1' : 'items-center text-center max-w-[120px]'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
isActive && 'text-foreground',
|
||||
!isActive && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
{step.description && (
|
||||
<span className="text-xs text-muted-foreground mt-0.5">
|
||||
{step.description}
|
||||
</span>
|
||||
)}
|
||||
{step.optional && (
|
||||
<span className="text-xs text-muted-foreground italic mt-0.5">
|
||||
Optional
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isLast && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
isVertical ? 'h-8 w-10 ml-5' : 'flex-1 h-0.5 mx-2',
|
||||
isCompact && !isVertical && 'mx-1'
|
||||
)}
|
||||
>
|
||||
{isVertical ? (
|
||||
<div className="h-full w-0.5 bg-border" />
|
||||
) : (
|
||||
<div className="h-full w-full bg-border" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={cn('flex items-center justify-between gap-4', className)}>
|
||||
<button
|
||||
onClick={onPrevious}
|
||||
disabled={isFirst || disablePrevious}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md',
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'transition-colors'
|
||||
)}
|
||||
>
|
||||
<CaretLeft size={16} weight="bold" />
|
||||
{previousLabel}
|
||||
</button>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Step {currentStep + 1} of {totalSteps}
|
||||
</div>
|
||||
|
||||
{isLast ? (
|
||||
<button
|
||||
onClick={onComplete}
|
||||
disabled={disableNext}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md',
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'transition-colors'
|
||||
)}
|
||||
>
|
||||
{completeLabel}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={disableNext}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md',
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'transition-colors'
|
||||
)}
|
||||
>
|
||||
{nextLabel}
|
||||
<CaretRight size={16} weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
117
src/components/ui/timeline-enhanced.tsx
Normal file
117
src/components/ui/timeline-enhanced.tsx
Normal file
@@ -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<string, any>
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'relative',
|
||||
isVertical ? 'space-y-8' : 'flex gap-8 overflow-x-auto pb-4',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1
|
||||
const timestamp = typeof item.timestamp === 'string'
|
||||
? item.timestamp
|
||||
: item.timestamp.toLocaleString()
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'relative flex gap-4',
|
||||
isVertical ? 'flex-row' : 'flex-col min-w-[200px]',
|
||||
onItemClick && 'cursor-pointer hover:opacity-80 transition-opacity'
|
||||
)}
|
||||
onClick={() => onItemClick?.(item)}
|
||||
>
|
||||
<div className={cn(
|
||||
'relative flex',
|
||||
isVertical ? 'flex-col items-center' : 'flex-row items-center justify-center'
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 bg-background',
|
||||
item.status === 'completed' && 'border-success bg-success text-success-foreground',
|
||||
item.status === 'current' && 'border-accent bg-accent text-accent-foreground',
|
||||
item.status === 'upcoming' && 'border-muted-foreground bg-muted text-muted-foreground',
|
||||
item.status === 'error' && 'border-destructive bg-destructive text-destructive-foreground',
|
||||
!item.status && 'border-primary bg-primary text-primary-foreground'
|
||||
)}
|
||||
>
|
||||
{item.icon || (
|
||||
<span className="text-sm font-medium">{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isLast && (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-border',
|
||||
isVertical
|
||||
? 'absolute top-10 h-full w-0.5 left-1/2 -translate-x-1/2'
|
||||
: 'h-0.5 w-full'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
'flex-1',
|
||||
isVertical ? 'pb-8' : 'pt-4'
|
||||
)}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="font-semibold text-sm">{item.title}</h4>
|
||||
<time className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{timestamp}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{item.description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{item.metadata && Object.keys(item.metadata).length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{Object.entries(item.metadata).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{key}:</span>
|
||||
<span className="font-medium">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
117
src/components/ui/tree-view.tsx
Normal file
117
src/components/ui/tree-view.tsx
Normal file
@@ -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<string, any>
|
||||
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<Set<string>>(
|
||||
() => {
|
||||
if (!expandedByDefault) return new Set()
|
||||
const expanded = new Set<string>()
|
||||
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 (
|
||||
<div key={node.id} className="select-none">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 py-1.5 px-2 rounded-md cursor-pointer transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isSelected && 'bg-accent text-accent-foreground font-medium',
|
||||
node.disabled && 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 20 + 8}px` }}
|
||||
onClick={() => !node.disabled && onSelect?.(node)}
|
||||
>
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleExpand(node.id)
|
||||
}}
|
||||
className="flex-shrink-0 p-0.5 hover:bg-accent-foreground/10 rounded"
|
||||
>
|
||||
<svg
|
||||
className={cn('h-4 w-4 transition-transform', isExpanded && 'rotate-90')}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{!hasChildren && showLines && <div className="w-4 flex-shrink-0" />}
|
||||
{node.icon && <div className="flex-shrink-0">{node.icon}</div>}
|
||||
<span className="text-sm flex-1">{node.label}</span>
|
||||
{node.metadata && Object.keys(node.metadata).length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{Object.values(node.metadata)[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasChildren && isExpanded && (
|
||||
<div className={cn(showLines && 'border-l border-border ml-3')}>
|
||||
{node.children!.map(child => renderNode(child, level + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-0.5', className)}>
|
||||
{data.map(node => renderNode(node))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
src/components/ui/validation-banner.tsx
Normal file
141
src/components/ui/validation-banner.tsx
Normal file
@@ -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 (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{displayedRules.map(result => (
|
||||
<div
|
||||
key={result.id}
|
||||
className={cn(
|
||||
'flex items-start gap-2 text-sm',
|
||||
result.passed ? 'text-success' : 'text-destructive'
|
||||
)}
|
||||
>
|
||||
{result.passed ? (
|
||||
<Check size={16} weight="bold" className="mt-0.5 flex-shrink-0" />
|
||||
) : (
|
||||
<X size={16} weight="bold" className="mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
<span>{result.message || result.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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: <Info size={20} weight="fill" />,
|
||||
success: <Check size={20} weight="bold" />,
|
||||
warning: <Warning size={20} weight="fill" />,
|
||||
error: <X size={20} weight="bold" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border p-4',
|
||||
variantStyles[variant],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{icon || defaultIcons[variant]}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-1">
|
||||
{title && (
|
||||
<h4 className="font-semibold text-sm">{title}</h4>
|
||||
)}
|
||||
<div className="text-sm">{children}</div>
|
||||
</div>
|
||||
|
||||
{(actions || dismissible) && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{actions}
|
||||
{dismissible && (
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="text-current hover:opacity-70 transition-opacity"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X size={16} weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
151
src/hooks/use-bulk-operations.ts
Normal file
151
src/hooks/use-bulk-operations.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
export interface BulkOperationState<T> {
|
||||
selectedItems: Set<string>
|
||||
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<T = any>() {
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set())
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [errors, setErrors] = useState<Array<{ id: string; error: string }>>([])
|
||||
const [results, setResults] = useState<Array<{ id: string; success: boolean; data?: any }>>([])
|
||||
|
||||
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 <R = any>(
|
||||
operation: (id: string) => Promise<R>,
|
||||
options: BulkOperationOptions = {}
|
||||
): Promise<void> => {
|
||||
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
|
||||
}
|
||||
}
|
||||
183
src/hooks/use-cache.ts
Normal file
183
src/hooks/use-cache.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
|
||||
export interface CacheOptions<T> {
|
||||
ttl?: number
|
||||
maxSize?: number
|
||||
serialize?: (data: T) => string
|
||||
deserialize?: (data: string) => T
|
||||
}
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export function useCache<T>(options: CacheOptions<T> = {}) {
|
||||
const {
|
||||
ttl = 5 * 60 * 1000,
|
||||
maxSize = 100,
|
||||
serialize = JSON.stringify,
|
||||
deserialize = JSON.parse
|
||||
} = options
|
||||
|
||||
const [cache, setCache] = useState<Map<string, CacheEntry<T>>>(new Map())
|
||||
const [hits, setHits] = useState(0)
|
||||
const [misses, setMisses] = useState(0)
|
||||
|
||||
const isExpired = useCallback((entry: CacheEntry<T>): 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<T>
|
||||
): Promise<T> => {
|
||||
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<string, CacheEntry<T>>()
|
||||
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
|
||||
}
|
||||
}
|
||||
123
src/hooks/use-drag-and-drop.ts
Normal file
123
src/hooks/use-drag-and-drop.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
|
||||
export interface DragItem<T = any> {
|
||||
id: string
|
||||
data: T
|
||||
type?: string
|
||||
}
|
||||
|
||||
export interface DropZone {
|
||||
id: string
|
||||
accepts?: string[]
|
||||
}
|
||||
|
||||
export interface DragState<T = any> {
|
||||
isDragging: boolean
|
||||
draggedItem: DragItem<T> | null
|
||||
draggedOver: string | null
|
||||
}
|
||||
|
||||
export function useDragAndDrop<T = any>() {
|
||||
const [dragState, setDragState] = useState<DragState<T>>({
|
||||
isDragging: false,
|
||||
draggedItem: null,
|
||||
draggedOver: null
|
||||
})
|
||||
|
||||
const dragImageRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
const startDrag = useCallback((item: DragItem<T>, 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<T>) => ({
|
||||
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<T>) => 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
|
||||
}
|
||||
}
|
||||
99
src/hooks/use-event-bus.ts
Normal file
99
src/hooks/use-event-bus.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
|
||||
export interface EventBusEvent {
|
||||
type: string
|
||||
payload?: any
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type EventHandler<T = any> = (payload: T) => void
|
||||
|
||||
export function useEventBus() {
|
||||
const listenersRef = useRef<Map<string, Set<EventHandler>>>(new Map())
|
||||
const [events, setEvents] = useState<EventBusEvent[]>([])
|
||||
|
||||
const emit = useCallback(<T = any>(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(<T = any>(type: string, handler: EventHandler<T>) => {
|
||||
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(<T = any>(type: string, handler: EventHandler<T>) => {
|
||||
const wrappedHandler: EventHandler<T> = (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())
|
||||
}
|
||||
}
|
||||
117
src/hooks/use-optimistic-update.ts
Normal file
117
src/hooks/use-optimistic-update.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
|
||||
interface OptimisticUpdate<T> {
|
||||
id: string
|
||||
previousValue: T
|
||||
newValue: T
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export function useOptimisticUpdate<T>() {
|
||||
const [pendingUpdates, setPendingUpdates] = useState<Map<string, OptimisticUpdate<T>>>(new Map())
|
||||
const rollbackTimers = useRef<Map<string, NodeJS.Timeout>>(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 <R = void>(
|
||||
id: string,
|
||||
previousValue: T,
|
||||
newValue: T,
|
||||
operation: () => Promise<R>,
|
||||
options: { timeout?: number; onSuccess?: (result: R) => void; onError?: (error: Error) => void } = {}
|
||||
): Promise<R | null> => {
|
||||
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
|
||||
}
|
||||
}
|
||||
124
src/hooks/use-polling.ts
Normal file
124
src/hooks/use-polling.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
export interface PollingOptions<T> {
|
||||
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<T>(
|
||||
fetchFn: () => Promise<T>,
|
||||
options: PollingOptions<T>
|
||||
) {
|
||||
const {
|
||||
interval,
|
||||
enabled = true,
|
||||
onSuccess,
|
||||
onError,
|
||||
maxRetries = 3,
|
||||
backoffMultiplier = 1.5,
|
||||
shouldRetry = () => true
|
||||
} = options
|
||||
|
||||
const [data, setData] = useState<T | null>(null)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [isPolling, setIsPolling] = useState(enabled)
|
||||
const [retryCount, setRetryCount] = useState(0)
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | 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
|
||||
}
|
||||
}
|
||||
207
src/hooks/use-queue.ts
Normal file
207
src/hooks/use-queue.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
|
||||
export interface QueueItem<T> {
|
||||
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<T, R = void>(
|
||||
processor: (data: T) => Promise<R>,
|
||||
options: QueueOptions = {}
|
||||
) {
|
||||
const {
|
||||
concurrency = 1,
|
||||
maxRetries = 3,
|
||||
retryDelay = 1000,
|
||||
autoStart = true
|
||||
} = options
|
||||
|
||||
const [queue, setQueue] = useState<QueueItem<T>[]>([])
|
||||
const [processing, setProcessing] = useState<QueueItem<T>[]>([])
|
||||
const [completed, setCompleted] = useState<QueueItem<T>[]>([])
|
||||
const [failed, setFailed] = useState<QueueItem<T>[]>([])
|
||||
const [isRunning, setIsRunning] = useState(autoStart)
|
||||
|
||||
const processingRef = useRef<Set<string>>(new Set())
|
||||
const mountedRef = useRef(true)
|
||||
|
||||
const enqueue = useCallback((data: T, priority: number = 0) => {
|
||||
const item: QueueItem<T> = {
|
||||
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<T>) => {
|
||||
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
|
||||
}
|
||||
}
|
||||
96
src/hooks/use-virtual-scroll.ts
Normal file
96
src/hooks/use-virtual-scroll.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
|
||||
export interface VirtualScrollOptions {
|
||||
itemHeight: number
|
||||
overscan?: number
|
||||
containerHeight?: number
|
||||
}
|
||||
|
||||
export function useVirtualScroll<T>(
|
||||
items: T[],
|
||||
options: VirtualScrollOptions
|
||||
) {
|
||||
const {
|
||||
itemHeight,
|
||||
overscan = 3,
|
||||
containerHeight = 600
|
||||
} = options
|
||||
|
||||
const [scrollTop, setScrollTop] = useState(0)
|
||||
const containerRef = useRef<HTMLDivElement>(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
|
||||
}
|
||||
}
|
||||
138
src/hooks/use-websocket.ts
Normal file
138
src/hooks/use-websocket.ts
Normal file
@@ -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<number>(WebSocket.CONNECTING)
|
||||
const [lastMessage, setLastMessage] = useState<MessageEvent | null>(null)
|
||||
const [reconnectCount, setReconnectCount] = useState(0)
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user