@svadmin/ui 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/package.json +9 -4
  2. package/src/actions.ts +110 -0
  3. package/src/components/AdminApp.svelte +7 -4
  4. package/src/components/Authenticated.svelte +1 -7
  5. package/src/components/AutoForm.svelte +158 -69
  6. package/src/components/AutoSaveIndicator.svelte +19 -14
  7. package/src/components/AutoTable.svelte +465 -243
  8. package/src/components/Breadcrumbs.svelte +20 -17
  9. package/src/components/CanAccess.svelte +6 -4
  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 +3 -2
  15. package/src/components/DevTools.svelte +99 -230
  16. package/src/components/DrawerForm.svelte +19 -10
  17. package/src/components/EditPage.svelte +25 -6
  18. package/src/components/EmptyState.svelte +5 -1
  19. package/src/components/ErrorBoundary.svelte +38 -40
  20. package/src/components/ErrorComponent.svelte +18 -17
  21. package/src/components/FieldRenderer.svelte +240 -75
  22. package/src/components/ForgotPasswordPage.svelte +20 -111
  23. package/src/components/Header.svelte +28 -8
  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 +25 -1
  28. package/src/components/LiveIndicator.svelte +30 -87
  29. package/src/components/LoginPage.svelte +81 -155
  30. package/src/components/ModalForm.svelte +6 -2
  31. package/src/components/PageSkeleton.svelte +2 -1
  32. package/src/components/PasswordInput.svelte +95 -0
  33. package/src/components/ProfilePage.svelte +166 -0
  34. package/src/components/RegisterPage.svelte +35 -164
  35. package/src/components/ShowPage.svelte +32 -33
  36. package/src/components/Sidebar.svelte +185 -73
  37. package/src/components/StatsCard.svelte +12 -3
  38. package/src/components/StepsForm.svelte +139 -0
  39. package/src/components/Toast.svelte +15 -54
  40. package/src/components/TooltipButton.svelte +52 -0
  41. package/src/components/UndoableNotification.svelte +28 -96
  42. package/src/components/UpdatePasswordPage.svelte +32 -160
  43. package/src/components/buttons/RefreshButton.svelte +7 -2
  44. package/src/components/fieldComponentMap.ts +79 -0
  45. package/src/components/fields/BooleanField.svelte +9 -9
  46. package/src/components/fields/EmailField.svelte +2 -8
  47. package/src/components/fields/FileField.svelte +4 -9
  48. package/src/components/fields/ImageField.svelte +2 -7
  49. package/src/components/fields/JsonField.svelte +72 -0
  50. package/src/components/fields/MarkdownField.svelte +2 -12
  51. package/src/components/fields/MultiSelectField.svelte +20 -0
  52. package/src/components/fields/RelationField.svelte +24 -0
  53. package/src/components/fields/RichTextField.svelte +35 -0
  54. package/src/components/fields/SelectField.svelte +14 -0
  55. package/src/components/fields/TagField.svelte +6 -11
  56. package/src/components/fields/TextField.svelte +2 -7
  57. package/src/components/fields/UrlField.svelte +4 -10
  58. package/src/components/ui/alert-dialog/alert-dialog-content.svelte +25 -0
  59. package/src/components/ui/alert-dialog/alert-dialog-description.svelte +16 -0
  60. package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +8 -0
  61. package/src/components/ui/alert-dialog/alert-dialog-header.svelte +8 -0
  62. package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +16 -0
  63. package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +6 -0
  64. package/src/components/ui/alert-dialog/alert-dialog-title.svelte +16 -0
  65. package/src/components/ui/alert-dialog/index.ts +20 -0
  66. package/src/components/ui/breadcrumb/breadcrumb-ellipsis.svelte +18 -0
  67. package/src/components/ui/breadcrumb/breadcrumb-item.svelte +15 -0
  68. package/src/components/ui/breadcrumb/breadcrumb-link.svelte +15 -0
  69. package/src/components/ui/breadcrumb/breadcrumb-list.svelte +15 -0
  70. package/src/components/ui/breadcrumb/breadcrumb-page.svelte +18 -0
  71. package/src/components/ui/breadcrumb/breadcrumb-separator.svelte +22 -0
  72. package/src/components/ui/breadcrumb/breadcrumb.svelte +11 -0
  73. package/src/components/ui/breadcrumb/index.ts +7 -0
  74. package/src/components/ui/collapsible/index.ts +7 -0
  75. package/src/components/ui/command/command-dialog.svelte +31 -0
  76. package/src/components/ui/command/index.ts +2 -0
  77. package/src/components/ui/context-menu/index.ts +11 -0
  78. package/src/components/ui/drawer/drawer-content.svelte +21 -0
  79. package/src/components/ui/drawer/drawer-description.svelte +11 -0
  80. package/src/components/ui/drawer/drawer-footer.svelte +11 -0
  81. package/src/components/ui/drawer/drawer-header.svelte +11 -0
  82. package/src/components/ui/drawer/drawer-title.svelte +11 -0
  83. package/src/components/ui/drawer/drawer.svelte +18 -0
  84. package/src/components/ui/drawer/index.ts +6 -0
  85. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +43 -0
  86. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +27 -0
  87. package/src/components/ui/dropdown-menu/index.ts +6 -0
  88. package/src/components/ui/label/index.ts +1 -0
  89. package/src/components/ui/label/label.svelte +18 -0
  90. package/src/components/ui/pagination/index.ts +7 -0
  91. package/src/components/ui/pagination/pagination-content.svelte +11 -0
  92. package/src/components/ui/pagination/pagination-ellipsis.svelte +12 -0
  93. package/src/components/ui/pagination/pagination-item.svelte +11 -0
  94. package/src/components/ui/pagination/pagination-link.svelte +26 -0
  95. package/src/components/ui/pagination/pagination-next.svelte +22 -0
  96. package/src/components/ui/pagination/pagination-previous.svelte +22 -0
  97. package/src/components/ui/pagination/pagination.svelte +11 -0
  98. package/src/components/ui/popover/index.ts +12 -0
  99. package/src/components/ui/popover/popover-content.svelte +25 -0
  100. package/src/components/ui/progress/index.ts +1 -0
  101. package/src/components/ui/progress/progress.svelte +22 -0
  102. package/src/components/ui/scroll-area/index.ts +1 -0
  103. package/src/components/ui/scroll-area/scroll-area.svelte +20 -0
  104. package/src/components/ui/sheet/index.ts +15 -1
  105. package/src/components/ui/sheet/sheet-content.svelte +28 -0
  106. package/src/components/ui/sheet/sheet-description.svelte +22 -0
  107. package/src/components/ui/sheet/sheet-footer.svelte +22 -0
  108. package/src/components/ui/sheet/sheet-header.svelte +22 -0
  109. package/src/components/ui/sheet/sheet-title.svelte +22 -0
  110. package/src/index.ts +31 -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 { Plus, Pencil, Trash2, ChevronLeft, ChevronRight, Search, Loader2, Download, Upload, ChevronDown, ChevronUp, SlidersHorizontal } 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';
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
- } = $props<{
23
- resourceName: string;
24
- expandedRowRender?: Snippet<[record: Record<string, unknown>]>;
25
- selectable?: boolean;
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 init
73
+ // ─── URL state + server-side state ────────────────────────────
32
74
  const urlState = readURLState();
33
75
 
34
- let pagination = $state<Pagination>({
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
- urlState.sortField
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 listFields = $derived(visibleFields);
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
- const listResult = useList({
97
- resource: resourceName,
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
- function toggleSort(field: string) {
108
- const existing = sorters.find(s => s.field === field);
109
- if (!existing) {
110
- sorters = [{ field, order: 'asc' }];
111
- } else if (existing.order === 'asc') {
112
- sorters = [{ field, order: 'desc' }];
113
- } else {
114
- sorters = [];
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
- function getSortIcon(field: string): string {
119
- const s = sorters.find(ss => ss.field === field);
120
- if (!s) return '⇅';
121
- return s.order === 'asc' ? '↑' : '↓';
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
- // Batch operations
125
- function toggleSelect(id: string | number) {
126
- const next = new Set(selectedIds);
127
- if (next.has(id)) next.delete(id); else next.add(id);
128
- selectedIds = next;
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
- function toggleSelectAll() {
132
- if (selectAll) {
133
- selectedIds = new Set();
134
- selectAll = false;
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
- const allIds = (query.data?.data ?? []).map(r => r[primaryKey] as string | number);
137
- selectedIds = new Set(allIds);
138
- 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);
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
- confirmMessage = t('common.batchDeleteConfirm', { count: selectedIds.size });
261
+ const ids = Object.keys(rowSelection);
262
+ confirmMessage = t('common.batchDeleteConfirm', { count: ids.length });
153
263
  confirmAction = async () => {
154
- for (const id of selectedIds) {
264
+ for (const id of ids) {
155
265
  await deleteMutation.mutateAsync({ id, resource: resourceName });
156
266
  }
157
- selectedIds = new Set();
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
- listFields.map(f => {
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
- // CSV Import
189
- function handleImportCSV() {
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 selectedIds.size > 0 && canDelete}
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: selectedIds.size })}
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
- <div class="relative">
230
- <Button variant="outline" size="sm" onclick={() => showColumnPicker = !showColumnPicker}>
231
- <SlidersHorizontal class="h-4 w-4" data-icon="inline-start" /> {t('common.columns') || 'Columns'}
232
- </Button>
233
- {#if showColumnPicker}
234
- <div class="absolute right-0 top-full mt-1 z-50 w-48 rounded-lg border bg-popover p-2 shadow-lg">
235
- {#each resource.fields.filter(f => f.showInList !== false) as field}
236
- <label class="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent cursor-pointer">
237
- <Checkbox
238
- checked={columnVisibility[field.key] !== false}
239
- onCheckedChange={(v) => { columnVisibility = { ...columnVisibility, [field.key]: !!v }; }}
240
- />
241
- {field.label}
242
- </label>
243
- {/each}
244
- </div>
245
- {/if}
246
- </div>
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
- <!-- 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) -->
270
406
  <div class="rounded-xl border border-border/60 bg-card shadow-md overflow-hidden">
271
407
  {#if query.isLoading}
272
- <div class="flex h-64 items-center justify-center">
273
- <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}
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
- <Table.Root>
281
- <Table.Header>
282
- <Table.Row class="bg-muted/50 hover:bg-muted/50">
283
- {#if selectable && canDelete}
284
- <Table.Head class="w-10">
285
- <Checkbox checked={selectAll} onCheckedChange={() => toggleSelectAll()} />
286
- </Table.Head>
287
- {/if}
288
- {#if expandedRowRender}
289
- <Table.Head class="w-10"></Table.Head>
290
- {/if}
291
- {#each listFields as field}
292
- <Table.Head style={field.width ? `width:${field.width}` : undefined}>
293
- <button class="flex items-center gap-1 hover:text-foreground" onclick={() => toggleSort(field.key)}>
294
- {field.label}
295
- <span class="text-xs opacity-50">{getSortIcon(field.key)}</span>
296
- </button>
297
- </Table.Head>
298
- {/each}
299
- <Table.Head class="text-right">{t('common.actions')}</Table.Head>
300
- </Table.Row>
301
- </Table.Header>
302
- <Table.Body>
303
- {#each query.data?.data ?? [] as record}
304
- {@const id = record[primaryKey] as string | number}
305
- <Table.Row class="transition-colors {selectedIds.has(id) ? 'bg-accent' : ''}">
306
- {#if selectable && canDelete}
307
- <Table.Cell>
308
- <Checkbox checked={selectedIds.has(id)} onCheckedChange={() => toggleSelect(id)} />
309
- </Table.Cell>
310
- {/if}
311
- {#if expandedRowRender}
312
- <Table.Cell>
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
- <ChevronDown class="h-4 w-4" />
461
+ {visibleFields.find(f => f.key === header.id)?.label ?? header.id}
318
462
  {/if}
319
- </button>
320
- </Table.Cell>
321
- {/if}
322
- {#each listFields as field}
323
- <Table.Cell>
324
- {#if field.type === 'boolean'}
325
- <span class="inline-block h-2 w-2 rounded-full {record[field.key] ? 'bg-green-500' : 'bg-muted-foreground/30'}"></span>
326
- {:else if field.type === 'date' && record[field.key]}
327
- {new Date(record[field.key] as string).toLocaleDateString('zh-CN')}
328
- {:else if field.type === 'tags' && Array.isArray(record[field.key])}
329
- <div class="flex flex-wrap gap-1">
330
- {#each (record[field.key] as string[]).slice(0, 3) as tag}
331
- <Badge variant="secondary">{tag}</Badge>
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
- </div>
334
- {:else if field.type === 'select' && field.options}
335
- {@const opt = field.options.find(o => o.value === record[field.key])}
336
- <Badge variant="outline">{opt?.label ?? record[field.key] ?? '—'}</Badge>
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
- <Button
346
- variant="ghost" size="icon-sm"
347
- onclick={() => navigate(`/${resourceName}/edit/${id}`)}
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
- <Button
355
- variant="ghost" size="icon-sm"
356
- onclick={() => confirmDelete(id)}
357
- title={t('common.delete')}
358
- class="hover:text-destructive"
359
- >
360
- <Trash2 class="h-4 w-4" />
361
- </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>
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
- {/if}
373
- {:else}
374
- <Table.Row>
375
- <Table.Cell colspan={listFields.length + (selectable && canDelete ? 2 : 1) + (expandedRowRender ? 1 : 0)} class="h-24 text-center text-muted-foreground">
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
- <Button
390
- variant="outline" size="icon-sm"
391
- onclick={() => { pagination = { ...pagination, current: Math.max(1, (pagination.current ?? 1) - 1) }; }}
392
- disabled={(pagination.current ?? 1) <= 1}
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
- <ChevronLeft class="h-4 w-4" />
395
- </Button>
396
- <span>{t('common.page', { current: pagination.current ?? 1, total: totalPages || 1 })}</span>
397
- <Button
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