@superdangerous/app-framework 4.14.0 → 4.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/middleware/validation.d.ts +12 -12
- package/dist/services/emailService.d.ts +146 -0
- package/dist/services/emailService.d.ts.map +1 -0
- package/dist/services/emailService.js +649 -0
- package/dist/services/emailService.js.map +1 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +2 -0
- package/dist/services/index.js.map +1 -1
- package/package.json +9 -1
- package/src/index.ts +14 -0
- package/src/services/emailService.ts +812 -0
- package/src/services/index.ts +14 -0
- package/ui/data-table/components/BatchActionsBar.tsx +53 -0
- package/ui/data-table/components/ColumnVisibility.tsx +111 -0
- package/ui/data-table/components/DataTablePage.tsx +238 -0
- package/ui/data-table/components/Pagination.tsx +203 -0
- package/ui/data-table/components/PaginationControls.tsx +122 -0
- package/ui/data-table/components/TableFilters.tsx +139 -0
- package/ui/data-table/components/index.ts +27 -0
- package/ui/data-table/hooks/index.ts +17 -0
- package/ui/data-table/hooks/useColumnOrder.ts +233 -0
- package/ui/data-table/hooks/useColumnVisibility.ts +128 -0
- package/ui/data-table/hooks/usePagination.ts +160 -0
- package/ui/data-table/hooks/useResizableColumns.ts +280 -0
- package/ui/data-table/index.ts +74 -0
- package/ui/dist/index.d.mts +207 -5
- package/ui/dist/index.d.ts +207 -5
- package/ui/dist/index.js +36 -43
- package/ui/dist/index.js.map +1 -1
- package/ui/dist/index.mjs +36 -43
- package/ui/dist/index.mjs.map +1 -1
package/src/services/index.ts
CHANGED
|
@@ -46,3 +46,17 @@ export type {
|
|
|
46
46
|
// Queue Service
|
|
47
47
|
export { default as QueueService } from "./queueService.js";
|
|
48
48
|
export type { QueueJob, QueueConfig, JobHandler } from "./queueService.js";
|
|
49
|
+
|
|
50
|
+
// Email Service
|
|
51
|
+
export {
|
|
52
|
+
EmailService,
|
|
53
|
+
getEmailService,
|
|
54
|
+
createEmailService,
|
|
55
|
+
} from "./emailService.js";
|
|
56
|
+
export type {
|
|
57
|
+
EmailConfig,
|
|
58
|
+
EmailOptions,
|
|
59
|
+
EmailServiceStatus,
|
|
60
|
+
NotificationEvent,
|
|
61
|
+
NotificationEventType,
|
|
62
|
+
} from "./emailService.js";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { X } from 'lucide-react';
|
|
3
|
+
import { Card } from '../../components/base/card';
|
|
4
|
+
import { Button } from '../../components/base/button';
|
|
5
|
+
import { cn } from '../../src/utils/cn';
|
|
6
|
+
|
|
7
|
+
export interface BatchActionsBarProps {
|
|
8
|
+
/** Number of selected items */
|
|
9
|
+
selectedCount: number;
|
|
10
|
+
/** Callback to clear selection */
|
|
11
|
+
onClear: () => void;
|
|
12
|
+
/** Action buttons to display on the right side */
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
/** Label for the selected items (default: "item"/"items") */
|
|
15
|
+
itemLabel?: string;
|
|
16
|
+
/** Additional CSS classes */
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A horizontal bar that appears when items are selected,
|
|
22
|
+
* showing the count and providing batch action buttons.
|
|
23
|
+
*/
|
|
24
|
+
export function BatchActionsBar({
|
|
25
|
+
selectedCount,
|
|
26
|
+
onClear,
|
|
27
|
+
children,
|
|
28
|
+
itemLabel,
|
|
29
|
+
className,
|
|
30
|
+
}: BatchActionsBarProps) {
|
|
31
|
+
if (selectedCount === 0) return null;
|
|
32
|
+
|
|
33
|
+
const label = itemLabel ?? (selectedCount === 1 ? 'item' : 'items');
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Card className={cn("p-3 bg-primary/5 border-primary/20", className)}>
|
|
37
|
+
<div className="flex items-center justify-between">
|
|
38
|
+
<div className="flex items-center gap-3">
|
|
39
|
+
<span className="font-medium text-sm" role="status" aria-live="polite">
|
|
40
|
+
{selectedCount} {label} selected
|
|
41
|
+
</span>
|
|
42
|
+
<Button variant="ghost" size="sm" onClick={onClear} aria-label="Clear selection">
|
|
43
|
+
<X className="h-4 w-4 mr-1" aria-hidden="true" />
|
|
44
|
+
Clear
|
|
45
|
+
</Button>
|
|
46
|
+
</div>
|
|
47
|
+
<div className="flex items-center gap-2" role="group" aria-label="Batch actions">
|
|
48
|
+
{children}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</Card>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Columns3, Check, Eye, EyeOff } from 'lucide-react';
|
|
2
|
+
import { Button } from '../../components/base/button';
|
|
3
|
+
import {
|
|
4
|
+
DropdownMenu,
|
|
5
|
+
DropdownMenuContent,
|
|
6
|
+
DropdownMenuItem,
|
|
7
|
+
DropdownMenuLabel,
|
|
8
|
+
DropdownMenuSeparator,
|
|
9
|
+
DropdownMenuTrigger,
|
|
10
|
+
} from '../../components/base/dropdown-menu';
|
|
11
|
+
import { cn } from '../../src/utils/cn';
|
|
12
|
+
import type { ColumnConfig } from '../hooks/useColumnVisibility';
|
|
13
|
+
|
|
14
|
+
interface ColumnVisibilityProps {
|
|
15
|
+
columns: ColumnConfig[];
|
|
16
|
+
isColumnVisible: (columnId: string) => boolean;
|
|
17
|
+
toggleColumn: (columnId: string) => void;
|
|
18
|
+
showAllColumns: () => void;
|
|
19
|
+
hideAllColumns: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function ColumnVisibility({
|
|
23
|
+
columns,
|
|
24
|
+
isColumnVisible,
|
|
25
|
+
toggleColumn,
|
|
26
|
+
showAllColumns,
|
|
27
|
+
hideAllColumns,
|
|
28
|
+
}: ColumnVisibilityProps) {
|
|
29
|
+
const visibleCount = columns.filter(c => isColumnVisible(c.id)).length;
|
|
30
|
+
const toggleableColumns = columns.filter(c => !c.locked);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<DropdownMenu>
|
|
34
|
+
<DropdownMenuTrigger asChild>
|
|
35
|
+
<Button variant="outline" size="sm" className="gap-2">
|
|
36
|
+
<Columns3 className="h-4 w-4" />
|
|
37
|
+
Columns
|
|
38
|
+
<span className="text-muted-foreground text-xs">
|
|
39
|
+
({visibleCount}/{columns.length})
|
|
40
|
+
</span>
|
|
41
|
+
</Button>
|
|
42
|
+
</DropdownMenuTrigger>
|
|
43
|
+
<DropdownMenuContent align="end" className="w-48">
|
|
44
|
+
<DropdownMenuLabel className="font-normal text-xs text-muted-foreground">
|
|
45
|
+
Toggle columns
|
|
46
|
+
</DropdownMenuLabel>
|
|
47
|
+
<DropdownMenuSeparator />
|
|
48
|
+
|
|
49
|
+
{columns.map(column => {
|
|
50
|
+
const visible = isColumnVisible(column.id);
|
|
51
|
+
const isLocked = column.locked === true;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<DropdownMenuItem
|
|
55
|
+
key={column.id}
|
|
56
|
+
onClick={(e) => {
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
if (!isLocked) {
|
|
59
|
+
toggleColumn(column.id);
|
|
60
|
+
}
|
|
61
|
+
}}
|
|
62
|
+
className={cn(
|
|
63
|
+
'gap-2 cursor-pointer',
|
|
64
|
+
isLocked && 'opacity-50 cursor-not-allowed'
|
|
65
|
+
)}
|
|
66
|
+
disabled={isLocked}
|
|
67
|
+
>
|
|
68
|
+
<div className="w-4 h-4 flex items-center justify-center">
|
|
69
|
+
{visible ? (
|
|
70
|
+
<Check className="h-3.5 w-3.5 text-primary" />
|
|
71
|
+
) : (
|
|
72
|
+
<div className="h-3.5 w-3.5" />
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
<span className="flex-1">{column.label}</span>
|
|
76
|
+
{isLocked && (
|
|
77
|
+
<span className="text-xs text-muted-foreground">Required</span>
|
|
78
|
+
)}
|
|
79
|
+
</DropdownMenuItem>
|
|
80
|
+
);
|
|
81
|
+
})}
|
|
82
|
+
|
|
83
|
+
{toggleableColumns.length > 1 && (
|
|
84
|
+
<>
|
|
85
|
+
<DropdownMenuSeparator />
|
|
86
|
+
<DropdownMenuItem
|
|
87
|
+
onClick={(e) => {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
showAllColumns();
|
|
90
|
+
}}
|
|
91
|
+
className="gap-2 cursor-pointer"
|
|
92
|
+
>
|
|
93
|
+
<Eye className="h-4 w-4" />
|
|
94
|
+
Show All
|
|
95
|
+
</DropdownMenuItem>
|
|
96
|
+
<DropdownMenuItem
|
|
97
|
+
onClick={(e) => {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
hideAllColumns();
|
|
100
|
+
}}
|
|
101
|
+
className="gap-2 cursor-pointer"
|
|
102
|
+
>
|
|
103
|
+
<EyeOff className="h-4 w-4" />
|
|
104
|
+
Hide Optional
|
|
105
|
+
</DropdownMenuItem>
|
|
106
|
+
</>
|
|
107
|
+
)}
|
|
108
|
+
</DropdownMenuContent>
|
|
109
|
+
</DropdownMenu>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataTablePage - Full-height data table layout with integrated header
|
|
3
|
+
*
|
|
4
|
+
* This component provides a desktop-app style layout where:
|
|
5
|
+
* - The header contains search, filters, action buttons, AND pagination controls
|
|
6
|
+
* - The data table fills the available vertical space between header and footer
|
|
7
|
+
* - Pagination controls appear inline in the header (right side)
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* ```tsx
|
|
11
|
+
* <DataTablePage
|
|
12
|
+
* title="Issues"
|
|
13
|
+
* description="Review and manage detected code issues"
|
|
14
|
+
* search={searchTerm}
|
|
15
|
+
* onSearchChange={setSearchTerm}
|
|
16
|
+
* searchPlaceholder="Search issues..."
|
|
17
|
+
* filters={filterOptions}
|
|
18
|
+
* activeFilterCount={countActiveFilters}
|
|
19
|
+
* onClearFilters={clearFilters}
|
|
20
|
+
* pagination={pagination}
|
|
21
|
+
* actions={<>
|
|
22
|
+
* <Button>Refresh</Button>
|
|
23
|
+
* <Button>Export</Button>
|
|
24
|
+
* </>}
|
|
25
|
+
* >
|
|
26
|
+
* <DataTable ... hidePagination />
|
|
27
|
+
* </DataTablePage>
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import React from 'react';
|
|
32
|
+
import { Search, Filter, X } from 'lucide-react';
|
|
33
|
+
import { Input } from '../../components/base/input';
|
|
34
|
+
import { Button } from '../../components/base/button';
|
|
35
|
+
import { Popover, PopoverContent, PopoverTrigger } from '../../components/base/popover';
|
|
36
|
+
import { cn } from '../../src/utils/cn';
|
|
37
|
+
import { PaginationControls, type PaginationControlsProps } from './PaginationControls';
|
|
38
|
+
|
|
39
|
+
export interface FilterOption {
|
|
40
|
+
id: string;
|
|
41
|
+
label: string;
|
|
42
|
+
render: () => React.ReactNode;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface DataTablePageProps {
|
|
46
|
+
/** Page title */
|
|
47
|
+
title: string;
|
|
48
|
+
/** Page description */
|
|
49
|
+
description?: string;
|
|
50
|
+
/** Search term */
|
|
51
|
+
search?: string;
|
|
52
|
+
/** Search change handler */
|
|
53
|
+
onSearchChange?: (value: string) => void;
|
|
54
|
+
/** Search placeholder text */
|
|
55
|
+
searchPlaceholder?: string;
|
|
56
|
+
/** Filter options for popover */
|
|
57
|
+
filters?: FilterOption[];
|
|
58
|
+
/** Number of active filters */
|
|
59
|
+
activeFilterCount?: number;
|
|
60
|
+
/** Clear all filters handler */
|
|
61
|
+
onClearFilters?: () => void;
|
|
62
|
+
/** Pagination props from usePagination hook */
|
|
63
|
+
pagination?: PaginationControlsProps;
|
|
64
|
+
/** Action buttons to show in the header */
|
|
65
|
+
actions?: React.ReactNode;
|
|
66
|
+
/** Content before the table (e.g., BatchActionsBar) */
|
|
67
|
+
beforeTable?: React.ReactNode;
|
|
68
|
+
/** The DataTable component */
|
|
69
|
+
children: React.ReactNode;
|
|
70
|
+
/** Additional class for the container */
|
|
71
|
+
className?: string;
|
|
72
|
+
/** Whether to show a loading state */
|
|
73
|
+
loading?: boolean;
|
|
74
|
+
/** Loading component to show */
|
|
75
|
+
loadingComponent?: React.ReactNode;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function DataTablePage({
|
|
79
|
+
title,
|
|
80
|
+
description,
|
|
81
|
+
search,
|
|
82
|
+
onSearchChange,
|
|
83
|
+
searchPlaceholder = 'Search...',
|
|
84
|
+
filters,
|
|
85
|
+
activeFilterCount = 0,
|
|
86
|
+
onClearFilters,
|
|
87
|
+
pagination,
|
|
88
|
+
actions,
|
|
89
|
+
beforeTable,
|
|
90
|
+
children,
|
|
91
|
+
className,
|
|
92
|
+
loading,
|
|
93
|
+
loadingComponent,
|
|
94
|
+
}: DataTablePageProps) {
|
|
95
|
+
// Always show pagination controls when pagination is provided (for row count selector)
|
|
96
|
+
const showPagination = pagination && pagination.totalItems > 0;
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className={cn('flex flex-col h-full', className)}>
|
|
100
|
+
{/* Page Header - has horizontal padding */}
|
|
101
|
+
<div className="data-table-page-header flex-shrink-0 space-y-4 pb-4">
|
|
102
|
+
{/* Title */}
|
|
103
|
+
<div>
|
|
104
|
+
<h1 className="text-3xl font-bold">{title}</h1>
|
|
105
|
+
{description && (
|
|
106
|
+
<p className="text-muted-foreground">{description}</p>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{/* Controls Row: Search | Filters | Pagination | Spacer | Actions */}
|
|
111
|
+
<div className="flex items-center gap-3 flex-wrap">
|
|
112
|
+
{/* Search Input - responsive width */}
|
|
113
|
+
{onSearchChange !== undefined && (
|
|
114
|
+
<div className="relative w-full sm:w-auto sm:min-w-[200px] sm:max-w-xs">
|
|
115
|
+
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none z-10" />
|
|
116
|
+
<Input
|
|
117
|
+
placeholder={searchPlaceholder}
|
|
118
|
+
value={search || ''}
|
|
119
|
+
onChange={(e) => onSearchChange(e.target.value)}
|
|
120
|
+
className="pl-9 h-9"
|
|
121
|
+
/>
|
|
122
|
+
{search && (
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
onClick={() => onSearchChange('')}
|
|
126
|
+
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 rounded-sm hover:bg-muted"
|
|
127
|
+
>
|
|
128
|
+
<X className="h-3 w-3 text-muted-foreground" />
|
|
129
|
+
</button>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{/* Filters Popover */}
|
|
135
|
+
{filters && filters.length > 0 && (
|
|
136
|
+
<Popover>
|
|
137
|
+
<PopoverTrigger asChild>
|
|
138
|
+
<Button variant="outline" size="sm" className="gap-2 h-9">
|
|
139
|
+
<Filter className="h-4 w-4" />
|
|
140
|
+
<span className="hidden sm:inline">Filters</span>
|
|
141
|
+
{activeFilterCount > 0 && (
|
|
142
|
+
<span className="rounded-full bg-primary text-primary-foreground px-2 py-0.5 text-xs font-medium">
|
|
143
|
+
{activeFilterCount}
|
|
144
|
+
</span>
|
|
145
|
+
)}
|
|
146
|
+
</Button>
|
|
147
|
+
</PopoverTrigger>
|
|
148
|
+
<PopoverContent
|
|
149
|
+
className="w-80 overflow-y-auto"
|
|
150
|
+
align="start"
|
|
151
|
+
collisionPadding={16}
|
|
152
|
+
style={{ maxHeight: 'var(--radix-popover-content-available-height)' }}
|
|
153
|
+
>
|
|
154
|
+
<div className="space-y-4">
|
|
155
|
+
<div className="flex items-center justify-between">
|
|
156
|
+
<h4 className="font-medium text-sm">Filters</h4>
|
|
157
|
+
{activeFilterCount > 0 && onClearFilters && (
|
|
158
|
+
<Button
|
|
159
|
+
variant="ghost"
|
|
160
|
+
size="sm"
|
|
161
|
+
onClick={onClearFilters}
|
|
162
|
+
className="h-auto p-0 text-xs text-destructive hover:text-destructive"
|
|
163
|
+
>
|
|
164
|
+
Clear all
|
|
165
|
+
</Button>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<div className="space-y-3">
|
|
170
|
+
{filters.map((filter) => (
|
|
171
|
+
<div key={filter.id} className="space-y-1.5">
|
|
172
|
+
<label className="text-xs font-medium text-muted-foreground">
|
|
173
|
+
{filter.label}
|
|
174
|
+
</label>
|
|
175
|
+
{filter.render()}
|
|
176
|
+
</div>
|
|
177
|
+
))}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
</PopoverContent>
|
|
181
|
+
</Popover>
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
{/* Clear filters button (visible when filters are active) */}
|
|
185
|
+
{activeFilterCount > 0 && onClearFilters && (
|
|
186
|
+
<Button
|
|
187
|
+
variant="ghost"
|
|
188
|
+
size="sm"
|
|
189
|
+
onClick={onClearFilters}
|
|
190
|
+
className="h-9 gap-1.5 text-muted-foreground hover:text-foreground"
|
|
191
|
+
title="Clear filters"
|
|
192
|
+
>
|
|
193
|
+
<X className="h-4 w-4" />
|
|
194
|
+
<span className="hidden sm:inline">Clear</span>
|
|
195
|
+
</Button>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
{/* Pagination Controls (after filters) */}
|
|
199
|
+
{showPagination && (
|
|
200
|
+
<PaginationControls {...pagination} />
|
|
201
|
+
)}
|
|
202
|
+
|
|
203
|
+
{/* Spacer */}
|
|
204
|
+
<div className="flex-1" />
|
|
205
|
+
|
|
206
|
+
{/* Action buttons (right side) - never wrap */}
|
|
207
|
+
{actions && (
|
|
208
|
+
<div className="flex items-center gap-2 flex-shrink-0">
|
|
209
|
+
{actions}
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
{/* Before Table Content (e.g., BatchActionsBar) - with padding */}
|
|
216
|
+
{beforeTable && (
|
|
217
|
+
<div className="px-6 pb-2">
|
|
218
|
+
{beforeTable}
|
|
219
|
+
</div>
|
|
220
|
+
)}
|
|
221
|
+
|
|
222
|
+
{/* Table Container - edge to edge, scrolls both directions */}
|
|
223
|
+
<div className="relative flex-1 min-h-0">
|
|
224
|
+
<div className="data-table-scroll-container h-full">
|
|
225
|
+
{loading ? (
|
|
226
|
+
loadingComponent || (
|
|
227
|
+
<div className="flex items-center justify-center h-full">
|
|
228
|
+
<div className="text-muted-foreground">Loading...</div>
|
|
229
|
+
</div>
|
|
230
|
+
)
|
|
231
|
+
) : (
|
|
232
|
+
children
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChevronLeft,
|
|
3
|
+
ChevronRight,
|
|
4
|
+
ChevronsLeft,
|
|
5
|
+
ChevronsRight,
|
|
6
|
+
} from 'lucide-react';
|
|
7
|
+
import { Button } from '../../components/base/button';
|
|
8
|
+
import {
|
|
9
|
+
Select,
|
|
10
|
+
SelectContent,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectTrigger,
|
|
13
|
+
SelectValue,
|
|
14
|
+
} from '../../components/base/select';
|
|
15
|
+
import { cn } from '../../src/utils/cn';
|
|
16
|
+
|
|
17
|
+
interface PaginationProps {
|
|
18
|
+
page: number;
|
|
19
|
+
pageSize: number;
|
|
20
|
+
totalPages: number;
|
|
21
|
+
totalItems: number;
|
|
22
|
+
startIndex: number;
|
|
23
|
+
endIndex: number;
|
|
24
|
+
canGoNext: boolean;
|
|
25
|
+
canGoPrev: boolean;
|
|
26
|
+
pageSizeOptions: number[];
|
|
27
|
+
onPageChange: (page: number) => void;
|
|
28
|
+
onPageSizeChange: (size: number) => void;
|
|
29
|
+
onNextPage: () => void;
|
|
30
|
+
onPrevPage: () => void;
|
|
31
|
+
onFirstPage: () => void;
|
|
32
|
+
onLastPage: () => void;
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function Pagination({
|
|
37
|
+
page,
|
|
38
|
+
pageSize,
|
|
39
|
+
totalPages,
|
|
40
|
+
totalItems,
|
|
41
|
+
startIndex,
|
|
42
|
+
endIndex,
|
|
43
|
+
canGoNext,
|
|
44
|
+
canGoPrev,
|
|
45
|
+
pageSizeOptions,
|
|
46
|
+
onPageChange,
|
|
47
|
+
onPageSizeChange,
|
|
48
|
+
onNextPage,
|
|
49
|
+
onPrevPage,
|
|
50
|
+
onFirstPage,
|
|
51
|
+
onLastPage,
|
|
52
|
+
className,
|
|
53
|
+
}: PaginationProps) {
|
|
54
|
+
// Generate page numbers to show
|
|
55
|
+
const getPageNumbers = () => {
|
|
56
|
+
const pages: (number | 'ellipsis')[] = [];
|
|
57
|
+
const showPages = 5; // Max pages to show
|
|
58
|
+
const halfShow = Math.floor(showPages / 2);
|
|
59
|
+
|
|
60
|
+
let startPage = Math.max(1, page - halfShow);
|
|
61
|
+
let endPage = Math.min(totalPages, page + halfShow);
|
|
62
|
+
|
|
63
|
+
// Adjust range if near edges
|
|
64
|
+
if (page <= halfShow) {
|
|
65
|
+
endPage = Math.min(totalPages, showPages);
|
|
66
|
+
}
|
|
67
|
+
if (page > totalPages - halfShow) {
|
|
68
|
+
startPage = Math.max(1, totalPages - showPages + 1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Add first page and ellipsis if needed
|
|
72
|
+
if (startPage > 1) {
|
|
73
|
+
pages.push(1);
|
|
74
|
+
if (startPage > 2) {
|
|
75
|
+
pages.push('ellipsis');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Add middle pages
|
|
80
|
+
for (let i = startPage; i <= endPage; i++) {
|
|
81
|
+
if (i !== 1 && i !== totalPages) {
|
|
82
|
+
pages.push(i);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Add ellipsis and last page if needed
|
|
87
|
+
if (endPage < totalPages) {
|
|
88
|
+
if (endPage < totalPages - 1) {
|
|
89
|
+
pages.push('ellipsis');
|
|
90
|
+
}
|
|
91
|
+
pages.push(totalPages);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return pages;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (totalItems === 0) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div
|
|
103
|
+
className={cn(
|
|
104
|
+
'flex flex-col sm:flex-row items-center justify-between gap-4 px-2 py-3',
|
|
105
|
+
className
|
|
106
|
+
)}
|
|
107
|
+
>
|
|
108
|
+
{/* Items info */}
|
|
109
|
+
<div className="text-sm text-muted-foreground">
|
|
110
|
+
Showing {startIndex} to {endIndex} of {totalItems} items
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{/* Controls */}
|
|
114
|
+
<div className="flex items-center gap-4">
|
|
115
|
+
{/* Page size selector */}
|
|
116
|
+
<div className="flex items-center gap-2">
|
|
117
|
+
<span className="text-sm text-muted-foreground">Per page:</span>
|
|
118
|
+
<Select
|
|
119
|
+
value={String(pageSize)}
|
|
120
|
+
onValueChange={(v) => onPageSizeChange(Number(v))}
|
|
121
|
+
>
|
|
122
|
+
<SelectTrigger className="w-20 h-8">
|
|
123
|
+
<SelectValue />
|
|
124
|
+
</SelectTrigger>
|
|
125
|
+
<SelectContent>
|
|
126
|
+
{pageSizeOptions.map((size) => (
|
|
127
|
+
<SelectItem key={size} value={String(size)}>
|
|
128
|
+
{size}
|
|
129
|
+
</SelectItem>
|
|
130
|
+
))}
|
|
131
|
+
</SelectContent>
|
|
132
|
+
</Select>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{/* Page navigation */}
|
|
136
|
+
<div className="flex items-center gap-1">
|
|
137
|
+
<Button
|
|
138
|
+
variant="outline"
|
|
139
|
+
size="icon"
|
|
140
|
+
className="h-8 w-8"
|
|
141
|
+
onClick={onFirstPage}
|
|
142
|
+
disabled={!canGoPrev}
|
|
143
|
+
title="First page"
|
|
144
|
+
>
|
|
145
|
+
<ChevronsLeft className="h-4 w-4" />
|
|
146
|
+
</Button>
|
|
147
|
+
<Button
|
|
148
|
+
variant="outline"
|
|
149
|
+
size="icon"
|
|
150
|
+
className="h-8 w-8"
|
|
151
|
+
onClick={onPrevPage}
|
|
152
|
+
disabled={!canGoPrev}
|
|
153
|
+
title="Previous page"
|
|
154
|
+
>
|
|
155
|
+
<ChevronLeft className="h-4 w-4" />
|
|
156
|
+
</Button>
|
|
157
|
+
|
|
158
|
+
{/* Page numbers */}
|
|
159
|
+
<div className="flex items-center gap-1">
|
|
160
|
+
{getPageNumbers().map((pageNum, idx) =>
|
|
161
|
+
pageNum === 'ellipsis' ? (
|
|
162
|
+
<span key={`ellipsis-${idx}`} className="px-2 text-muted-foreground">
|
|
163
|
+
...
|
|
164
|
+
</span>
|
|
165
|
+
) : (
|
|
166
|
+
<Button
|
|
167
|
+
key={pageNum}
|
|
168
|
+
variant={page === pageNum ? 'default' : 'outline'}
|
|
169
|
+
size="icon"
|
|
170
|
+
className="h-8 w-8"
|
|
171
|
+
onClick={() => onPageChange(pageNum)}
|
|
172
|
+
>
|
|
173
|
+
{pageNum}
|
|
174
|
+
</Button>
|
|
175
|
+
)
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<Button
|
|
180
|
+
variant="outline"
|
|
181
|
+
size="icon"
|
|
182
|
+
className="h-8 w-8"
|
|
183
|
+
onClick={onNextPage}
|
|
184
|
+
disabled={!canGoNext}
|
|
185
|
+
title="Next page"
|
|
186
|
+
>
|
|
187
|
+
<ChevronRight className="h-4 w-4" />
|
|
188
|
+
</Button>
|
|
189
|
+
<Button
|
|
190
|
+
variant="outline"
|
|
191
|
+
size="icon"
|
|
192
|
+
className="h-8 w-8"
|
|
193
|
+
onClick={onLastPage}
|
|
194
|
+
disabled={!canGoNext}
|
|
195
|
+
title="Last page"
|
|
196
|
+
>
|
|
197
|
+
<ChevronsRight className="h-4 w-4" />
|
|
198
|
+
</Button>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|