@svadmin/ui 0.3.0 → 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 +7 -3
- package/src/actions.ts +110 -0
- package/src/components/AdminApp.svelte +7 -4
- package/src/components/Authenticated.svelte +1 -7
- package/src/components/AutoForm.svelte +158 -69
- package/src/components/AutoSaveIndicator.svelte +19 -14
- package/src/components/AutoTable.svelte +465 -243
- package/src/components/Breadcrumbs.svelte +20 -17
- 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 +3 -2
- package/src/components/DevTools.svelte +99 -230
- package/src/components/DrawerForm.svelte +19 -10
- package/src/components/EditPage.svelte +25 -6
- package/src/components/EmptyState.svelte +5 -1
- package/src/components/ErrorBoundary.svelte +38 -40
- package/src/components/ErrorComponent.svelte +18 -17
- package/src/components/FieldRenderer.svelte +240 -75
- package/src/components/ForgotPasswordPage.svelte +20 -111
- package/src/components/Header.svelte +28 -8
- 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 +25 -1
- package/src/components/LiveIndicator.svelte +30 -87
- package/src/components/LoginPage.svelte +33 -154
- package/src/components/ModalForm.svelte +6 -2
- package/src/components/PageSkeleton.svelte +2 -1
- package/src/components/PasswordInput.svelte +95 -0
- package/src/components/RegisterPage.svelte +35 -164
- package/src/components/ShowPage.svelte +32 -33
- package/src/components/Sidebar.svelte +185 -73
- package/src/components/StatsCard.svelte +12 -3
- package/src/components/StepsForm.svelte +139 -0
- package/src/components/Toast.svelte +15 -54
- 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/RefreshButton.svelte +7 -2
- package/src/components/fieldComponentMap.ts +79 -0
- package/src/components/fields/BooleanField.svelte +9 -9
- package/src/components/fields/EmailField.svelte +2 -8
- package/src/components/fields/FileField.svelte +4 -9
- package/src/components/fields/ImageField.svelte +2 -7
- package/src/components/fields/JsonField.svelte +72 -0
- package/src/components/fields/MarkdownField.svelte +2 -12
- package/src/components/fields/MultiSelectField.svelte +20 -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 +6 -11
- package/src/components/fields/TextField.svelte +2 -7
- package/src/components/fields/UrlField.svelte +4 -10
- 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/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 +30 -1
|
@@ -1,76 +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';
|
|
15
|
-
|
|
16
36
|
import type { Snippet } from 'svelte';
|
|
17
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
|
+
}
|
|
57
|
+
|
|
18
58
|
let {
|
|
19
59
|
resourceName,
|
|
20
|
-
expandedRowRender,
|
|
21
60
|
selectable = true,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
61
|
+
headerActions,
|
|
62
|
+
cellRenderer,
|
|
63
|
+
rowActions,
|
|
64
|
+
emptyState,
|
|
65
|
+
expandedRowRender,
|
|
66
|
+
pagination: externalPagination,
|
|
67
|
+
sorters: externalSorters,
|
|
68
|
+
}: Props = $props();
|
|
27
69
|
|
|
28
70
|
const resource = getResource(resourceName);
|
|
29
71
|
const primaryKey = $derived(resource.primaryKey ?? 'id');
|
|
30
72
|
|
|
31
|
-
// URL state
|
|
73
|
+
// ─── URL state + server-side state ────────────────────────────
|
|
32
74
|
const urlState = readURLState();
|
|
33
75
|
|
|
34
|
-
let pagination = $state<
|
|
76
|
+
let pagination = $state<{ current: number; pageSize: number }>(externalPagination ?? {
|
|
35
77
|
current: urlState.page ?? 1,
|
|
36
78
|
pageSize: urlState.pageSize ?? resource.pageSize ?? 10,
|
|
37
79
|
});
|
|
38
80
|
let sorters = $state<Sort[]>(
|
|
39
|
-
|
|
81
|
+
externalSorters ??
|
|
82
|
+
(urlState.sortField
|
|
40
83
|
? [{ field: urlState.sortField, order: urlState.sortOrder ?? 'asc' }]
|
|
41
|
-
: resource.defaultSort ? [resource.defaultSort] : []
|
|
84
|
+
: resource.defaultSort ? [resource.defaultSort] : [])
|
|
42
85
|
);
|
|
43
86
|
let filters = $state<Filter[]>([]);
|
|
44
87
|
let searchText = $state(urlState.search ?? '');
|
|
45
88
|
|
|
46
|
-
// Column visibility
|
|
47
|
-
let columnVisibility = $state<Record<string, boolean>>({});
|
|
48
|
-
function initColumnVisibility() {
|
|
49
|
-
const vis: Record<string, boolean> = {};
|
|
50
|
-
for (const f of resource.fields) vis[f.key] = f.showInList !== false;
|
|
51
|
-
return vis;
|
|
52
|
-
}
|
|
53
|
-
columnVisibility = initColumnVisibility();
|
|
54
|
-
const visibleFields = $derived(resource.fields.filter(f => columnVisibility[f.key] !== false && f.showInList !== false));
|
|
55
|
-
let showColumnPicker = $state(false);
|
|
56
|
-
|
|
57
|
-
// Expanded rows
|
|
58
|
-
let expandedRowIds = $state<Set<string | number>>(new Set());
|
|
59
|
-
function toggleExpand(id: string | number) {
|
|
60
|
-
const next = new Set(expandedRowIds);
|
|
61
|
-
if (next.has(id)) next.delete(id); else next.add(id);
|
|
62
|
-
expandedRowIds = next;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Batch selection
|
|
66
|
-
let selectedIds = $state<Set<string | number>>(new Set());
|
|
67
|
-
let selectAll = $state(false);
|
|
68
|
-
|
|
69
|
-
// Confirm dialog
|
|
70
|
-
let confirmOpen = $state(false);
|
|
71
|
-
let confirmMessage = $state('');
|
|
72
|
-
let confirmAction = $state<() => void>(() => {});
|
|
73
|
-
|
|
74
89
|
// URL sync
|
|
75
90
|
$effect(() => {
|
|
76
91
|
writeURLState({
|
|
@@ -82,62 +97,156 @@
|
|
|
82
97
|
});
|
|
83
98
|
});
|
|
84
99
|
|
|
100
|
+
// ─── Build active filters with search ─────────────────────────
|
|
85
101
|
const searchableFields = resource.fields.filter(f => f.searchable);
|
|
86
|
-
const
|
|
87
|
-
|
|
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);
|
|
88
105
|
const activeFilters = $derived.by(() => {
|
|
89
106
|
const result: Filter[] = [...filters];
|
|
90
107
|
if (searchText.trim() && searchableFields.length > 0) {
|
|
91
108
|
result.push({ field: searchableFields[0].key, operator: 'contains', value: searchText });
|
|
92
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
|
+
}
|
|
93
116
|
return result;
|
|
94
117
|
});
|
|
95
118
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
pagination,
|
|
99
|
-
sorters,
|
|
100
|
-
filters: activeFilters,
|
|
101
|
-
});
|
|
119
|
+
// ─── Data fetching ────────────────────────────────────────────
|
|
120
|
+
const listResult = useList({ resource: resourceName, pagination, sorters, filters: activeFilters });
|
|
102
121
|
const query = listResult.query;
|
|
103
|
-
|
|
104
122
|
const deleteResult = useDelete({ resource: resourceName });
|
|
105
123
|
const deleteMutation = deleteResult.mutation;
|
|
106
124
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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;
|
|
130
|
+
|
|
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;
|
|
115
139
|
}
|
|
116
|
-
|
|
140
|
+
return vis;
|
|
141
|
+
})());
|
|
142
|
+
let rowSelection = $state<RowSelectionState>({});
|
|
143
|
+
let expanded = $state.raw<ExpandedState>({});
|
|
117
144
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
+
});
|
|
123
155
|
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
+
});
|
|
130
224
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
225
|
+
const selectedCount = $derived(Object.keys(rowSelection).length);
|
|
226
|
+
const totalPages = $derived(Math.ceil((query.data?.total ?? 0) / (pagination.pageSize ?? 10)));
|
|
227
|
+
|
|
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);
|
|
135
234
|
} else {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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);
|
|
139
242
|
}
|
|
140
|
-
|
|
243
|
+
return p;
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ─── Confirm dialog ───────────────────────────────────────────
|
|
247
|
+
let confirmOpen = $state(false);
|
|
248
|
+
let confirmMessage = $state('');
|
|
249
|
+
let confirmAction = $state<() => void>(() => {});
|
|
141
250
|
|
|
142
251
|
function confirmDelete(id: string | number) {
|
|
143
252
|
confirmMessage = t('common.deleteConfirm');
|
|
@@ -149,34 +258,32 @@
|
|
|
149
258
|
}
|
|
150
259
|
|
|
151
260
|
function confirmBatchDelete() {
|
|
152
|
-
|
|
261
|
+
const ids = Object.keys(rowSelection);
|
|
262
|
+
confirmMessage = t('common.batchDeleteConfirm', { count: ids.length });
|
|
153
263
|
confirmAction = async () => {
|
|
154
|
-
for (const id of
|
|
264
|
+
for (const id of ids) {
|
|
155
265
|
await deleteMutation.mutateAsync({ id, resource: resourceName });
|
|
156
266
|
}
|
|
157
|
-
|
|
158
|
-
selectAll = false;
|
|
267
|
+
rowSelection = {};
|
|
159
268
|
confirmOpen = false;
|
|
160
269
|
};
|
|
161
270
|
confirmOpen = true;
|
|
162
271
|
}
|
|
163
272
|
|
|
164
|
-
// CSV Export
|
|
273
|
+
// ─── CSV Export ───────────────────────────────────────────────
|
|
165
274
|
function exportCSV() {
|
|
166
275
|
const data = query.data?.data ?? [];
|
|
167
276
|
if (data.length === 0) return;
|
|
168
|
-
|
|
169
|
-
const headers = listFields.map(f => f.label);
|
|
277
|
+
const headers = visibleFields.map(f => f.label);
|
|
170
278
|
const rows = data.map(record =>
|
|
171
|
-
|
|
279
|
+
visibleFields.map(f => {
|
|
172
280
|
const val = record[f.key];
|
|
173
281
|
if (val == null) return '';
|
|
174
282
|
if (typeof val === 'object') return JSON.stringify(val);
|
|
175
283
|
return String(val);
|
|
176
284
|
})
|
|
177
285
|
);
|
|
178
|
-
|
|
179
|
-
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');
|
|
180
287
|
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
|
181
288
|
const link = document.createElement('a');
|
|
182
289
|
link.href = URL.createObjectURL(blob);
|
|
@@ -185,26 +292,9 @@
|
|
|
185
292
|
URL.revokeObjectURL(link.href);
|
|
186
293
|
}
|
|
187
294
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const input = document.createElement('input');
|
|
191
|
-
input.type = 'file';
|
|
192
|
-
input.accept = '.csv';
|
|
193
|
-
input.onchange = async (e: Event) => {
|
|
194
|
-
const file = (e.target as HTMLInputElement).files?.[0];
|
|
195
|
-
if (!file) return;
|
|
196
|
-
alert(`Import: selected ${file.name}, batch create API needed`);
|
|
197
|
-
};
|
|
198
|
-
input.click();
|
|
295
|
+
function goToPage(page: number) {
|
|
296
|
+
pagination = { ...pagination, current: page };
|
|
199
297
|
}
|
|
200
|
-
|
|
201
|
-
// Permissions
|
|
202
|
-
const canCreate = canAccess(resourceName, 'create').can && resource.canCreate !== false;
|
|
203
|
-
const canEdit = canAccess(resourceName, 'edit').can && resource.canEdit !== false;
|
|
204
|
-
const canDelete = canAccess(resourceName, 'delete').can && resource.canDelete !== false;
|
|
205
|
-
const canExport = canAccess(resourceName, 'export').can;
|
|
206
|
-
|
|
207
|
-
const totalPages = $derived(Math.ceil((query.data?.total ?? 0) / (pagination.pageSize ?? 10)));
|
|
208
298
|
</script>
|
|
209
299
|
|
|
210
300
|
<div class="space-y-4">
|
|
@@ -216,34 +306,35 @@
|
|
|
216
306
|
<Button variant="outline" size="sm" onclick={exportCSV}>
|
|
217
307
|
<Download class="h-4 w-4" data-icon="inline-start" /> {t('common.export')}
|
|
218
308
|
</Button>
|
|
219
|
-
<Button variant="outline" size="sm" onclick={handleImportCSV}>
|
|
220
|
-
<Upload class="h-4 w-4" data-icon="inline-start" /> {t('common.import')}
|
|
221
|
-
</Button>
|
|
222
309
|
{/if}
|
|
223
|
-
{#if
|
|
310
|
+
{#if selectedCount > 0 && canDelete}
|
|
224
311
|
<Button variant="destructive" size="sm" onclick={confirmBatchDelete}>
|
|
225
|
-
<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 })}
|
|
226
313
|
</Button>
|
|
227
314
|
{/if}
|
|
228
|
-
<!-- Column Visibility Picker -->
|
|
229
|
-
<
|
|
230
|
-
<
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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}
|
|
247
338
|
{#if canCreate}
|
|
248
339
|
<Button onclick={() => navigate(`/${resourceName}/create`)}>
|
|
249
340
|
<Plus class="h-4 w-4" data-icon="inline-start" /> {t('common.create')}
|
|
@@ -266,143 +357,274 @@
|
|
|
266
357
|
</div>
|
|
267
358
|
{/if}
|
|
268
359
|
|
|
269
|
-
<!--
|
|
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) -->
|
|
270
406
|
<div class="rounded-xl border border-border/60 bg-card shadow-md overflow-hidden">
|
|
271
407
|
{#if query.isLoading}
|
|
272
|
-
<div class="
|
|
273
|
-
<
|
|
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}
|
|
274
421
|
</div>
|
|
275
422
|
{:else if query.error}
|
|
276
423
|
<div class="flex h-64 items-center justify-center text-destructive text-sm">
|
|
277
424
|
{t('common.loadFailed', { message: (query.error as Error).message })}
|
|
278
425
|
</div>
|
|
279
426
|
{:else}
|
|
280
|
-
<
|
|
281
|
-
<Table.
|
|
282
|
-
<Table.
|
|
283
|
-
{#
|
|
284
|
-
<Table.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
<button class="p-1 hover:bg-accent rounded" onclick={() => toggleExpand(id)}>
|
|
314
|
-
{#if expandedRowIds.has(id)}
|
|
315
|
-
<ChevronUp class="h-4 w-4" />
|
|
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>
|
|
316
460
|
{:else}
|
|
317
|
-
|
|
461
|
+
{visibleFields.find(f => f.key === header.id)?.label ?? header.id}
|
|
318
462
|
{/if}
|
|
319
|
-
</
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
463
|
+
</Table.Head>
|
|
464
|
+
{/each}
|
|
465
|
+
</Table.Row>
|
|
466
|
+
{/each}
|
|
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>
|
|
332
530
|
{/each}
|
|
333
|
-
</
|
|
334
|
-
{
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
{:else}
|
|
338
|
-
{record[field.key] ?? '—'}
|
|
339
|
-
{/if}
|
|
340
|
-
</Table.Cell>
|
|
341
|
-
{/each}
|
|
342
|
-
<Table.Cell class="text-right">
|
|
343
|
-
<div class="flex items-center justify-end gap-1">
|
|
531
|
+
</Table.Row>
|
|
532
|
+
{/snippet}
|
|
533
|
+
</ContextMenu.Trigger>
|
|
534
|
+
<ContextMenu.Content class="w-48">
|
|
344
535
|
{#if canEdit}
|
|
345
|
-
<
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
title={t('common.edit')}
|
|
349
|
-
>
|
|
350
|
-
<Pencil class="h-4 w-4" />
|
|
351
|
-
</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>
|
|
352
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>
|
|
353
546
|
{#if canDelete}
|
|
354
|
-
<
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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>
|
|
551
|
+
{/if}
|
|
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')}
|
|
362
568
|
{/if}
|
|
363
|
-
</div>
|
|
364
|
-
</Table.Cell>
|
|
365
|
-
</Table.Row>
|
|
366
|
-
{#if expandedRowRender && expandedRowIds.has(id)}
|
|
367
|
-
<Table.Row class="bg-muted/30">
|
|
368
|
-
<Table.Cell colspan={listFields.length + (selectable && canDelete ? 1 : 0) + (expandedRowRender ? 1 : 0) + 1}>
|
|
369
|
-
{@render expandedRowRender(record)}
|
|
370
569
|
</Table.Cell>
|
|
371
570
|
</Table.Row>
|
|
372
|
-
{/
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
{t('common.noData')}
|
|
377
|
-
</Table.Cell>
|
|
378
|
-
</Table.Row>
|
|
379
|
-
{/each}
|
|
380
|
-
</Table.Body>
|
|
381
|
-
</Table.Root>
|
|
571
|
+
{/each}
|
|
572
|
+
</Table.Body>
|
|
573
|
+
</Table.Root>
|
|
574
|
+
</div>
|
|
382
575
|
{/if}
|
|
383
576
|
</div>
|
|
384
577
|
|
|
385
|
-
<!-- Pagination -->
|
|
578
|
+
<!-- Pagination (shadcn) -->
|
|
579
|
+
{#if totalPages > 0}
|
|
386
580
|
<div class="flex items-center justify-between text-sm text-muted-foreground">
|
|
387
|
-
<span>{t('common.total', { total: query.data?.total ?? 0 })}</span>
|
|
388
581
|
<div class="flex items-center gap-2">
|
|
389
|
-
<
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
+
}}
|
|
393
590
|
>
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
variant="outline" size="icon-sm"
|
|
399
|
-
onclick={() => { pagination = { ...pagination, current: Math.min(totalPages, (pagination.current ?? 1) + 1) }; }}
|
|
400
|
-
disabled={(pagination.current ?? 1) >= totalPages}
|
|
401
|
-
>
|
|
402
|
-
<ChevronRight class="h-4 w-4" />
|
|
403
|
-
</Button>
|
|
591
|
+
{#each [10, 20, 50, 100] as size}
|
|
592
|
+
<option value={size}>{size} / {t('common.perPage')}</option>
|
|
593
|
+
{/each}
|
|
594
|
+
</Select>
|
|
404
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>
|
|
405
626
|
</div>
|
|
627
|
+
{/if}
|
|
406
628
|
</div>
|
|
407
629
|
|
|
408
630
|
<ConfirmDialog
|