@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.
Files changed (120) hide show
  1. package/package.json +8 -3
  2. package/src/actions.ts +110 -0
  3. package/src/components/AdminApp.svelte +8 -4
  4. package/src/components/Authenticated.svelte +38 -13
  5. package/src/components/AutoForm.svelte +163 -74
  6. package/src/components/AutoSaveIndicator.svelte +35 -0
  7. package/src/components/AutoTable.svelte +476 -183
  8. package/src/components/Breadcrumbs.svelte +22 -17
  9. package/src/components/CatchAllNavigate.svelte +21 -0
  10. package/src/components/ComboboxField.svelte +122 -0
  11. package/src/components/CommandPalette.svelte +93 -0
  12. package/src/components/ConfigErrorScreen.svelte +53 -151
  13. package/src/components/ConfirmDialog.svelte +25 -17
  14. package/src/components/CreatePage.svelte +43 -0
  15. package/src/components/DevTools.svelte +99 -230
  16. package/src/components/DrawerForm.svelte +19 -10
  17. package/src/components/EditPage.svelte +71 -0
  18. package/src/components/EmptyState.svelte +21 -31
  19. package/src/components/ErrorBoundary.svelte +38 -40
  20. package/src/components/ErrorComponent.svelte +36 -0
  21. package/src/components/FieldRenderer.svelte +240 -75
  22. package/src/components/ForgotPasswordPage.svelte +20 -111
  23. package/src/components/Header.svelte +46 -57
  24. package/src/components/InferencerPanel.svelte +136 -110
  25. package/src/components/InfiniteList.svelte +93 -0
  26. package/src/components/Layout.svelte +85 -22
  27. package/src/components/ListPage.svelte +71 -0
  28. package/src/components/LiveIndicator.svelte +58 -0
  29. package/src/components/LoginPage.svelte +33 -154
  30. package/src/components/ModalForm.svelte +6 -2
  31. package/src/components/NavigateToResource.svelte +20 -0
  32. package/src/components/PageSkeleton.svelte +71 -0
  33. package/src/components/PasswordInput.svelte +95 -0
  34. package/src/components/RegisterPage.svelte +35 -164
  35. package/src/components/ShowPage.svelte +35 -36
  36. package/src/components/Sidebar.svelte +212 -81
  37. package/src/components/StatsCard.svelte +12 -3
  38. package/src/components/StepsForm.svelte +139 -0
  39. package/src/components/ThemedTitle.svelte +32 -0
  40. package/src/components/Toast.svelte +15 -44
  41. package/src/components/TooltipButton.svelte +52 -0
  42. package/src/components/UndoableNotification.svelte +28 -96
  43. package/src/components/UpdatePasswordPage.svelte +32 -160
  44. package/src/components/buttons/CloneButton.svelte +1 -1
  45. package/src/components/buttons/CreateButton.svelte +1 -1
  46. package/src/components/buttons/DeleteButton.svelte +3 -4
  47. package/src/components/buttons/EditButton.svelte +1 -1
  48. package/src/components/buttons/ImportButton.svelte +29 -7
  49. package/src/components/buttons/RefreshButton.svelte +11 -5
  50. package/src/components/fieldComponentMap.ts +79 -0
  51. package/src/components/fields/BooleanField.svelte +13 -0
  52. package/src/components/fields/DateField.svelte +6 -0
  53. package/src/components/fields/EmailField.svelte +9 -0
  54. package/src/components/fields/FileField.svelte +14 -0
  55. package/src/components/fields/ImageField.svelte +9 -0
  56. package/src/components/fields/JsonField.svelte +72 -0
  57. package/src/components/fields/MarkdownField.svelte +9 -0
  58. package/src/components/fields/MultiSelectField.svelte +20 -0
  59. package/src/components/fields/NumberField.svelte +6 -0
  60. package/src/components/fields/RelationField.svelte +24 -0
  61. package/src/components/fields/RichTextField.svelte +35 -0
  62. package/src/components/fields/SelectField.svelte +14 -0
  63. package/src/components/fields/TagField.svelte +12 -0
  64. package/src/components/fields/TextField.svelte +11 -0
  65. package/src/components/fields/UrlField.svelte +14 -0
  66. package/src/components/ui/alert-dialog/alert-dialog-content.svelte +25 -0
  67. package/src/components/ui/alert-dialog/alert-dialog-description.svelte +16 -0
  68. package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +8 -0
  69. package/src/components/ui/alert-dialog/alert-dialog-header.svelte +8 -0
  70. package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +16 -0
  71. package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +6 -0
  72. package/src/components/ui/alert-dialog/alert-dialog-title.svelte +16 -0
  73. package/src/components/ui/alert-dialog/index.ts +20 -0
  74. package/src/components/ui/avatar/avatar.svelte +2 -1
  75. package/src/components/ui/breadcrumb/breadcrumb-ellipsis.svelte +18 -0
  76. package/src/components/ui/breadcrumb/breadcrumb-item.svelte +15 -0
  77. package/src/components/ui/breadcrumb/breadcrumb-link.svelte +15 -0
  78. package/src/components/ui/breadcrumb/breadcrumb-list.svelte +15 -0
  79. package/src/components/ui/breadcrumb/breadcrumb-page.svelte +18 -0
  80. package/src/components/ui/breadcrumb/breadcrumb-separator.svelte +22 -0
  81. package/src/components/ui/breadcrumb/breadcrumb.svelte +11 -0
  82. package/src/components/ui/breadcrumb/index.ts +7 -0
  83. package/src/components/ui/collapsible/index.ts +7 -0
  84. package/src/components/ui/command/command-dialog.svelte +31 -0
  85. package/src/components/ui/command/index.ts +2 -0
  86. package/src/components/ui/context-menu/index.ts +11 -0
  87. package/src/components/ui/drawer/drawer-content.svelte +21 -0
  88. package/src/components/ui/drawer/drawer-description.svelte +11 -0
  89. package/src/components/ui/drawer/drawer-footer.svelte +11 -0
  90. package/src/components/ui/drawer/drawer-header.svelte +11 -0
  91. package/src/components/ui/drawer/drawer-title.svelte +11 -0
  92. package/src/components/ui/drawer/drawer.svelte +18 -0
  93. package/src/components/ui/drawer/index.ts +6 -0
  94. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +43 -0
  95. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +27 -0
  96. package/src/components/ui/dropdown-menu/index.ts +6 -0
  97. package/src/components/ui/label/index.ts +1 -0
  98. package/src/components/ui/label/label.svelte +18 -0
  99. package/src/components/ui/pagination/index.ts +7 -0
  100. package/src/components/ui/pagination/pagination-content.svelte +11 -0
  101. package/src/components/ui/pagination/pagination-ellipsis.svelte +12 -0
  102. package/src/components/ui/pagination/pagination-item.svelte +11 -0
  103. package/src/components/ui/pagination/pagination-link.svelte +26 -0
  104. package/src/components/ui/pagination/pagination-next.svelte +22 -0
  105. package/src/components/ui/pagination/pagination-previous.svelte +22 -0
  106. package/src/components/ui/pagination/pagination.svelte +11 -0
  107. package/src/components/ui/popover/index.ts +12 -0
  108. package/src/components/ui/popover/popover-content.svelte +25 -0
  109. package/src/components/ui/progress/index.ts +1 -0
  110. package/src/components/ui/progress/progress.svelte +22 -0
  111. package/src/components/ui/scroll-area/index.ts +1 -0
  112. package/src/components/ui/scroll-area/scroll-area.svelte +20 -0
  113. package/src/components/ui/sheet/index.ts +15 -1
  114. package/src/components/ui/sheet/sheet-content.svelte +28 -0
  115. package/src/components/ui/sheet/sheet-description.svelte +22 -0
  116. package/src/components/ui/sheet/sheet-footer.svelte +22 -0
  117. package/src/components/ui/sheet/sheet-header.svelte +22 -0
  118. package/src/components/ui/sheet/sheet-title.svelte +22 -0
  119. package/src/index.ts +52 -1
  120. 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 { Plus, Pencil, Trash2, ChevronLeft, ChevronRight, Search, Loader2, Download, Upload } from 'lucide-svelte';
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 { resourceName } = $props<{ resourceName: string }>();
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 init
73
+ // ─── URL state + server-side state ────────────────────────────
22
74
  const urlState = readURLState();
23
75
 
24
- let pagination = $state<Pagination>({
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
- urlState.sortField
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 listFields = resource.fields.filter(f => f.showInList !== false);
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
- const query = useList({
68
- resource: resourceName,
69
- pagination,
70
- sorters,
71
- filters: activeFilters,
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
- const deleteMutation = useDelete(resourceName);
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
- function toggleSort(field: string) {
77
- const existing = sorters.find(s => s.field === field);
78
- if (!existing) {
79
- sorters = [{ field, order: 'asc' }];
80
- } else if (existing.order === 'asc') {
81
- sorters = [{ field, order: 'desc' }];
82
- } else {
83
- sorters = [];
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
- function getSortIcon(field: string): string {
88
- const s = sorters.find(ss => ss.field === field);
89
- if (!s) return '⇅';
90
- return s.order === 'asc' ? '↑' : '↓';
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
- // Batch operations
94
- function toggleSelect(id: string | number) {
95
- const next = new Set(selectedIds);
96
- if (next.has(id)) next.delete(id); else next.add(id);
97
- selectedIds = next;
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
- function toggleSelectAll() {
101
- if (selectAll) {
102
- selectedIds = new Set();
103
- selectAll = false;
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
- const allIds = (query.data?.data ?? []).map(r => r[primaryKey] as string | number);
106
- selectedIds = new Set(allIds);
107
- selectAll = true;
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
- confirmMessage = t('common.batchDeleteConfirm', { count: selectedIds.size });
261
+ const ids = Object.keys(rowSelection);
262
+ confirmMessage = t('common.batchDeleteConfirm', { count: ids.length });
122
263
  confirmAction = async () => {
123
- for (const id of selectedIds) {
124
- await deleteMutation.mutateAsync(id);
264
+ for (const id of ids) {
265
+ await deleteMutation.mutateAsync({ id, resource: resourceName });
125
266
  }
126
- selectedIds = new Set();
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
- listFields.map(f => {
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
- // CSV Import
158
- function handleImportCSV() {
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 selectedIds.size > 0 && canDelete}
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: selectedIds.size })}
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
- <!-- Table -->
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="flex h-64 items-center justify-center">
223
- <Loader2 class="h-6 w-6 animate-spin text-primary" />
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
- <Table.Root>
231
- <Table.Header>
232
- <Table.Row class="bg-muted/50 hover:bg-muted/50">
233
- {#if canDelete}
234
- <Table.Head class="w-10">
235
- <Checkbox checked={selectAll} onCheckedChange={() => toggleSelectAll()} />
236
- </Table.Head>
237
- {/if}
238
- {#each listFields as field}
239
- <Table.Head style={field.width ? `width:${field.width}` : undefined}>
240
- <button class="flex items-center gap-1 hover:text-foreground" onclick={() => toggleSort(field.key)}>
241
- {field.label}
242
- <span class="text-xs opacity-50">{getSortIcon(field.key)}</span>
243
- </button>
244
- </Table.Head>
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
- <Table.Head class="text-right">{t('common.actions')}</Table.Head>
247
- </Table.Row>
248
- </Table.Header>
249
- <Table.Body>
250
- {#each query.data?.data ?? [] as record}
251
- {@const id = record[primaryKey] as string | number}
252
- <Table.Row class="transition-colors {selectedIds.has(id) ? 'bg-accent' : ''}">
253
- {#if canDelete}
254
- <Table.Cell>
255
- <Checkbox checked={selectedIds.has(id)} onCheckedChange={() => toggleSelect(id)} />
256
- </Table.Cell>
257
- {/if}
258
- {#each listFields as field}
259
- <Table.Cell>
260
- {#if field.type === 'boolean'}
261
- <span class="inline-block h-2 w-2 rounded-full {record[field.key] ? 'bg-green-500' : 'bg-muted-foreground/30'}"></span>
262
- {:else if field.type === 'date' && record[field.key]}
263
- {new Date(record[field.key] as string).toLocaleDateString('zh-CN')}
264
- {:else if field.type === 'tags' && Array.isArray(record[field.key])}
265
- <div class="flex flex-wrap gap-1">
266
- {#each (record[field.key] as string[]).slice(0, 3) as tag}
267
- <Badge variant="secondary">{tag}</Badge>
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
- </div>
270
- {:else if field.type === 'select' && field.options}
271
- {@const opt = field.options.find(o => o.value === record[field.key])}
272
- <Badge variant="outline">{opt?.label ?? record[field.key] ?? '—'}</Badge>
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
- <Button
282
- variant="ghost" size="icon-sm"
283
- onclick={() => navigate(`/${resourceName}/edit/${id}`)}
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
- <Button
291
- variant="ghost" size="icon-sm"
292
- onclick={() => confirmDelete(id)}
293
- title={t('common.delete')}
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
- </div>
300
- </Table.Cell>
301
- </Table.Row>
302
- {:else}
303
- <Table.Row>
304
- <Table.Cell colspan={listFields.length + (canDelete ? 2 : 1)} class="h-24 text-center text-muted-foreground">
305
- {t('common.noData')}
306
- </Table.Cell>
307
- </Table.Row>
308
- {/each}
309
- </Table.Body>
310
- </Table.Root>
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
- <Button
319
- variant="outline" size="icon-sm"
320
- onclick={() => { pagination = { ...pagination, current: Math.max(1, (pagination.current ?? 1) - 1) }; }}
321
- disabled={(pagination.current ?? 1) <= 1}
322
- >
323
- <ChevronLeft class="h-4 w-4" />
324
- </Button>
325
- <span>{t('common.page', { current: pagination.current ?? 1, total: totalPages || 1 })}</span>
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
- <ChevronRight class="h-4 w-4" />
332
- </Button>
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