@svadmin/ui 0.0.6 → 0.4.1
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/package.json +8 -3
- package/src/actions.ts +110 -0
- package/src/components/AdminApp.svelte +8 -4
- package/src/components/Authenticated.svelte +38 -13
- package/src/components/AutoForm.svelte +163 -74
- package/src/components/AutoSaveIndicator.svelte +35 -0
- package/src/components/AutoTable.svelte +476 -183
- package/src/components/Breadcrumbs.svelte +22 -17
- package/src/components/CatchAllNavigate.svelte +21 -0
- package/src/components/ComboboxField.svelte +122 -0
- package/src/components/CommandPalette.svelte +93 -0
- package/src/components/ConfigErrorScreen.svelte +53 -151
- package/src/components/ConfirmDialog.svelte +25 -17
- package/src/components/CreatePage.svelte +43 -0
- package/src/components/DevTools.svelte +99 -230
- package/src/components/DrawerForm.svelte +19 -10
- package/src/components/EditPage.svelte +71 -0
- package/src/components/EmptyState.svelte +21 -31
- package/src/components/ErrorBoundary.svelte +38 -40
- package/src/components/ErrorComponent.svelte +36 -0
- package/src/components/FieldRenderer.svelte +240 -75
- package/src/components/ForgotPasswordPage.svelte +20 -111
- package/src/components/Header.svelte +46 -57
- package/src/components/InferencerPanel.svelte +136 -110
- package/src/components/InfiniteList.svelte +93 -0
- package/src/components/Layout.svelte +85 -22
- package/src/components/ListPage.svelte +71 -0
- package/src/components/LiveIndicator.svelte +58 -0
- package/src/components/LoginPage.svelte +33 -154
- package/src/components/ModalForm.svelte +6 -2
- package/src/components/NavigateToResource.svelte +20 -0
- package/src/components/PageSkeleton.svelte +71 -0
- package/src/components/PasswordInput.svelte +95 -0
- package/src/components/RegisterPage.svelte +35 -164
- package/src/components/ShowPage.svelte +35 -36
- package/src/components/Sidebar.svelte +212 -81
- package/src/components/StatsCard.svelte +12 -3
- package/src/components/StepsForm.svelte +139 -0
- package/src/components/ThemedTitle.svelte +32 -0
- package/src/components/Toast.svelte +15 -44
- package/src/components/TooltipButton.svelte +52 -0
- package/src/components/UndoableNotification.svelte +28 -96
- package/src/components/UpdatePasswordPage.svelte +32 -160
- package/src/components/buttons/CloneButton.svelte +1 -1
- package/src/components/buttons/CreateButton.svelte +1 -1
- package/src/components/buttons/DeleteButton.svelte +3 -4
- package/src/components/buttons/EditButton.svelte +1 -1
- package/src/components/buttons/ImportButton.svelte +29 -7
- package/src/components/buttons/RefreshButton.svelte +11 -5
- package/src/components/fieldComponentMap.ts +79 -0
- package/src/components/fields/BooleanField.svelte +13 -0
- package/src/components/fields/DateField.svelte +6 -0
- package/src/components/fields/EmailField.svelte +9 -0
- package/src/components/fields/FileField.svelte +14 -0
- package/src/components/fields/ImageField.svelte +9 -0
- package/src/components/fields/JsonField.svelte +72 -0
- package/src/components/fields/MarkdownField.svelte +9 -0
- package/src/components/fields/MultiSelectField.svelte +20 -0
- package/src/components/fields/NumberField.svelte +6 -0
- package/src/components/fields/RelationField.svelte +24 -0
- package/src/components/fields/RichTextField.svelte +35 -0
- package/src/components/fields/SelectField.svelte +14 -0
- package/src/components/fields/TagField.svelte +12 -0
- package/src/components/fields/TextField.svelte +11 -0
- package/src/components/fields/UrlField.svelte +14 -0
- package/src/components/ui/alert-dialog/alert-dialog-content.svelte +25 -0
- package/src/components/ui/alert-dialog/alert-dialog-description.svelte +16 -0
- package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +8 -0
- package/src/components/ui/alert-dialog/alert-dialog-header.svelte +8 -0
- package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +16 -0
- package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +6 -0
- package/src/components/ui/alert-dialog/alert-dialog-title.svelte +16 -0
- package/src/components/ui/alert-dialog/index.ts +20 -0
- package/src/components/ui/avatar/avatar.svelte +2 -1
- package/src/components/ui/breadcrumb/breadcrumb-ellipsis.svelte +18 -0
- package/src/components/ui/breadcrumb/breadcrumb-item.svelte +15 -0
- package/src/components/ui/breadcrumb/breadcrumb-link.svelte +15 -0
- package/src/components/ui/breadcrumb/breadcrumb-list.svelte +15 -0
- package/src/components/ui/breadcrumb/breadcrumb-page.svelte +18 -0
- package/src/components/ui/breadcrumb/breadcrumb-separator.svelte +22 -0
- package/src/components/ui/breadcrumb/breadcrumb.svelte +11 -0
- package/src/components/ui/breadcrumb/index.ts +7 -0
- package/src/components/ui/collapsible/index.ts +7 -0
- package/src/components/ui/command/command-dialog.svelte +31 -0
- package/src/components/ui/command/index.ts +2 -0
- package/src/components/ui/context-menu/index.ts +11 -0
- package/src/components/ui/drawer/drawer-content.svelte +21 -0
- package/src/components/ui/drawer/drawer-description.svelte +11 -0
- package/src/components/ui/drawer/drawer-footer.svelte +11 -0
- package/src/components/ui/drawer/drawer-header.svelte +11 -0
- package/src/components/ui/drawer/drawer-title.svelte +11 -0
- package/src/components/ui/drawer/drawer.svelte +18 -0
- package/src/components/ui/drawer/index.ts +6 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +43 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +27 -0
- package/src/components/ui/dropdown-menu/index.ts +6 -0
- package/src/components/ui/label/index.ts +1 -0
- package/src/components/ui/label/label.svelte +18 -0
- package/src/components/ui/pagination/index.ts +7 -0
- package/src/components/ui/pagination/pagination-content.svelte +11 -0
- package/src/components/ui/pagination/pagination-ellipsis.svelte +12 -0
- package/src/components/ui/pagination/pagination-item.svelte +11 -0
- package/src/components/ui/pagination/pagination-link.svelte +26 -0
- package/src/components/ui/pagination/pagination-next.svelte +22 -0
- package/src/components/ui/pagination/pagination-previous.svelte +22 -0
- package/src/components/ui/pagination/pagination.svelte +11 -0
- package/src/components/ui/popover/index.ts +12 -0
- package/src/components/ui/popover/popover-content.svelte +25 -0
- package/src/components/ui/progress/index.ts +1 -0
- package/src/components/ui/progress/progress.svelte +22 -0
- package/src/components/ui/scroll-area/index.ts +1 -0
- package/src/components/ui/scroll-area/scroll-area.svelte +20 -0
- package/src/components/ui/sheet/index.ts +15 -1
- package/src/components/ui/sheet/sheet-content.svelte +28 -0
- package/src/components/ui/sheet/sheet-description.svelte +22 -0
- package/src/components/ui/sheet/sheet-footer.svelte +22 -0
- package/src/components/ui/sheet/sheet-header.svelte +22 -0
- package/src/components/ui/sheet/sheet-title.svelte +22 -0
- package/src/index.ts +52 -1
- package/src/router-state.svelte.ts +16 -9
|
@@ -1,47 +1,91 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
createTable,
|
|
4
|
+
getCoreRowModel,
|
|
5
|
+
type ColumnDef,
|
|
6
|
+
type SortingState,
|
|
7
|
+
type RowSelectionState,
|
|
8
|
+
type VisibilityState,
|
|
9
|
+
type ExpandedState,
|
|
10
|
+
} from '@tanstack/svelte-table';
|
|
11
|
+
|
|
2
12
|
import { useList, useDelete, getResource } from '@svadmin/core';
|
|
3
|
-
import type { Pagination, Sort, Filter } from '@svadmin/core';
|
|
13
|
+
import type { Pagination as PaginationState, Sort, Filter, FieldDefinition } from '@svadmin/core';
|
|
4
14
|
import { navigate } from '@svadmin/core/router';
|
|
5
15
|
import { canAccess } from '@svadmin/core/permissions';
|
|
6
16
|
import { readURLState, writeURLState } from '@svadmin/core';
|
|
7
17
|
import { t } from '@svadmin/core/i18n';
|
|
18
|
+
import { fade } from 'svelte/transition';
|
|
8
19
|
import { Button } from './ui/button/index.js';
|
|
9
20
|
import { Input } from './ui/input/index.js';
|
|
10
21
|
import { Checkbox } from './ui/checkbox/index.js';
|
|
11
22
|
import { Badge } from './ui/badge/index.js';
|
|
12
23
|
import * as Table from './ui/table/index.js';
|
|
13
|
-
import {
|
|
24
|
+
import { Skeleton } from './ui/skeleton/index.js';
|
|
25
|
+
import * as Popover from './ui/popover/index.js';
|
|
26
|
+
import * as DropdownMenu from './ui/dropdown-menu/index.js';
|
|
27
|
+
import * as PaginationUI from './ui/pagination/index.js';
|
|
28
|
+
import * as ContextMenu from './ui/context-menu/index.js';
|
|
29
|
+
import { Select } from './ui/select/index.js';
|
|
30
|
+
import {
|
|
31
|
+
Plus, Pencil, Trash2,
|
|
32
|
+
Search, Download, Upload, ChevronDown, ChevronUp, SlidersHorizontal, Filter as FilterIcon,
|
|
33
|
+
Eye, Copy
|
|
34
|
+
} from 'lucide-svelte';
|
|
14
35
|
import ConfirmDialog from './ConfirmDialog.svelte';
|
|
36
|
+
import type { Snippet } from 'svelte';
|
|
37
|
+
|
|
38
|
+
// ─── Props with Snippet composability ─────────────────────────
|
|
39
|
+
interface Props {
|
|
40
|
+
resourceName: string;
|
|
41
|
+
selectable?: boolean;
|
|
42
|
+
/** Custom header actions (right side) */
|
|
43
|
+
headerActions?: Snippet;
|
|
44
|
+
/** Custom cell renderer per field */
|
|
45
|
+
cellRenderer?: Snippet<[{ field: FieldDefinition; value: unknown; record: Record<string, unknown> }]>;
|
|
46
|
+
/** Custom row actions (edit/delete column) */
|
|
47
|
+
rowActions?: Snippet<[{ record: Record<string, unknown>; id: string | number }]>;
|
|
48
|
+
/** Custom empty state */
|
|
49
|
+
emptyState?: Snippet;
|
|
50
|
+
/** Expandable row content */
|
|
51
|
+
expandedRowRender?: Snippet<[{ record: Record<string, unknown> }]>;
|
|
52
|
+
/** Externally controlled pagination */
|
|
53
|
+
pagination?: { current: number; pageSize: number };
|
|
54
|
+
/** Externally controlled sorters */
|
|
55
|
+
sorters?: Sort[];
|
|
56
|
+
}
|
|
15
57
|
|
|
16
|
-
let {
|
|
58
|
+
let {
|
|
59
|
+
resourceName,
|
|
60
|
+
selectable = true,
|
|
61
|
+
headerActions,
|
|
62
|
+
cellRenderer,
|
|
63
|
+
rowActions,
|
|
64
|
+
emptyState,
|
|
65
|
+
expandedRowRender,
|
|
66
|
+
pagination: externalPagination,
|
|
67
|
+
sorters: externalSorters,
|
|
68
|
+
}: Props = $props();
|
|
17
69
|
|
|
18
70
|
const resource = getResource(resourceName);
|
|
19
71
|
const primaryKey = $derived(resource.primaryKey ?? 'id');
|
|
20
72
|
|
|
21
|
-
// URL state
|
|
73
|
+
// ─── URL state + server-side state ────────────────────────────
|
|
22
74
|
const urlState = readURLState();
|
|
23
75
|
|
|
24
|
-
let pagination = $state<
|
|
76
|
+
let pagination = $state<{ current: number; pageSize: number }>(externalPagination ?? {
|
|
25
77
|
current: urlState.page ?? 1,
|
|
26
78
|
pageSize: urlState.pageSize ?? resource.pageSize ?? 10,
|
|
27
79
|
});
|
|
28
80
|
let sorters = $state<Sort[]>(
|
|
29
|
-
|
|
81
|
+
externalSorters ??
|
|
82
|
+
(urlState.sortField
|
|
30
83
|
? [{ field: urlState.sortField, order: urlState.sortOrder ?? 'asc' }]
|
|
31
|
-
: resource.defaultSort ? [resource.defaultSort] : []
|
|
84
|
+
: resource.defaultSort ? [resource.defaultSort] : [])
|
|
32
85
|
);
|
|
33
86
|
let filters = $state<Filter[]>([]);
|
|
34
87
|
let searchText = $state(urlState.search ?? '');
|
|
35
88
|
|
|
36
|
-
// Batch selection
|
|
37
|
-
let selectedIds = $state<Set<string | number>>(new Set());
|
|
38
|
-
let selectAll = $state(false);
|
|
39
|
-
|
|
40
|
-
// Confirm dialog
|
|
41
|
-
let confirmOpen = $state(false);
|
|
42
|
-
let confirmMessage = $state('');
|
|
43
|
-
let confirmAction = $state<() => void>(() => {});
|
|
44
|
-
|
|
45
89
|
// URL sync
|
|
46
90
|
$effect(() => {
|
|
47
91
|
writeURLState({
|
|
@@ -53,99 +97,193 @@
|
|
|
53
97
|
});
|
|
54
98
|
});
|
|
55
99
|
|
|
100
|
+
// ─── Build active filters with search ─────────────────────────
|
|
56
101
|
const searchableFields = resource.fields.filter(f => f.searchable);
|
|
57
|
-
const
|
|
58
|
-
|
|
102
|
+
const filterableFields = resource.fields.filter(f => f.filterable);
|
|
103
|
+
let filterValues = $state<Record<string, string>>({});
|
|
104
|
+
const activeFilterCount = $derived(Object.values(filterValues).filter(v => v.trim()).length);
|
|
59
105
|
const activeFilters = $derived.by(() => {
|
|
60
106
|
const result: Filter[] = [...filters];
|
|
61
107
|
if (searchText.trim() && searchableFields.length > 0) {
|
|
62
108
|
result.push({ field: searchableFields[0].key, operator: 'contains', value: searchText });
|
|
63
109
|
}
|
|
110
|
+
// Add popover filters
|
|
111
|
+
for (const [key, value] of Object.entries(filterValues)) {
|
|
112
|
+
if (value.trim()) {
|
|
113
|
+
result.push({ field: key, operator: 'contains', value: value.trim() });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
64
116
|
return result;
|
|
65
117
|
});
|
|
66
118
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
});
|
|
119
|
+
// ─── Data fetching ────────────────────────────────────────────
|
|
120
|
+
const listResult = useList({ resource: resourceName, pagination, sorters, filters: activeFilters });
|
|
121
|
+
const query = listResult.query;
|
|
122
|
+
const deleteResult = useDelete({ resource: resourceName });
|
|
123
|
+
const deleteMutation = deleteResult.mutation;
|
|
73
124
|
|
|
74
|
-
|
|
125
|
+
// ─── Permissions ──────────────────────────────────────────────
|
|
126
|
+
const canCreate = canAccess(resourceName, 'create').can && resource.canCreate !== false;
|
|
127
|
+
const canEdit = canAccess(resourceName, 'edit').can && resource.canEdit !== false;
|
|
128
|
+
const canDelete = canAccess(resourceName, 'delete').can && resource.canDelete !== false;
|
|
129
|
+
const canExport = canAccess(resourceName, 'export').can;
|
|
75
130
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
131
|
+
// ─── TanStack Table state ────────────────────────────────────
|
|
132
|
+
let sorting = $state<SortingState>(
|
|
133
|
+
sorters.map(s => ({ id: s.field, desc: s.order === 'desc' }))
|
|
134
|
+
);
|
|
135
|
+
let columnVisibility = $state<VisibilityState>((() => {
|
|
136
|
+
const vis: VisibilityState = {};
|
|
137
|
+
for (const f of resource.fields) {
|
|
138
|
+
if (f.showInList === false) vis[f.key] = false;
|
|
84
139
|
}
|
|
85
|
-
|
|
140
|
+
return vis;
|
|
141
|
+
})());
|
|
142
|
+
let rowSelection = $state<RowSelectionState>({});
|
|
143
|
+
let expanded = $state.raw<ExpandedState>({});
|
|
86
144
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
145
|
+
// Sync TanStack sorting → server sorters
|
|
146
|
+
$effect(() => {
|
|
147
|
+
const newSorters: Sort[] = sorting.map(s => ({
|
|
148
|
+
field: s.id,
|
|
149
|
+
order: s.desc ? 'desc' as const : 'asc' as const,
|
|
150
|
+
}));
|
|
151
|
+
if (JSON.stringify(newSorters) !== JSON.stringify(sorters)) {
|
|
152
|
+
sorters = newSorters;
|
|
153
|
+
}
|
|
154
|
+
});
|
|
92
155
|
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
156
|
+
// ─── Auto-generate columns from resource fields ──────────────
|
|
157
|
+
const visibleFields = $derived(
|
|
158
|
+
resource.fields.filter(f => f.showInList !== false)
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const columns = $derived<ColumnDef<Record<string, unknown>>[]>([
|
|
162
|
+
// Selection column
|
|
163
|
+
...(selectable && canDelete ? [{
|
|
164
|
+
id: '_select',
|
|
165
|
+
header: () => '',
|
|
166
|
+
cell: () => '',
|
|
167
|
+
size: 40,
|
|
168
|
+
enableSorting: false,
|
|
169
|
+
} satisfies ColumnDef<Record<string, unknown>>] : []),
|
|
170
|
+
// Expand column
|
|
171
|
+
...(expandedRowRender ? [{
|
|
172
|
+
id: '_expand',
|
|
173
|
+
header: () => '',
|
|
174
|
+
cell: () => '',
|
|
175
|
+
size: 40,
|
|
176
|
+
enableSorting: false,
|
|
177
|
+
} satisfies ColumnDef<Record<string, unknown>>] : []),
|
|
178
|
+
// Data columns
|
|
179
|
+
...visibleFields.map((field): ColumnDef<Record<string, unknown>> => ({
|
|
180
|
+
id: field.key,
|
|
181
|
+
accessorKey: field.key,
|
|
182
|
+
header: () => field.label,
|
|
183
|
+
size: field.width ? parseInt(String(field.width)) : undefined,
|
|
184
|
+
})),
|
|
185
|
+
// Actions column
|
|
186
|
+
{
|
|
187
|
+
id: '_actions',
|
|
188
|
+
header: () => t('common.actions'),
|
|
189
|
+
cell: () => '',
|
|
190
|
+
size: 100,
|
|
191
|
+
enableSorting: false,
|
|
192
|
+
},
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
// ─── Create TanStack Table ────────────────────────────────────
|
|
196
|
+
const tbl = createTable({
|
|
197
|
+
get data() { return query.data?.data ?? []; },
|
|
198
|
+
columns,
|
|
199
|
+
getCoreRowModel: getCoreRowModel(),
|
|
200
|
+
manualPagination: true,
|
|
201
|
+
manualSorting: true,
|
|
202
|
+
getRowId: (row) => String(row[primaryKey]),
|
|
203
|
+
state: {
|
|
204
|
+
get sorting() { return sorting; },
|
|
205
|
+
get columnVisibility() { return columnVisibility; },
|
|
206
|
+
get rowSelection() { return rowSelection; },
|
|
207
|
+
get expanded() { return expanded; },
|
|
208
|
+
},
|
|
209
|
+
onSortingChange: (updater) => {
|
|
210
|
+
sorting = typeof updater === 'function' ? updater(sorting) : updater;
|
|
211
|
+
},
|
|
212
|
+
onColumnVisibilityChange: (updater) => {
|
|
213
|
+
columnVisibility = typeof updater === 'function' ? updater(columnVisibility) : updater;
|
|
214
|
+
},
|
|
215
|
+
onRowSelectionChange: (updater) => {
|
|
216
|
+
rowSelection = typeof updater === 'function' ? updater(rowSelection) : updater;
|
|
217
|
+
},
|
|
218
|
+
onExpandedChange: (updater) => {
|
|
219
|
+
expanded = typeof updater === 'function' ? updater(expanded) : updater;
|
|
220
|
+
},
|
|
221
|
+
enableRowSelection: selectable && canDelete,
|
|
222
|
+
enableExpanding: !!expandedRowRender,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const selectedCount = $derived(Object.keys(rowSelection).length);
|
|
226
|
+
const totalPages = $derived(Math.ceil((query.data?.total ?? 0) / (pagination.pageSize ?? 10)));
|
|
99
227
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
228
|
+
// ─── Pagination helpers ───────────────────────────────────────
|
|
229
|
+
const currentPage = $derived(pagination.current ?? 1);
|
|
230
|
+
const pages = $derived.by(() => {
|
|
231
|
+
const p: (number | '...')[] = [];
|
|
232
|
+
if (totalPages <= 7) {
|
|
233
|
+
for (let i = 1; i <= totalPages; i++) p.push(i);
|
|
104
234
|
} else {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
235
|
+
p.push(1);
|
|
236
|
+
if (currentPage > 3) p.push('...');
|
|
237
|
+
const start = Math.max(2, currentPage - 1);
|
|
238
|
+
const end = Math.min(totalPages - 1, currentPage + 1);
|
|
239
|
+
for (let i = start; i <= end; i++) p.push(i);
|
|
240
|
+
if (currentPage < totalPages - 2) p.push('...');
|
|
241
|
+
p.push(totalPages);
|
|
108
242
|
}
|
|
109
|
-
|
|
243
|
+
return p;
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ─── Confirm dialog ───────────────────────────────────────────
|
|
247
|
+
let confirmOpen = $state(false);
|
|
248
|
+
let confirmMessage = $state('');
|
|
249
|
+
let confirmAction = $state<() => void>(() => {});
|
|
110
250
|
|
|
111
251
|
function confirmDelete(id: string | number) {
|
|
112
252
|
confirmMessage = t('common.deleteConfirm');
|
|
113
253
|
confirmAction = async () => {
|
|
114
|
-
await deleteMutation.mutateAsync(id);
|
|
254
|
+
await deleteMutation.mutateAsync({ id, resource: resourceName });
|
|
115
255
|
confirmOpen = false;
|
|
116
256
|
};
|
|
117
257
|
confirmOpen = true;
|
|
118
258
|
}
|
|
119
259
|
|
|
120
260
|
function confirmBatchDelete() {
|
|
121
|
-
|
|
261
|
+
const ids = Object.keys(rowSelection);
|
|
262
|
+
confirmMessage = t('common.batchDeleteConfirm', { count: ids.length });
|
|
122
263
|
confirmAction = async () => {
|
|
123
|
-
for (const id of
|
|
124
|
-
await deleteMutation.mutateAsync(id);
|
|
264
|
+
for (const id of ids) {
|
|
265
|
+
await deleteMutation.mutateAsync({ id, resource: resourceName });
|
|
125
266
|
}
|
|
126
|
-
|
|
127
|
-
selectAll = false;
|
|
267
|
+
rowSelection = {};
|
|
128
268
|
confirmOpen = false;
|
|
129
269
|
};
|
|
130
270
|
confirmOpen = true;
|
|
131
271
|
}
|
|
132
272
|
|
|
133
|
-
// CSV Export
|
|
273
|
+
// ─── CSV Export ───────────────────────────────────────────────
|
|
134
274
|
function exportCSV() {
|
|
135
275
|
const data = query.data?.data ?? [];
|
|
136
276
|
if (data.length === 0) return;
|
|
137
|
-
|
|
138
|
-
const headers = listFields.map(f => f.label);
|
|
277
|
+
const headers = visibleFields.map(f => f.label);
|
|
139
278
|
const rows = data.map(record =>
|
|
140
|
-
|
|
279
|
+
visibleFields.map(f => {
|
|
141
280
|
const val = record[f.key];
|
|
142
281
|
if (val == null) return '';
|
|
143
282
|
if (typeof val === 'object') return JSON.stringify(val);
|
|
144
283
|
return String(val);
|
|
145
284
|
})
|
|
146
285
|
);
|
|
147
|
-
|
|
148
|
-
const csv = [headers.join(','), ...rows.map(r => r.map(v => `"${v.replace(/"/g, '""')}"`).join(','))].join('\n');
|
|
286
|
+
const csv = [headers.join(','), ...rows.map(r => r.map(v => `"${v.replace(new RegExp('"', 'g'), '""')}"`).join(','))].join('\n');
|
|
149
287
|
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
|
150
288
|
const link = document.createElement('a');
|
|
151
289
|
link.href = URL.createObjectURL(blob);
|
|
@@ -154,26 +292,9 @@
|
|
|
154
292
|
URL.revokeObjectURL(link.href);
|
|
155
293
|
}
|
|
156
294
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const input = document.createElement('input');
|
|
160
|
-
input.type = 'file';
|
|
161
|
-
input.accept = '.csv';
|
|
162
|
-
input.onchange = async (e: Event) => {
|
|
163
|
-
const file = (e.target as HTMLInputElement).files?.[0];
|
|
164
|
-
if (!file) return;
|
|
165
|
-
alert(`Import: selected ${file.name}, batch create API needed`);
|
|
166
|
-
};
|
|
167
|
-
input.click();
|
|
295
|
+
function goToPage(page: number) {
|
|
296
|
+
pagination = { ...pagination, current: page };
|
|
168
297
|
}
|
|
169
|
-
|
|
170
|
-
// Permissions
|
|
171
|
-
const canCreate = canAccess(resourceName, 'create').can && resource.canCreate !== false;
|
|
172
|
-
const canEdit = canAccess(resourceName, 'edit').can && resource.canEdit !== false;
|
|
173
|
-
const canDelete = canAccess(resourceName, 'delete').can && resource.canDelete !== false;
|
|
174
|
-
const canExport = canAccess(resourceName, 'export').can;
|
|
175
|
-
|
|
176
|
-
const totalPages = $derived(Math.ceil((query.data?.total ?? 0) / (pagination.pageSize ?? 10)));
|
|
177
298
|
</script>
|
|
178
299
|
|
|
179
300
|
<div class="space-y-4">
|
|
@@ -185,15 +306,35 @@
|
|
|
185
306
|
<Button variant="outline" size="sm" onclick={exportCSV}>
|
|
186
307
|
<Download class="h-4 w-4" data-icon="inline-start" /> {t('common.export')}
|
|
187
308
|
</Button>
|
|
188
|
-
<Button variant="outline" size="sm" onclick={handleImportCSV}>
|
|
189
|
-
<Upload class="h-4 w-4" data-icon="inline-start" /> {t('common.import')}
|
|
190
|
-
</Button>
|
|
191
309
|
{/if}
|
|
192
|
-
{#if
|
|
310
|
+
{#if selectedCount > 0 && canDelete}
|
|
193
311
|
<Button variant="destructive" size="sm" onclick={confirmBatchDelete}>
|
|
194
|
-
<Trash2 class="h-4 w-4" data-icon="inline-start" /> {t('common.batchDelete', { count:
|
|
312
|
+
<Trash2 class="h-4 w-4" data-icon="inline-start" /> {t('common.batchDelete', { count: selectedCount })}
|
|
195
313
|
</Button>
|
|
196
314
|
{/if}
|
|
315
|
+
<!-- Column Visibility Picker (DropdownMenu) -->
|
|
316
|
+
<DropdownMenu.Root>
|
|
317
|
+
<DropdownMenu.Trigger>
|
|
318
|
+
{#snippet child({ props })}
|
|
319
|
+
<Button variant="outline" size="sm" {...props}>
|
|
320
|
+
<SlidersHorizontal class="h-4 w-4" data-icon="inline-start" /> {t('common.columns') || 'Columns'}
|
|
321
|
+
</Button>
|
|
322
|
+
{/snippet}
|
|
323
|
+
</DropdownMenu.Trigger>
|
|
324
|
+
<DropdownMenu.Content align="end" class="w-48">
|
|
325
|
+
{#each tbl.getAllLeafColumns().filter(c => !c.id.startsWith('_')) as column}
|
|
326
|
+
<DropdownMenu.CheckboxItem
|
|
327
|
+
checked={column.getIsVisible()}
|
|
328
|
+
onCheckedChange={(v) => column.toggleVisibility(!!v)}
|
|
329
|
+
>
|
|
330
|
+
{visibleFields.find(f => f.key === column.id)?.label ?? column.id}
|
|
331
|
+
</DropdownMenu.CheckboxItem>
|
|
332
|
+
{/each}
|
|
333
|
+
</DropdownMenu.Content>
|
|
334
|
+
</DropdownMenu.Root>
|
|
335
|
+
{#if headerActions}
|
|
336
|
+
{@render headerActions()}
|
|
337
|
+
{/if}
|
|
197
338
|
{#if canCreate}
|
|
198
339
|
<Button onclick={() => navigate(`/${resourceName}/create`)}>
|
|
199
340
|
<Plus class="h-4 w-4" data-icon="inline-start" /> {t('common.create')}
|
|
@@ -216,122 +357,274 @@
|
|
|
216
357
|
</div>
|
|
217
358
|
{/if}
|
|
218
359
|
|
|
219
|
-
<!--
|
|
360
|
+
<!-- Advanced Filters (Popover) -->
|
|
361
|
+
{#if filterableFields.length > 0}
|
|
362
|
+
<Popover.Root>
|
|
363
|
+
<Popover.Trigger>
|
|
364
|
+
{#snippet child({ props })}
|
|
365
|
+
<Button variant="outline" size="sm" {...props}>
|
|
366
|
+
<FilterIcon class="h-4 w-4" data-icon="inline-start" />
|
|
367
|
+
{t('common.filter')}
|
|
368
|
+
{#if activeFilterCount > 0}
|
|
369
|
+
<Badge variant="secondary" class="ml-1 h-5 min-w-5 px-1">{activeFilterCount}</Badge>
|
|
370
|
+
{/if}
|
|
371
|
+
</Button>
|
|
372
|
+
{/snippet}
|
|
373
|
+
</Popover.Trigger>
|
|
374
|
+
<Popover.Content class="w-80">
|
|
375
|
+
<div class="space-y-3">
|
|
376
|
+
<h4 class="font-medium text-sm">{t('common.filter')}</h4>
|
|
377
|
+
{#each filterableFields as field}
|
|
378
|
+
<div class="space-y-1">
|
|
379
|
+
<label class="text-xs text-muted-foreground" for="filter-{field.key}">{field.label}</label>
|
|
380
|
+
<Input
|
|
381
|
+
id="filter-{field.key}"
|
|
382
|
+
type="text"
|
|
383
|
+
bind:value={filterValues[field.key]}
|
|
384
|
+
placeholder={field.label}
|
|
385
|
+
class="h-8 text-sm"
|
|
386
|
+
/>
|
|
387
|
+
</div>
|
|
388
|
+
{/each}
|
|
389
|
+
<div class="flex gap-2">
|
|
390
|
+
<Button size="sm" class="flex-1" onclick={() => { pagination = { ...pagination, current: 1 }; }}>
|
|
391
|
+
{t('common.confirm')}
|
|
392
|
+
</Button>
|
|
393
|
+
<Button variant="outline" size="sm" onclick={() => {
|
|
394
|
+
filterValues = {};
|
|
395
|
+
pagination = { ...pagination, current: 1 };
|
|
396
|
+
}}>
|
|
397
|
+
{t('common.reset')}
|
|
398
|
+
</Button>
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
</Popover.Content>
|
|
402
|
+
</Popover.Root>
|
|
403
|
+
{/if}
|
|
404
|
+
|
|
405
|
+
<!-- Table (TanStack-powered) -->
|
|
220
406
|
<div class="rounded-xl border border-border/60 bg-card shadow-md overflow-hidden">
|
|
221
407
|
{#if query.isLoading}
|
|
222
|
-
<div class="
|
|
223
|
-
<
|
|
408
|
+
<div class="p-4 space-y-3">
|
|
409
|
+
<div class="flex gap-4 mb-2">
|
|
410
|
+
{#each visibleFields.slice(0, 4) as _}
|
|
411
|
+
<Skeleton class="h-4 flex-1" />
|
|
412
|
+
{/each}
|
|
413
|
+
</div>
|
|
414
|
+
{#each Array(5) as _}
|
|
415
|
+
<div class="flex gap-4">
|
|
416
|
+
{#each visibleFields.slice(0, 4) as __}
|
|
417
|
+
<Skeleton class="h-8 flex-1" />
|
|
418
|
+
{/each}
|
|
419
|
+
</div>
|
|
420
|
+
{/each}
|
|
224
421
|
</div>
|
|
225
422
|
{:else if query.error}
|
|
226
423
|
<div class="flex h-64 items-center justify-center text-destructive text-sm">
|
|
227
424
|
{t('common.loadFailed', { message: (query.error as Error).message })}
|
|
228
425
|
</div>
|
|
229
426
|
{:else}
|
|
230
|
-
<
|
|
231
|
-
<Table.
|
|
232
|
-
<Table.
|
|
233
|
-
{#
|
|
234
|
-
<Table.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
427
|
+
<div in:fade={{ duration: 150 }}>
|
|
428
|
+
<Table.Root>
|
|
429
|
+
<Table.Header>
|
|
430
|
+
{#each tbl.getHeaderGroups() as headerGroup}
|
|
431
|
+
<Table.Row class="bg-muted/50 hover:bg-muted/50">
|
|
432
|
+
{#each headerGroup.headers as header}
|
|
433
|
+
<Table.Head
|
|
434
|
+
style={header.getSize() !== 150 ? `width:${header.getSize()}px` : undefined}
|
|
435
|
+
>
|
|
436
|
+
{#if header.id === '_select'}
|
|
437
|
+
<Checkbox
|
|
438
|
+
checked={tbl.getIsAllRowsSelected()}
|
|
439
|
+
onCheckedChange={() => tbl.toggleAllRowsSelected()}
|
|
440
|
+
/>
|
|
441
|
+
{:else if header.id === '_expand'}
|
|
442
|
+
<!-- empty -->
|
|
443
|
+
{:else if header.id === '_actions'}
|
|
444
|
+
<span class="text-right block">{t('common.actions')}</span>
|
|
445
|
+
{:else if header.column.getCanSort()}
|
|
446
|
+
<Button
|
|
447
|
+
variant="ghost"
|
|
448
|
+
size="sm"
|
|
449
|
+
class="flex items-center gap-1 hover:text-foreground -ml-3 h-auto py-1 px-2"
|
|
450
|
+
onclick={() => header.column.toggleSorting()}
|
|
451
|
+
>
|
|
452
|
+
{visibleFields.find(f => f.key === header.id)?.label ?? header.id}
|
|
453
|
+
<span class="text-xs opacity-50">
|
|
454
|
+
{#if header.column.getIsSorted() === 'asc'}↑
|
|
455
|
+
{:else if header.column.getIsSorted() === 'desc'}↓
|
|
456
|
+
{:else}⇅
|
|
457
|
+
{/if}
|
|
458
|
+
</span>
|
|
459
|
+
</Button>
|
|
460
|
+
{:else}
|
|
461
|
+
{visibleFields.find(f => f.key === header.id)?.label ?? header.id}
|
|
462
|
+
{/if}
|
|
463
|
+
</Table.Head>
|
|
464
|
+
{/each}
|
|
465
|
+
</Table.Row>
|
|
245
466
|
{/each}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
467
|
+
</Table.Header>
|
|
468
|
+
<Table.Body>
|
|
469
|
+
{#each tbl.getRowModel().rows as row}
|
|
470
|
+
{@const record = row.original}
|
|
471
|
+
{@const id = record[primaryKey] as string | number}
|
|
472
|
+
<ContextMenu.Root>
|
|
473
|
+
<ContextMenu.Trigger>
|
|
474
|
+
{#snippet child({ props })}
|
|
475
|
+
<Table.Row {...props} class="transition-colors {row.getIsSelected() ? 'bg-accent' : ''}">
|
|
476
|
+
{#each row.getVisibleCells() as cell}
|
|
477
|
+
<Table.Cell>
|
|
478
|
+
{#if cell.column.id === '_select'}
|
|
479
|
+
<Checkbox
|
|
480
|
+
checked={row.getIsSelected()}
|
|
481
|
+
onCheckedChange={() => row.toggleSelected()}
|
|
482
|
+
/>
|
|
483
|
+
{:else if cell.column.id === '_expand'}
|
|
484
|
+
<Button variant="ghost" size="icon" class="h-7 w-7" onclick={() => row.toggleExpanded()}>
|
|
485
|
+
{#if row.getIsExpanded()}
|
|
486
|
+
<ChevronUp class="h-4 w-4" />
|
|
487
|
+
{:else}
|
|
488
|
+
<ChevronDown class="h-4 w-4" />
|
|
489
|
+
{/if}
|
|
490
|
+
</Button>
|
|
491
|
+
{:else if cell.column.id === '_actions'}
|
|
492
|
+
<div class="flex items-center justify-end gap-1">
|
|
493
|
+
{#if rowActions}
|
|
494
|
+
{@render rowActions({ record, id })}
|
|
495
|
+
{:else}
|
|
496
|
+
{#if canEdit}
|
|
497
|
+
<Button variant="ghost" size="icon-sm" onclick={() => navigate(`/${resourceName}/edit/${id}`)} title={t('common.edit')}>
|
|
498
|
+
<Pencil class="h-4 w-4" />
|
|
499
|
+
</Button>
|
|
500
|
+
{/if}
|
|
501
|
+
{#if canDelete}
|
|
502
|
+
<Button variant="ghost" size="icon-sm" onclick={() => confirmDelete(id)} title={t('common.delete')} class="hover:text-destructive">
|
|
503
|
+
<Trash2 class="h-4 w-4" />
|
|
504
|
+
</Button>
|
|
505
|
+
{/if}
|
|
506
|
+
{/if}
|
|
507
|
+
</div>
|
|
508
|
+
{:else}
|
|
509
|
+
{@const field = visibleFields.find(f => f.key === cell.column.id)}
|
|
510
|
+
{#if cellRenderer && field}
|
|
511
|
+
{@render cellRenderer({ field, value: cell.getValue(), record })}
|
|
512
|
+
{:else if field?.type === 'boolean'}
|
|
513
|
+
<span class="inline-block h-2 w-2 rounded-full {cell.getValue() ? 'bg-green-500' : 'bg-muted-foreground/30'}"></span>
|
|
514
|
+
{:else if field?.type === 'date' && cell.getValue()}
|
|
515
|
+
{new Date(cell.getValue() as string).toLocaleDateString()}
|
|
516
|
+
{:else if field?.type === 'tags' && Array.isArray(cell.getValue())}
|
|
517
|
+
<div class="flex flex-wrap gap-1">
|
|
518
|
+
{#each (cell.getValue() as string[]).slice(0, 3) as tag}
|
|
519
|
+
<Badge variant="secondary">{tag}</Badge>
|
|
520
|
+
{/each}
|
|
521
|
+
</div>
|
|
522
|
+
{:else if field?.type === 'select' && field.options}
|
|
523
|
+
{@const opt = field.options.find(o => o.value === cell.getValue())}
|
|
524
|
+
<Badge variant="outline">{opt?.label ?? cell.getValue() ?? '—'}</Badge>
|
|
525
|
+
{:else}
|
|
526
|
+
{cell.getValue() ?? '—'}
|
|
527
|
+
{/if}
|
|
528
|
+
{/if}
|
|
529
|
+
</Table.Cell>
|
|
268
530
|
{/each}
|
|
269
|
-
</
|
|
270
|
-
{
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
{:else}
|
|
274
|
-
{record[field.key] ?? '—'}
|
|
275
|
-
{/if}
|
|
276
|
-
</Table.Cell>
|
|
277
|
-
{/each}
|
|
278
|
-
<Table.Cell class="text-right">
|
|
279
|
-
<div class="flex items-center justify-end gap-1">
|
|
531
|
+
</Table.Row>
|
|
532
|
+
{/snippet}
|
|
533
|
+
</ContextMenu.Trigger>
|
|
534
|
+
<ContextMenu.Content class="w-48">
|
|
280
535
|
{#if canEdit}
|
|
281
|
-
<
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
title={t('common.edit')}
|
|
285
|
-
>
|
|
286
|
-
<Pencil class="h-4 w-4" />
|
|
287
|
-
</Button>
|
|
536
|
+
<ContextMenu.Item onclick={() => navigate(`/${resourceName}/edit/${id}`)} class="gap-2">
|
|
537
|
+
<Pencil class="h-4 w-4" /> {t('common.edit')}
|
|
538
|
+
</ContextMenu.Item>
|
|
288
539
|
{/if}
|
|
540
|
+
<ContextMenu.Item onclick={() => navigate(`/${resourceName}/show/${id}`)} class="gap-2">
|
|
541
|
+
<Eye class="h-4 w-4" /> {t('common.detail')}
|
|
542
|
+
</ContextMenu.Item>
|
|
543
|
+
<ContextMenu.Item onclick={() => navigator.clipboard?.writeText(String(id))} class="gap-2">
|
|
544
|
+
<Copy class="h-4 w-4" /> {t('common.copyId')}
|
|
545
|
+
</ContextMenu.Item>
|
|
289
546
|
{#if canDelete}
|
|
290
|
-
<
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
class="hover:text-destructive"
|
|
295
|
-
>
|
|
296
|
-
<Trash2 class="h-4 w-4" />
|
|
297
|
-
</Button>
|
|
547
|
+
<ContextMenu.Separator />
|
|
548
|
+
<ContextMenu.Item onclick={() => confirmDelete(id)} class="gap-2 text-destructive">
|
|
549
|
+
<Trash2 class="h-4 w-4" /> {t('common.delete')}
|
|
550
|
+
</ContextMenu.Item>
|
|
298
551
|
{/if}
|
|
299
|
-
</
|
|
300
|
-
</
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
552
|
+
</ContextMenu.Content>
|
|
553
|
+
</ContextMenu.Root>
|
|
554
|
+
{#if expandedRowRender && row.getIsExpanded()}
|
|
555
|
+
<Table.Row class="bg-muted/30">
|
|
556
|
+
<Table.Cell colspan={row.getVisibleCells().length}>
|
|
557
|
+
{@render expandedRowRender({ record })}
|
|
558
|
+
</Table.Cell>
|
|
559
|
+
</Table.Row>
|
|
560
|
+
{/if}
|
|
561
|
+
{:else}
|
|
562
|
+
<Table.Row>
|
|
563
|
+
<Table.Cell colspan={columns.length} class="h-24 text-center text-muted-foreground">
|
|
564
|
+
{#if emptyState}
|
|
565
|
+
{@render emptyState()}
|
|
566
|
+
{:else}
|
|
567
|
+
{t('common.noData')}
|
|
568
|
+
{/if}
|
|
569
|
+
</Table.Cell>
|
|
570
|
+
</Table.Row>
|
|
571
|
+
{/each}
|
|
572
|
+
</Table.Body>
|
|
573
|
+
</Table.Root>
|
|
574
|
+
</div>
|
|
311
575
|
{/if}
|
|
312
576
|
</div>
|
|
313
577
|
|
|
314
|
-
<!-- Pagination -->
|
|
578
|
+
<!-- Pagination (shadcn) -->
|
|
579
|
+
{#if totalPages > 0}
|
|
315
580
|
<div class="flex items-center justify-between text-sm text-muted-foreground">
|
|
316
|
-
<span>{t('common.total', { total: query.data?.total ?? 0 })}</span>
|
|
317
581
|
<div class="flex items-center gap-2">
|
|
318
|
-
<
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
<Button
|
|
327
|
-
variant="outline" size="icon-sm"
|
|
328
|
-
onclick={() => { pagination = { ...pagination, current: Math.min(totalPages, (pagination.current ?? 1) + 1) }; }}
|
|
329
|
-
disabled={(pagination.current ?? 1) >= totalPages}
|
|
582
|
+
<span>{t('common.total', { total: query.data?.total ?? 0 })}</span>
|
|
583
|
+
<Select
|
|
584
|
+
class="h-8 w-auto text-xs"
|
|
585
|
+
value={String(pagination.pageSize ?? 10)}
|
|
586
|
+
onchange={(e: Event) => {
|
|
587
|
+
const size = Number((e.target as HTMLSelectElement).value);
|
|
588
|
+
pagination = { ...pagination, pageSize: size, current: 1 };
|
|
589
|
+
}}
|
|
330
590
|
>
|
|
331
|
-
|
|
332
|
-
|
|
591
|
+
{#each [10, 20, 50, 100] as size}
|
|
592
|
+
<option value={size}>{size} / {t('common.perPage')}</option>
|
|
593
|
+
{/each}
|
|
594
|
+
</Select>
|
|
333
595
|
</div>
|
|
596
|
+
<PaginationUI.Root>
|
|
597
|
+
<PaginationUI.Content>
|
|
598
|
+
<PaginationUI.Item>
|
|
599
|
+
<PaginationUI.Previous
|
|
600
|
+
onclick={() => goToPage(Math.max(1, currentPage - 1))}
|
|
601
|
+
disabled={currentPage <= 1}
|
|
602
|
+
/>
|
|
603
|
+
</PaginationUI.Item>
|
|
604
|
+
{#each pages as page}
|
|
605
|
+
<PaginationUI.Item>
|
|
606
|
+
{#if page === '...'}
|
|
607
|
+
<PaginationUI.Ellipsis />
|
|
608
|
+
{:else}
|
|
609
|
+
<PaginationUI.Link
|
|
610
|
+
isActive={page === currentPage}
|
|
611
|
+
onclick={() => goToPage(page as number)}
|
|
612
|
+
>
|
|
613
|
+
{page}
|
|
614
|
+
</PaginationUI.Link>
|
|
615
|
+
{/if}
|
|
616
|
+
</PaginationUI.Item>
|
|
617
|
+
{/each}
|
|
618
|
+
<PaginationUI.Item>
|
|
619
|
+
<PaginationUI.Next
|
|
620
|
+
onclick={() => goToPage(Math.min(totalPages, currentPage + 1))}
|
|
621
|
+
disabled={currentPage >= totalPages}
|
|
622
|
+
/>
|
|
623
|
+
</PaginationUI.Item>
|
|
624
|
+
</PaginationUI.Content>
|
|
625
|
+
</PaginationUI.Root>
|
|
334
626
|
</div>
|
|
627
|
+
{/if}
|
|
335
628
|
</div>
|
|
336
629
|
|
|
337
630
|
<ConfirmDialog
|