@svadmin/ui 0.57.0 → 0.58.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 (35) hide show
  1. package/dist/components/AICommandBar.svelte +33 -10
  2. package/dist/components/AdminApp.svelte +16 -5
  3. package/dist/components/AutoForm.svelte +4 -4
  4. package/dist/components/AutoTable.svelte +866 -204
  5. package/dist/components/AutoTable.svelte.d.ts +1 -1
  6. package/dist/components/CommandPalette.svelte +39 -17
  7. package/dist/components/ConfirmDialog.svelte +4 -3
  8. package/dist/components/ConfirmDialog.svelte.d.ts +1 -0
  9. package/dist/components/CopilotPanel.svelte +3 -3
  10. package/dist/components/DraggableHeader.svelte +15 -36
  11. package/dist/components/DraggableHeader.svelte.d.ts +13 -4
  12. package/dist/components/EditPage.svelte +4 -4
  13. package/dist/components/ListPage.svelte +4 -12
  14. package/dist/components/ListPage.svelte.d.ts +1 -1
  15. package/dist/components/RecordDetailDrawer.svelte +121 -0
  16. package/dist/components/RecordDetailDrawer.svelte.d.ts +9 -0
  17. package/dist/components/ResourceAccessGuard.svelte +68 -0
  18. package/dist/components/ResourceAccessGuard.svelte.d.ts +12 -0
  19. package/dist/components/ResourceOperationsPage.svelte +8 -3
  20. package/dist/components/ShowPage.svelte +5 -6
  21. package/dist/components/TenantSwitcher.svelte +1 -0
  22. package/dist/components/Toast.svelte +35 -1
  23. package/dist/components/UndoableNotification.svelte +12 -6
  24. package/dist/components/UndoableNotification.svelte.d.ts +2 -0
  25. package/dist/components/buttons/CloneButton.svelte +9 -2
  26. package/dist/components/buttons/CreateButton.svelte +9 -2
  27. package/dist/components/buttons/DeleteButton.svelte +9 -2
  28. package/dist/components/buttons/EditButton.svelte +9 -2
  29. package/dist/components/buttons/ListButton.svelte +1 -1
  30. package/dist/components/buttons/ShowButton.svelte +9 -2
  31. package/dist/components/saved-list-views.d.ts +35 -0
  32. package/dist/components/saved-list-views.js +168 -0
  33. package/dist/index.d.ts +2 -0
  34. package/dist/index.js +2 -0
  35. package/package.json +2 -2
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { onDestroy, untrack } from 'svelte';
3
+ import { SvelteMap } from 'svelte/reactivity';
3
4
  import { cn } from '../utils.js';
4
5
  import {
5
6
  createTable,
@@ -16,25 +17,24 @@
16
17
  type ColumnVisibilityState,
17
18
  type ExpandedState,
18
19
  } from '@tanstack/svelte-table';
19
- import type { Column, Header, TableFeatures } from '@tanstack/table-core';
20
+ import type { Column, Header, Row, TableFeatures } from '@tanstack/table-core';
20
21
  import { createAtom, useSelector } from '@tanstack/svelte-store';
21
22
  import {
22
23
  column_getCanSort,
23
24
  column_getIsSorted,
24
25
  column_toggleSorting,
25
- column_toggleVisibility,
26
26
  header_getSize,
27
+ table_getHeaderGroups,
27
28
  table_getRowModel,
28
29
  table_getAllLeafColumns,
29
30
  table_getIsAllRowsSelected,
30
31
  table_toggleAllRowsSelected,
31
- row_getIsSelected,
32
32
  row_toggleSelected,
33
33
  row_getVisibleCells,
34
34
  cell_getValue,
35
35
  } from '@tanstack/table-core/static-functions';
36
36
 
37
- import { captureAdminContext, useList, useDelete, useDeleteMany, getResource, notifyWithProvider } from '@svadmin/core';
37
+ import { captureAdminContext, DeleteManyPartialError, getAdminOptions, useList, useDelete, useDeleteMany, getResource, useNavigation, useParsed } from '@svadmin/core';
38
38
  import type {
39
39
  BaseRecord,
40
40
  FieldDefinition,
@@ -46,6 +46,24 @@
46
46
  import { useCan } from '@svadmin/core';
47
47
  import { readURLState, writeURLState } from '@svadmin/core';
48
48
  import { useTranslation } from '@svadmin/core/i18n';
49
+ import {
50
+ activeSavedListViewStorageKey,
51
+ canMigrateLegacyListPreferences,
52
+ cloneSavedListViewState,
53
+ columnOrderStorageKey,
54
+ columnVisibilityStorageKey,
55
+ legacyActiveSavedListViewStorageKey,
56
+ legacyColumnOrderStorageKey,
57
+ legacyColumnVisibilityStorageKey,
58
+ legacySavedListViewsStorageKey,
59
+ listPreferenceScopeId,
60
+ readSavedListViews,
61
+ savedListViewsStorageKey,
62
+ serializeSavedListViews,
63
+ type ListPreferenceScope,
64
+ type SavedListView,
65
+ type SavedListViewState,
66
+ } from './saved-list-views.js';
49
67
 
50
68
  import { fade } from 'svelte/transition';
51
69
  import { Button } from './ui/button/index.js';
@@ -61,9 +79,11 @@
61
79
  import {
62
80
  Plus, Pencil, Trash2,
63
81
  Search, Download, ChevronDown, ChevronUp, SlidersHorizontal, Filter as FilterIcon,
64
- Eye, Copy, RefreshCw, Rows, X
82
+ Eye, Copy, RefreshCw, Rows, X, Bookmark, Check
65
83
  } from '@lucide/svelte';
66
84
  import ConfirmDialog from './ConfirmDialog.svelte';
85
+ import RecordDetailDrawer from './RecordDetailDrawer.svelte';
86
+ import CanAccess from './CanAccess.svelte';
67
87
  import TooltipButton from './TooltipButton.svelte';
68
88
  import InlineEdit from './InlineEdit.svelte';
69
89
  import DraggableHeader from './DraggableHeader.svelte';
@@ -98,7 +118,7 @@
98
118
  /** Externally controlled sorters */
99
119
  sorters?: Sort[];
100
120
  /** Custom batch actions to render when rows are selected */
101
- batchActions?: Snippet<[{ selectedIds: string[] }]>;
121
+ batchActions?: Snippet<[{ selectedIds: (string | number)[] }]>;
102
122
  }
103
123
 
104
124
  let {
@@ -124,30 +144,203 @@
124
144
  let densityOverride = $state<'compact' | 'comfortable' | undefined>(undefined);
125
145
  const currentDensity = $derived(densityOverride ?? density);
126
146
  const adminContext = captureAdminContext();
147
+ const adminOptions = getAdminOptions();
148
+ const mutationMode = $derived(adminOptions.mutationMode ?? 'pessimistic');
149
+ const parsed = useParsed();
150
+ const navigation = useNavigation();
127
151
 
128
152
  const resource = $derived(getResource(resourceName));
129
153
  const primaryKey = $derived(resource.primaryKey ?? 'id');
154
+ const listPreferenceScope = $derived.by(() => {
155
+ const matcher = adminContext.queryKeyMatcher(resourceName);
156
+ return {
157
+ resourceName,
158
+ providerName: matcher.provider ?? 'default',
159
+ tenantIdentity: matcher.tenant,
160
+ };
161
+ });
162
+
163
+ function readLocalPreference(key: string): string | null {
164
+ if (typeof window === 'undefined') return null;
165
+ try {
166
+ return localStorage.getItem(key);
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ function readScopedPreference(scope: ListPreferenceScope, scopedKey: string, legacyKey: string): string | null {
173
+ const scoped = readLocalPreference(scopedKey);
174
+ if (scoped !== null || !canMigrateLegacyListPreferences(scope)) return scoped;
175
+ const legacy = readLocalPreference(legacyKey);
176
+ if (legacy === null || typeof window === 'undefined') return null;
177
+ try {
178
+ localStorage.setItem(scopedKey, legacy);
179
+ localStorage.removeItem(legacyKey);
180
+ } catch { /* keep the legacy value readable when migration cannot persist */ }
181
+ return legacy;
182
+ }
130
183
 
131
184
  // ─── URL state + server-side state ────────────────────────────
132
185
  const urlState = readURLState(adminContext);
133
186
 
187
+ const savedViewColumnIds = new Set([
188
+ ...untrack(() => resource.fields.map((field) => field.key)),
189
+ '_select', '_expand', '_actions',
190
+ ]);
191
+ const storedSavedViews = untrack(() => readSavedListViews(
192
+ readScopedPreference(
193
+ listPreferenceScope,
194
+ savedListViewsStorageKey(listPreferenceScope),
195
+ legacySavedListViewsStorageKey(resourceName),
196
+ ),
197
+ savedViewColumnIds,
198
+ ));
199
+ let savedViews = $state<SavedListView[]>(storedSavedViews);
200
+ const storedActiveSavedViewId = untrack(() => {
201
+ const candidate = readScopedPreference(
202
+ listPreferenceScope,
203
+ activeSavedListViewStorageKey(listPreferenceScope),
204
+ legacyActiveSavedListViewStorageKey(resourceName),
205
+ );
206
+ return storedSavedViews.some((view) => view.id === candidate) ? candidate ?? undefined : undefined;
207
+ });
208
+ let savedViewName = $state('');
209
+ let savedViewsOpen = $state(false);
210
+
211
+ const hasExplicitURLState = untrack(() => (
212
+ urlState.page !== undefined
213
+ || urlState.pageSize !== undefined
214
+ || urlState.sortField !== undefined
215
+ || urlState.search !== undefined
216
+ || urlState.filters !== undefined
217
+ ));
218
+ let activeSavedViewId = $state<string | undefined>(hasExplicitURLState ? undefined : storedActiveSavedViewId);
219
+ const activeSavedViewName = $derived(savedViews.find((view) => view.id === activeSavedViewId)?.name);
220
+ const initialSavedView = untrack(() => (
221
+ !hasExplicitURLState && storedActiveSavedViewId
222
+ ? storedSavedViews.find((view) => view.id === storedActiveSavedViewId)
223
+ : undefined
224
+ ));
225
+ const initialViewState = initialSavedView?.state;
226
+
227
+ // The table can stay mounted while the surrounding tenant/provider changes.
228
+ // Keep persistence effects paused until the new scope has been loaded.
229
+ let loadedPreferenceScopeId = $state(untrack(() => listPreferenceScopeId(listPreferenceScope)));
230
+
231
+ function readColumnVisibilityPreference(scope: ListPreferenceScope): ColumnVisibilityState {
232
+ const stored = readScopedPreference(
233
+ scope,
234
+ columnVisibilityStorageKey(scope),
235
+ legacyColumnVisibilityStorageKey(resourceName),
236
+ );
237
+ if (stored) {
238
+ try {
239
+ const parsed: unknown = JSON.parse(stored);
240
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
241
+ const visibility: ColumnVisibilityState = {};
242
+ for (const [columnId, visible] of Object.entries(parsed as Record<string, unknown>)) {
243
+ if (savedViewColumnIds.has(columnId) && typeof visible === 'boolean') {
244
+ visibility[columnId] = visible;
245
+ }
246
+ }
247
+ return visibility;
248
+ }
249
+ } catch { /* fall through to resource defaults */ }
250
+ }
251
+
252
+ const visibility: ColumnVisibilityState = {};
253
+ for (const field of resource.fields) {
254
+ if (field.showInList === false) visibility[field.key] = false;
255
+ }
256
+ return visibility;
257
+ }
258
+
259
+ function readColumnOrderPreference(scope: ListPreferenceScope): string[] {
260
+ const stored = readScopedPreference(
261
+ scope,
262
+ columnOrderStorageKey(scope),
263
+ legacyColumnOrderStorageKey(resourceName),
264
+ );
265
+ if (!stored) return [];
266
+ try {
267
+ const parsed: unknown = JSON.parse(stored);
268
+ return Array.isArray(parsed)
269
+ ? [...new Set(parsed.filter((columnId): columnId is string => (
270
+ typeof columnId === 'string' && savedViewColumnIds.has(columnId)
271
+ )))]
272
+ : [];
273
+ } catch {
274
+ return [];
275
+ }
276
+ }
277
+
278
+ function readScopedSavedViewPreferences(scope: ListPreferenceScope): {
279
+ savedViews: SavedListView[];
280
+ activeSavedViewId?: string;
281
+ activeSavedView?: SavedListView;
282
+ columnVisibility: ColumnVisibilityState;
283
+ columnOrder: string[];
284
+ } {
285
+ const scopedSavedViews = readSavedListViews(
286
+ readScopedPreference(
287
+ scope,
288
+ savedListViewsStorageKey(scope),
289
+ legacySavedListViewsStorageKey(resourceName),
290
+ ),
291
+ savedViewColumnIds,
292
+ );
293
+ const candidate = readScopedPreference(
294
+ scope,
295
+ activeSavedListViewStorageKey(scope),
296
+ legacyActiveSavedListViewStorageKey(resourceName),
297
+ );
298
+ const activeSavedView = scopedSavedViews.find((view) => view.id === candidate);
299
+ return {
300
+ savedViews: scopedSavedViews,
301
+ activeSavedViewId: activeSavedView?.id,
302
+ activeSavedView,
303
+ columnVisibility: readColumnVisibilityPreference(scope),
304
+ columnOrder: readColumnOrderPreference(scope),
305
+ };
306
+ }
307
+
134
308
  // Snapshot resource values for initial state (untrack to avoid reactive tracking)
135
- const storedPageSize = typeof window !== 'undefined' ? parseInt(localStorage.getItem('svadmin-default-page-size') ?? '', 10) : NaN;
309
+ const storedPageSize = parseInt(readLocalPreference('svadmin-default-page-size') ?? '', 10);
136
310
  const initPageSize = untrack(() => resource.pageSize ?? (isNaN(storedPageSize) ? 10 : storedPageSize));
137
311
  const initDefaultSort = untrack(() => resource.defaultSort);
138
312
 
139
313
  let pagination = $state<PaginationState>(untrack(() => externalPagination ?? {
140
- current: urlState.page ?? 1,
141
- pageSize: urlState.pageSize ?? initPageSize,
314
+ current: urlState.page ?? initialViewState?.pagination.current ?? 1,
315
+ pageSize: urlState.pageSize ?? initialViewState?.pagination.pageSize ?? initPageSize,
142
316
  }));
143
317
  let sorters = $state<Sort[]>(untrack(() =>
144
318
  externalSorters ??
145
319
  (urlState.sortField
146
320
  ? [{ field: urlState.sortField, order: urlState.sortOrder ?? 'asc' }]
147
- : initDefaultSort ? [initDefaultSort] : []))
321
+ : initialViewState?.sorters ?? (initDefaultSort ? [initDefaultSort] : [])))
148
322
  );
149
- let filters = $state<Filter[]>([]);
150
- let searchText = $state(urlState.search ?? '');
323
+ const initialURLFilters = untrack(() => urlState.filters ?? initialViewState?.filters ?? []);
324
+ const editableFilterKeys = new Set(untrack(() => resource.fields.filter((field) => field.filterable).map((field) => field.key)));
325
+ const editableFilterCounts: Record<string, number> = {};
326
+ for (const filter of initialURLFilters) {
327
+ if ('field' in filter && filter.operator === 'contains' && typeof filter.value === 'string' && editableFilterKeys.has(filter.field)) {
328
+ editableFilterCounts[filter.field] = (editableFilterCounts[filter.field] ?? 0) + 1;
329
+ }
330
+ }
331
+ const initialFilterValues: Record<string, string> = {};
332
+ for (const filter of initialURLFilters) {
333
+ if (
334
+ 'field' in filter
335
+ && filter.operator === 'contains'
336
+ && typeof filter.value === 'string'
337
+ && editableFilterCounts[filter.field] === 1
338
+ ) {
339
+ initialFilterValues[filter.field] = filter.value;
340
+ }
341
+ }
342
+ let filters = $state<Filter[]>(initialURLFilters);
343
+ let searchText = $state(urlState.search ?? initialViewState?.search ?? '');
151
344
  let appliedSearchText = $state(untrack(() => searchText));
152
345
  let searchDebounceTimer: ReturnType<typeof setTimeout> | undefined;
153
346
 
@@ -156,6 +349,7 @@
156
349
  if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
157
350
  searchDebounceTimer = setTimeout(() => {
158
351
  appliedSearchText = searchText;
352
+ markSavedViewDirty();
159
353
  pagination = { ...pagination, current: 1 };
160
354
  searchDebounceTimer = undefined;
161
355
  }, 300);
@@ -178,24 +372,23 @@
178
372
  }
179
373
  });
180
374
 
181
- // URL sync
182
- $effect(() => {
183
- writeURLState({
184
- page: pagination.current,
185
- pageSize: pagination.pageSize,
186
- sortField: sorters[0]?.field,
187
- sortOrder: sorters[0]?.order,
188
- search: appliedSearchText || undefined,
189
- }, adminContext);
190
- });
191
-
192
375
  // ─── Build active filters with search ─────────────────────────
193
376
  const searchableFields = $derived(resource.fields.filter(f => f.searchable));
194
377
  const filterableFields = $derived(resource.fields.filter(f => f.filterable));
195
- let filterValues = $state<Record<string, string>>({});
196
- const activeFilterCount = $derived(Object.values(filterValues).filter(v => v.trim()).length);
378
+ let filterValues = $state<Record<string, string>>(initialFilterValues);
379
+ const locationFilters = $derived<Filter[]>(filters);
380
+ const activeFilterCount = $derived(locationFilters.length);
381
+ const activeFilterItems = $derived.by(() => {
382
+ const labels = new Map(resource.fields.map((field) => [field.key, field.label]));
383
+ return locationFilters.map((filter, index) => {
384
+ if ('field' in filter) {
385
+ return { index, label: `${labels.get(filter.field) ?? filter.field}: ${String(filter.value)}` };
386
+ }
387
+ return { index, label: filter.operator };
388
+ });
389
+ });
197
390
  const activeFilters = $derived.by(() => {
198
- const result: Filter[] = [...filters];
391
+ const result: Filter[] = [...locationFilters];
199
392
  if (appliedSearchText.trim() && searchableFields.length > 0) {
200
393
  if (searchableFields.length === 1) {
201
394
  result.push({ field: searchableFields[0].key, operator: 'contains', value: appliedSearchText });
@@ -211,17 +404,61 @@
211
404
  result.push(searchFilter);
212
405
  }
213
406
  }
214
- for (const [key, value] of Object.entries(filterValues)) {
215
- if (value.trim()) {
216
- result.push({ field: key, operator: 'contains', value: value.trim() });
217
- }
218
- }
219
407
  return result;
220
408
  });
221
409
 
222
- function removeFilterKey(key: string): void {
223
- const { [key]: _removed, ...rest } = filterValues;
224
- filterValues = rest;
410
+ // URL sync
411
+ $effect(() => {
412
+ writeURLState({
413
+ page: pagination.current,
414
+ pageSize: pagination.pageSize,
415
+ sortField: sorters[0]?.field,
416
+ sortOrder: sorters[0]?.order,
417
+ search: appliedSearchText || undefined,
418
+ filters: locationFilters,
419
+ }, adminContext);
420
+ });
421
+
422
+ function clearFilters(): void {
423
+ markSavedViewDirty();
424
+ filters = [];
425
+ filterValues = {};
426
+ pagination = { ...pagination, current: 1 };
427
+ }
428
+
429
+ function setFilterValue(field: string, value: string): void {
430
+ markSavedViewDirty();
431
+ const trimmedValue = value.trim();
432
+ const nextFilter = { field, operator: 'contains' as const, value: trimmedValue };
433
+ let replaced = false;
434
+ filters = filters.flatMap((filter) => {
435
+ if (!('field' in filter) || filter.field !== field || filter.operator !== 'contains') return [filter];
436
+ if (!trimmedValue || replaced) return [];
437
+ replaced = true;
438
+ return [nextFilter];
439
+ });
440
+ if (trimmedValue && !replaced) filters = [...filters, nextFilter];
441
+ filterValues[field] = value;
442
+ pagination = { ...pagination, current: 1 };
443
+ }
444
+
445
+ function removeActiveFilter(index: number): void {
446
+ markSavedViewDirty();
447
+ const filter = locationFilters[index];
448
+ if (!filter) return;
449
+ const nextFilters = filters.filter((_, filterIndex) => filterIndex !== index);
450
+ filters = nextFilters;
451
+ if ('field' in filter && filter.operator === 'contains') {
452
+ const remaining = nextFilters.filter((candidate) => (
453
+ 'field' in candidate
454
+ && candidate.field === filter.field
455
+ && candidate.operator === 'contains'
456
+ && typeof candidate.value === 'string'
457
+ ));
458
+ filterValues[filter.field] = remaining.length === 1 && 'field' in remaining[0]
459
+ ? String(remaining[0].value)
460
+ : '';
461
+ }
225
462
  pagination = { ...pagination, current: 1 };
226
463
  }
227
464
 
@@ -260,12 +497,6 @@
260
497
  })));
261
498
  const queryFilters = $derived<Filter[]>(activeFilters.map(cloneFilter));
262
499
 
263
- $effect(() => {
264
- if (Object.values(filterValues).some(v => v.trim())) {
265
- pagination = { ...pagination, current: 1 };
266
- }
267
- });
268
-
269
500
  // ─── Data fetching ────────────────────────────────────────────
270
501
  const listResult = useList({
271
502
  get resource() { return resourceName; },
@@ -276,47 +507,48 @@
276
507
  const query = listResult;
277
508
  const deleteResult = useDelete({ get resource() { return resourceName; } });
278
509
  const deleteMutation = deleteResult.mutation;
279
- const deleteManyResult = useDeleteMany({ get resource() { return resourceName; } });
510
+ let batchOperationCount = $state(0);
511
+ const deleteManyResult = useDeleteMany({
512
+ get resource() { return resourceName; },
513
+ successNotification: () => ({
514
+ message: i18n.t('common.batchDeleteSuccess', { count: batchOperationCount }),
515
+ }),
516
+ errorNotification: (error) => ({
517
+ type: 'error',
518
+ message: error instanceof DeleteManyPartialError
519
+ ? i18n.t('common.batchDeletePartialFail', { failed: error.failedIds.length, total: batchOperationCount })
520
+ : i18n.t('common.batchDeleteFailed', { count: batchOperationCount }),
521
+ }),
522
+ });
280
523
  const deleteManyMutation = deleteManyResult.mutation;
281
524
 
282
525
  // ─── Permissions ──────────────────────────────────────────────
283
526
  const acEnabled = $derived(!!adminContext.accessControlProvider);
284
527
  const canCreatePerm = useCan(() => ({ resource: resourceName, action: 'create', queryOptions: { enabled: acEnabled } }));
285
- const canEditPerm = useCan(() => ({ resource: resourceName, action: 'edit', queryOptions: { enabled: acEnabled } }));
286
- const canShowPerm = useCan(() => ({ resource: resourceName, action: 'show', queryOptions: { enabled: acEnabled } }));
287
- const canDeletePerm = useCan(() => ({ resource: resourceName, action: 'delete', queryOptions: { enabled: acEnabled } }));
288
528
  const canExportPerm = useCan(() => ({ resource: resourceName, action: 'export', queryOptions: { enabled: acEnabled } }));
289
- const canCreate = $derived(canCreatePerm.allowed && resource.canCreate !== false);
290
- const canEdit = $derived(canEditPerm.allowed && resource.canEdit !== false);
291
- const canShow = $derived(canShowPerm.allowed && resource.canShow !== false);
292
- const canDelete = $derived(canDeletePerm.allowed && resource.canDelete !== false);
529
+ const canCreate = $derived(resource.canCreate !== false && (!acEnabled || canCreatePerm.allowed));
530
+ const canEdit = $derived(resource.canEdit !== false);
531
+ const canShow = $derived(resource.canShow !== false);
532
+ const canDelete = $derived(resource.canDelete !== false);
293
533
  const canExport = $derived(canExportPerm.allowed);
294
534
 
295
535
  // ─── TanStack Table state ────────────────────────────────────
296
536
  const initialSorting = untrack<SortingState>(() =>
297
537
  sorters.map(s => ({ id: s.field, desc: s.order === 'desc' }))
298
538
  );
299
- const initialColumnVisibility = (() => {
300
- // Try to restore from localStorage first
301
- const storageKey = `svadmin-columns-${resourceName}`;
302
- if (typeof window !== 'undefined') {
303
- try {
304
- const stored = localStorage.getItem(storageKey);
305
- if (stored) return JSON.parse(stored);
306
- } catch { /* ignore */ }
307
- }
308
- // Fall back to resource field definitions
309
- const vis: ColumnVisibilityState = {};
310
- for (const f of resource.fields) {
311
- if (f.showInList === false) vis[f.key] = false;
312
- }
313
- return vis;
314
- })();
539
+ const initialColumnVisibility = untrack(() => (
540
+ initialViewState?.columnVisibility ?? readColumnVisibilityPreference(listPreferenceScope)
541
+ ));
315
542
  const sortingAtom = createAtom(initialSorting);
316
543
  const columnVisibilityAtom = createAtom(initialColumnVisibility);
317
544
  const rowSelectionAtom = createAtom({} as RowSelectionState);
318
545
  const expandedAtom = createAtom({} as ExpandedState);
319
- const columnOrderAtom = createAtom([] as string[]);
546
+ const initialColumnOrder = untrack(() => (
547
+ initialViewState?.columnOrder?.length
548
+ ? initialViewState.columnOrder
549
+ : readColumnOrderPreference(listPreferenceScope)
550
+ ));
551
+ const columnOrderAtom = createAtom(initialColumnOrder);
320
552
  const features = tableFeatures({
321
553
  columnOrderingFeature,
322
554
  columnSizingFeature,
@@ -331,12 +563,36 @@
331
563
  const tableRowSelection = useSelector(rowSelectionAtom);
332
564
  const tableExpanded = useSelector(expandedAtom);
333
565
  const tableColumnOrder = useSelector(columnOrderAtom);
566
+ const selectedIdValueByKey = new SvelteMap<string, string | number>();
567
+
568
+ function preferenceScopeIsLoaded(): boolean {
569
+ return loadedPreferenceScopeId === listPreferenceScopeId(listPreferenceScope);
570
+ }
334
571
 
335
572
  function rowIsExpanded(rowId: string): boolean {
336
573
  const expanded = tableExpanded.current;
337
574
  return expanded === true || expanded[rowId] === true;
338
575
  }
339
576
 
577
+ function rowIsSelected(rowId: string): boolean {
578
+ return tableRowSelection.current[rowId] === true;
579
+ }
580
+
581
+ function rowIdValue(row: Row<TableFeatures, BaseRecord>): string | number {
582
+ const value = row.original[primaryKey];
583
+ return typeof value === 'string' || typeof value === 'number' ? value : row.id;
584
+ }
585
+
586
+ function toggleRowSelection(row: Row<TableFeatures, BaseRecord>): void {
587
+ const wasSelected = rowIsSelected(row.id);
588
+ if (!wasSelected) {
589
+ selectedIdValueByKey.set(row.id, rowIdValue(row));
590
+ } else if (wasSelected) {
591
+ selectedIdValueByKey.delete(row.id);
592
+ }
593
+ row_toggleSelected(row);
594
+ }
595
+
340
596
  $effect(() => {
341
597
  const nextSorters: Sort[] = tableSorting.current.map((sorter) => ({
342
598
  field: sorter.id,
@@ -350,7 +606,8 @@
350
606
 
351
607
  // Persist column visibility to localStorage
352
608
  $effect(() => {
353
- const storageKey = `svadmin-columns-${resourceName}`;
609
+ if (!preferenceScopeIsLoaded()) return;
610
+ const storageKey = columnVisibilityStorageKey(listPreferenceScope);
354
611
  if (typeof window !== 'undefined') {
355
612
  try {
356
613
  localStorage.setItem(storageKey, JSON.stringify(tableColumnVisibility.current));
@@ -358,6 +615,176 @@
358
615
  }
359
616
  });
360
617
 
618
+ $effect(() => {
619
+ if (!preferenceScopeIsLoaded()) return;
620
+ const ids = tableColumnOrder.current;
621
+ persistColumnOrder(ids);
622
+ });
623
+
624
+ function persistSavedViews(): void {
625
+ if (typeof window === 'undefined' || !preferenceScopeIsLoaded()) return;
626
+ try {
627
+ localStorage.setItem(savedListViewsStorageKey(listPreferenceScope), serializeSavedListViews(savedViews));
628
+ } catch { /* ignore quota errors */ }
629
+ }
630
+
631
+ function persistActiveSavedView(): void {
632
+ if (typeof window === 'undefined' || !preferenceScopeIsLoaded()) return;
633
+ try {
634
+ const key = activeSavedListViewStorageKey(listPreferenceScope);
635
+ if (activeSavedViewId) localStorage.setItem(key, activeSavedViewId);
636
+ else localStorage.removeItem(key);
637
+ } catch { /* ignore quota errors */ }
638
+ }
639
+
640
+ function markSavedViewDirty(): void {
641
+ if (!activeSavedViewId) return;
642
+ activeSavedViewId = undefined;
643
+ persistActiveSavedView();
644
+ }
645
+
646
+ function getCurrentSavedViewState(): SavedListViewState {
647
+ return {
648
+ search: appliedSearchText,
649
+ filters: locationFilters.map(cloneFilter),
650
+ sorters: sorters.map((sorter) => ({ ...sorter })),
651
+ pagination: {
652
+ current: pagination.current ?? 1,
653
+ pageSize: pagination.pageSize ?? initPageSize,
654
+ },
655
+ columnVisibility: { ...tableColumnVisibility.current },
656
+ columnOrder: [...tableColumnOrder.current],
657
+ };
658
+ }
659
+
660
+ function applySavedView(view: SavedListView): void {
661
+ if (searchDebounceTimer) {
662
+ clearTimeout(searchDebounceTimer);
663
+ searchDebounceTimer = undefined;
664
+ }
665
+ const state = cloneSavedListViewState(view.state);
666
+ activeSavedViewId = view.id;
667
+ persistActiveSavedView();
668
+ searchText = state.search;
669
+ appliedSearchText = state.search;
670
+ filters = state.filters;
671
+ const restoredFilterCounts: Record<string, number> = {};
672
+ for (const filter of state.filters) {
673
+ if ('field' in filter && filter.operator === 'contains' && typeof filter.value === 'string' && editableFilterKeys.has(filter.field)) {
674
+ restoredFilterCounts[filter.field] = (restoredFilterCounts[filter.field] ?? 0) + 1;
675
+ }
676
+ }
677
+ filterValues = {};
678
+ for (const filter of state.filters) {
679
+ if (
680
+ 'field' in filter
681
+ && filter.operator === 'contains'
682
+ && typeof filter.value === 'string'
683
+ && restoredFilterCounts[filter.field] === 1
684
+ ) {
685
+ filterValues[filter.field] = filter.value;
686
+ }
687
+ }
688
+ pagination = { ...pagination, ...state.pagination };
689
+ sorters = state.sorters;
690
+ sortingAtom.set(state.sorters.map((sorter) => ({ id: sorter.field, desc: sorter.order === 'desc' })));
691
+ columnVisibilityAtom.set(state.columnVisibility);
692
+ columnOrderAtom.set(state.columnOrder);
693
+ persistColumnOrder(state.columnOrder);
694
+ rowSelectionAtom.set({});
695
+ }
696
+
697
+ function resetToDefaultListState(): void {
698
+ if (searchDebounceTimer) {
699
+ clearTimeout(searchDebounceTimer);
700
+ searchDebounceTimer = undefined;
701
+ }
702
+ searchText = '';
703
+ appliedSearchText = '';
704
+ filters = [];
705
+ filterValues = {};
706
+ if (!externalPagination) {
707
+ pagination = { current: 1, pageSize: initPageSize };
708
+ }
709
+ if (!externalSorters) {
710
+ const defaultSorters = initDefaultSort ? [{ ...initDefaultSort }] : [];
711
+ sorters = defaultSorters;
712
+ sortingAtom.set(defaultSorters.map((sorter) => ({ id: sorter.field, desc: sorter.order === 'desc' })));
713
+ }
714
+ }
715
+
716
+ $effect(() => {
717
+ const scope = listPreferenceScope;
718
+ const scopeId = listPreferenceScopeId(scope);
719
+ if (scopeId === loadedPreferenceScopeId) return;
720
+
721
+ const preferences = readScopedSavedViewPreferences(scope);
722
+ savedViews = preferences.savedViews;
723
+ activeSavedViewId = preferences.activeSavedViewId;
724
+
725
+ if (!hasExplicitURLState && preferences.activeSavedView) {
726
+ applySavedView(preferences.activeSavedView);
727
+ } else {
728
+ if (!hasExplicitURLState) resetToDefaultListState();
729
+ columnVisibilityAtom.set(preferences.columnVisibility);
730
+ columnOrderAtom.set(preferences.columnOrder);
731
+ rowSelectionAtom.set({});
732
+ }
733
+
734
+ loadedPreferenceScopeId = scopeId;
735
+ });
736
+
737
+ function saveCurrentView(): void {
738
+ const name = savedViewName.trim().slice(0, 60);
739
+ if (!name) return;
740
+ if (searchDebounceTimer) {
741
+ clearTimeout(searchDebounceTimer);
742
+ searchDebounceTimer = undefined;
743
+ appliedSearchText = searchText;
744
+ }
745
+ const existing = savedViews.find((view) => view.name.toLocaleLowerCase() === name.toLocaleLowerCase());
746
+ const view: SavedListView = {
747
+ id: existing?.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
748
+ name,
749
+ state: getCurrentSavedViewState(),
750
+ };
751
+ savedViews = existing
752
+ ? savedViews.map((candidate) => candidate.id === existing.id ? view : candidate)
753
+ : [view, ...savedViews].slice(0, 25);
754
+ activeSavedViewId = view.id;
755
+ savedViewName = '';
756
+ persistSavedViews();
757
+ persistActiveSavedView();
758
+ }
759
+
760
+ function deleteSavedView(id: string): void {
761
+ savedViews = savedViews.filter((view) => view.id !== id);
762
+ if (activeSavedViewId === id) activeSavedViewId = undefined;
763
+ persistSavedViews();
764
+ persistActiveSavedView();
765
+ }
766
+
767
+ function setColumnVisibility(columnId: string, visible: boolean): void {
768
+ markSavedViewDirty();
769
+ columnVisibilityAtom.set({ ...tableColumnVisibility.current, [columnId]: visible });
770
+ }
771
+
772
+ function persistColumnOrder(ids: string[]): void {
773
+ if (typeof window === 'undefined' || !preferenceScopeIsLoaded()) return;
774
+ try { localStorage.setItem(columnOrderStorageKey(listPreferenceScope), JSON.stringify(ids)); } catch { /* ignore */ }
775
+ }
776
+
777
+ function setColumnOrder(newOrder: Array<{ id: string }>): void {
778
+ markSavedViewDirty();
779
+ const ids = newOrder.map((column) => column.id);
780
+ columnOrderAtom.set(ids);
781
+ }
782
+
783
+ function toggleColumnSort(column: Column<TableFeatures, BaseRecord, unknown>): void {
784
+ markSavedViewDirty();
785
+ column_toggleSorting(column);
786
+ }
787
+
361
788
  // Sync external sorters ? local sorting state (controlled mode only)
362
789
  $effect(() => {
363
790
  if (!externalSorters) return;
@@ -431,9 +858,14 @@
431
858
  get columns() { return orderedColumns; },
432
859
  manualSorting: true,
433
860
  getRowId: (row: BaseRecord) => String(row[primaryKey]),
861
+ state: {
862
+ get sorting() { return tableSorting.current; },
863
+ get columnVisibility() { return tableColumnVisibility.current; },
864
+ },
865
+ onSortingChange: (updater) => {
866
+ sortingAtom.set(typeof updater === 'function' ? updater(tableSorting.current) : updater);
867
+ },
434
868
  atoms: {
435
- sorting: sortingAtom,
436
- columnVisibility: columnVisibilityAtom,
437
869
  rowSelection: rowSelectionAtom,
438
870
  expanded: expandedAtom,
439
871
  },
@@ -444,14 +876,38 @@
444
876
  },
445
877
  );
446
878
 
447
- $effect.pre(() => {
448
- const nextColumns = orderedColumns;
449
- untrack(() => {
450
- tbl.setOptions({ ...tbl.options, columns: nextColumns });
451
- });
452
- });
879
+ function toggleAllRowsSelection(): void {
880
+ const willSelect = !table_getIsAllRowsSelected(tbl);
881
+ for (const row of tableView.rows) {
882
+ if (willSelect) {
883
+ selectedIdValueByKey.set(row.id, rowIdValue(row));
884
+ } else if (!willSelect) {
885
+ selectedIdValueByKey.delete(row.id);
886
+ }
887
+ }
888
+ table_toggleAllRowsSelected(tbl);
889
+ }
453
890
 
454
- const selectedCount = $derived(Object.keys(tableRowSelection.current).length);
891
+ const selectedIds = $derived(
892
+ Object.keys(tableRowSelection.current).map((key) => selectedIdValueByKey.get(key) ?? key)
893
+ );
894
+ const selectedCount = $derived(selectedIds.length);
895
+ const batchDeletePerm = useCan(() => ({
896
+ resource: resourceName,
897
+ action: 'delete',
898
+ params: { ids: selectedIds },
899
+ queryOptions: { enabled: acEnabled && canDelete && selectedIds.length > 0 },
900
+ }));
901
+ const canBatchDelete = $derived(
902
+ canDelete && (!acEnabled || (!batchDeletePerm.isLoading && batchDeletePerm.allowed))
903
+ );
904
+
905
+ function clearSelection(): void {
906
+ if (!deleteManyMutation.isPending) {
907
+ selectedIdValueByKey.clear();
908
+ rowSelectionAtom.set({});
909
+ }
910
+ }
455
911
 
456
912
  function isColumnVisible(columnId: string): boolean {
457
913
  return tableColumnVisibility.current[columnId] !== false;
@@ -460,11 +916,13 @@
460
916
  const tableView = $derived.by(() => {
461
917
  void tableSorting.current;
462
918
  void tableColumnVisibility.current;
919
+ void orderedColumns;
463
920
  void tableRowSelection.current;
464
921
  void tableExpanded.current;
465
922
  void tableColumnOrder.current;
923
+ const headerGroups = table_getHeaderGroups(tbl);
466
924
  return {
467
- headerGroups: tbl.getHeaderGroups(),
925
+ headerGroups,
468
926
  rows: table_getRowModel(tbl).rows,
469
927
  };
470
928
  });
@@ -512,38 +970,79 @@
512
970
  let confirmOpen = $state(false);
513
971
  let confirmMessage = $state('');
514
972
  let confirmAction = $state<() => void>(() => {});
973
+ let confirmPending = $state(false);
974
+ let detailOpenedInHistory = $state(false);
975
+ const detailRecordId = $derived(parsed.params.detail);
976
+ const detailOpen = $derived(detailRecordId != null);
515
977
 
516
978
  function confirmDelete(id: string | number) {
517
979
  confirmMessage = i18n.t('common.deleteConfirm');
518
980
  confirmAction = async () => {
519
- await deleteMutation.mutateAsync({ id, resource: resourceName });
520
- confirmOpen = false;
981
+ confirmPending = true;
982
+ if (mutationMode !== 'pessimistic') confirmOpen = false;
983
+ try {
984
+ await deleteMutation.mutateAsync({
985
+ id,
986
+ resource: resourceName,
987
+ onCancel: () => { confirmOpen = false; },
988
+ });
989
+ confirmOpen = false;
990
+ } catch {
991
+ // useDelete owns error feedback and restores optimistic cache state.
992
+ } finally {
993
+ confirmPending = false;
994
+ }
521
995
  };
522
996
  confirmOpen = true;
523
997
  }
524
998
 
525
999
  function confirmBatchDelete() {
526
- const ids = Object.keys(tableRowSelection.current);
1000
+ const ids = [...selectedIds];
1001
+ if (ids.length === 0 || deleteManyMutation.isPending) return;
1002
+ batchOperationCount = ids.length;
527
1003
  confirmMessage = i18n.t('common.batchDeleteConfirm', { count: ids.length });
528
1004
  confirmAction = async () => {
1005
+ confirmPending = true;
1006
+ if (mutationMode !== 'pessimistic') confirmOpen = false;
529
1007
  try {
530
- await deleteManyMutation.mutateAsync({ ids, resource: resourceName });
531
- notifyWithProvider(
532
- { type: 'success', message: i18n.t('common.batchDeleteSuccess', { count: ids.length }) },
533
- adminContext.notificationProvider,
534
- );
535
- } catch {
536
- notifyWithProvider(
537
- { type: 'error', message: i18n.t('common.batchDeletePartialFail', { failed: 1, total: ids.length }) },
538
- adminContext.notificationProvider,
539
- );
1008
+ await deleteManyMutation.mutateAsync({
1009
+ ids,
1010
+ resource: resourceName,
1011
+ onCancel: () => { confirmOpen = false; },
1012
+ });
1013
+ selectedIdValueByKey.clear();
1014
+ rowSelectionAtom.set({});
1015
+ confirmOpen = false;
1016
+ } catch (error) {
1017
+ if (error instanceof DeleteManyPartialError) {
1018
+ const failedSelection: RowSelectionState = {};
1019
+ for (const id of error.failedIds) failedSelection[String(id)] = true;
1020
+ for (const id of error.succeededIds) selectedIdValueByKey.delete(String(id));
1021
+ rowSelectionAtom.set(failedSelection);
1022
+ }
1023
+ // useDeleteMany owns the error notification; keep failed selection for retry.
1024
+ } finally {
1025
+ confirmPending = false;
540
1026
  }
541
- rowSelectionAtom.set({});
542
- confirmOpen = false;
543
1027
  };
544
1028
  confirmOpen = true;
545
1029
  }
546
1030
 
1031
+ function openDetail(id: string | number): void {
1032
+ detailOpenedInHistory = true;
1033
+ writeURLState({ detailId: String(id) }, adminContext, 'push');
1034
+ }
1035
+
1036
+ function closeDetail(): void {
1037
+ if (detailOpenedInHistory && adminContext.routerProvider) {
1038
+ detailOpenedInHistory = false;
1039
+ adminContext.routerProvider.back();
1040
+ return;
1041
+ }
1042
+ detailOpenedInHistory = false;
1043
+ writeURLState({ detailId: undefined }, adminContext);
1044
+ }
1045
+
547
1046
  // ─── CSV Export ───────────────────────────────────────────────
548
1047
  function exportCSV() {
549
1048
  const data = query.data?.data ?? [];
@@ -567,6 +1066,7 @@
567
1066
  }
568
1067
 
569
1068
  function goToPage(page: number) {
1069
+ markSavedViewDirty();
570
1070
  pagination = { ...pagination, current: page };
571
1071
  }
572
1072
  </script>
@@ -579,15 +1079,15 @@
579
1079
  <div class="flex flex-wrap items-center gap-2">
580
1080
  {#if canExport}
581
1081
  <Button variant="outline" size="sm" onclick={exportCSV}>
582
- <Download class="h-4 w-4" data-icon="inline-start" /> {i18n.t('common.export')}
1082
+ <Download class="h-4 w-4" data-icon="inline-start" /> {i18n.t("common.export")}
583
1083
  </Button>
584
1084
  {/if}
585
1085
  {#if headerActions}
586
1086
  {@render headerActions()}
587
1087
  {/if}
588
1088
  {#if canCreate}
589
- <Button onclick={() => adminContext.navigate(`/${resourceName}/create`)}>
590
- <Plus class="h-4 w-4" data-icon="inline-start" /> {i18n.t('common.create')}
1089
+ <Button onclick={() => navigation.create(resourceName)}>
1090
+ <Plus class="h-4 w-4" data-icon="inline-start" /> {i18n.t("common.create")}
591
1091
  </Button>
592
1092
  {/if}
593
1093
  </div>
@@ -596,31 +1096,40 @@
596
1096
 
597
1097
  <!-- Selection Banner (Enterprise Batch Actions) -->
598
1098
  {#if selectedCount > 0}
599
- <div class="flex flex-wrap items-center justify-between gap-3 px-3.5 py-2 bg-primary/10 border border-primary/20 rounded-lg text-sm text-foreground animate-in fade-in duration-200">
600
- <div class="flex items-center gap-2">
601
- <span class="font-medium text-xs sm:text-sm">{i18n.t('common.selectedItems', { count: selectedCount })}</span>
1099
+ <div
1100
+ class="flex flex-col gap-3 border border-primary/20 bg-primary/10 px-3.5 py-2 rounded-lg text-sm text-foreground animate-in fade-in duration-200 sm:flex-row sm:items-center sm:justify-between"
1101
+ aria-label={i18n.t("common.selectedCount", { count: selectedCount })}
1102
+ data-svadmin-batch-toolbar
1103
+ >
1104
+ <div class="flex min-w-0 items-center gap-2 text-sm">
1105
+ <span class="font-medium text-foreground">{i18n.t("common.selectedCount", { count: selectedCount })}</span>
1106
+ {#if deleteManyMutation.isPending}
1107
+ <span class="text-muted-foreground" role="status">{i18n.t("common.processing")}</span>
1108
+ {/if}
602
1109
  </div>
603
- <div class="flex items-center gap-2">
604
- {#if canDelete}
1110
+ <div class="flex flex-wrap items-center gap-2">
1111
+ {#if batchActions}
1112
+ {@render batchActions({ selectedIds })}
1113
+ {/if}
1114
+ {#if canBatchDelete}
605
1115
  <Button
606
1116
  variant="destructive"
607
1117
  size="sm"
608
1118
  class="h-8 text-xs whitespace-nowrap"
1119
+ disabled={deleteManyMutation.isPending}
609
1120
  onclick={confirmBatchDelete}
610
1121
  >
611
- <Trash2 class="h-3.5 w-3.5" data-icon="inline-start" /> {i18n.t('common.batchDelete', { count: selectedCount })}
1122
+ <Trash2 class="h-3.5 w-3.5" data-icon="inline-start" /> {i18n.t("common.batchDelete", { count: selectedCount })}
612
1123
  </Button>
613
1124
  {/if}
614
- {#if batchActions}
615
- {@render batchActions({ selectedIds: Object.keys(tableRowSelection.current) })}
616
- {/if}
617
1125
  <Button
618
1126
  variant="ghost"
619
1127
  size="sm"
620
1128
  class="h-8 text-xs text-muted-foreground hover:text-foreground"
621
- onclick={() => rowSelectionAtom.set({})}
1129
+ disabled={deleteManyMutation.isPending}
1130
+ onclick={clearSelection}
622
1131
  >
623
- {i18n.t('common.clearSelection')}
1132
+ {i18n.t("common.clearSelection")}
624
1133
  </Button>
625
1134
  </div>
626
1135
  </div>
@@ -637,7 +1146,7 @@
637
1146
  type="text"
638
1147
  value={searchText}
639
1148
  oninput={scheduleSearch}
640
- placeholder={i18n.t('common.search')}
1149
+ placeholder={i18n.t("common.search")}
641
1150
  class="pl-9 h-9 text-sm"
642
1151
  />
643
1152
  </div>
@@ -649,7 +1158,7 @@
649
1158
  {#snippet child({ props })}
650
1159
  <Button variant="outline" size="sm" class="h-9 px-3" {...props}>
651
1160
  <FilterIcon class="h-4 w-4" data-icon="inline-start" />
652
- {i18n.t('common.filter')}
1161
+ {i18n.t("common.filter")}
653
1162
  {#if activeFilterCount > 0}
654
1163
  <Badge variant="secondary" class="ml-1 h-5 min-w-5 px-1">{activeFilterCount}</Badge>
655
1164
  {/if}
@@ -658,18 +1167,18 @@
658
1167
  </Popover.Trigger>
659
1168
  <Popover.Content class="w-80">
660
1169
  <div class="space-y-3">
661
- <h4 class="font-medium text-sm">{i18n.t('common.filter')}</h4>
1170
+ <h4 class="font-medium text-sm">{i18n.t("common.filter")}</h4>
662
1171
  {#each filterableFields as field, _i (_i)}
663
1172
  <div class="space-y-1">
664
1173
  <label class="text-xs text-muted-foreground" for="filter-{field.key}">{field.label}</label>
665
- {#if field.type === 'select' && field.options}
1174
+ {#if field.type === "select" && field.options}
666
1175
  <select
667
1176
  id="filter-{field.key}"
668
1177
  class="h-9 text-sm w-full font-normal flex w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
669
- value={filterValues[field.key] ?? ''}
670
- onchange={(e) => filterValues[field.key] = (e.currentTarget as HTMLSelectElement).value}
1178
+ value={filterValues[field.key] ?? ""}
1179
+ onchange={(e) => setFilterValue(field.key, e.currentTarget.value)}
671
1180
  >
672
- <option value="">{i18n.t('common.all')}</option>
1181
+ <option value="">{i18n.t("common.all")}</option>
673
1182
  {#each field.options as opt, _i (_i)}
674
1183
  <option value={opt.value}>{opt.label}</option>
675
1184
  {/each}
@@ -678,8 +1187,8 @@
678
1187
  <Input
679
1188
  id="filter-{field.key}"
680
1189
  type="text"
681
- value={filterValues[field.key] ?? ''}
682
- oninput={(e) => filterValues[field.key] = (e.currentTarget as HTMLInputElement).value}
1190
+ value={filterValues[field.key] ?? ""}
1191
+ oninput={(e) => setFilterValue(field.key, e.currentTarget.value)}
683
1192
  placeholder={field.label}
684
1193
  class="h-9 text-sm"
685
1194
  />
@@ -688,13 +1197,10 @@
688
1197
  {/each}
689
1198
  <div class="flex gap-2 pt-2">
690
1199
  <Button size="sm" class="flex-1" onclick={() => { pagination = { ...pagination, current: 1 }; }}>
691
- {i18n.t('common.confirm')}
1200
+ {i18n.t("common.confirm")}
692
1201
  </Button>
693
- <Button variant="outline" size="sm" onclick={() => {
694
- filterValues = {};
695
- pagination = { ...pagination, current: 1 };
696
- }}>
697
- {i18n.t('common.reset')}
1202
+ <Button variant="outline" size="sm" onclick={clearFilters}>
1203
+ {i18n.t("common.reset")}
698
1204
  </Button>
699
1205
  </div>
700
1206
  </div>
@@ -703,10 +1209,10 @@
703
1209
  {/if}
704
1210
  </div>
705
1211
 
706
- <!-- Right: Table Controls (Density, Columns, Refresh, Export if !showHeader) -->
1212
+ <!-- Right: Table Controls (Density, Columns, Saved Views, Refresh, Export if !showHeader) -->
707
1213
  <div class="flex items-center gap-1.5 shrink-0">
708
1214
  {#if !showHeader && canExport}
709
- <TooltipButton tooltip={i18n.t('common.export')} variant="outline" size="sm" class="h-9 px-2.5" onclick={exportCSV}>
1215
+ <TooltipButton tooltip={i18n.t("common.export")} variant="outline" size="sm" class="h-9 px-2.5" onclick={exportCSV}>
710
1216
  <Download class="h-4 w-4" />
711
1217
  </TooltipButton>
712
1218
  {/if}
@@ -715,17 +1221,17 @@
715
1221
  <DropdownMenu.Root>
716
1222
  <DropdownMenu.Trigger>
717
1223
  {#snippet child({ props })}
718
- <TooltipButton tooltip={i18n.t('common.density')} variant="outline" size="sm" class="h-9 px-2.5" {...props}>
1224
+ <TooltipButton tooltip={i18n.t("common.density")} variant="outline" size="sm" class="h-9 px-2.5" {...props}>
719
1225
  <Rows class="h-4 w-4" />
720
1226
  </TooltipButton>
721
1227
  {/snippet}
722
1228
  </DropdownMenu.Trigger>
723
1229
  <DropdownMenu.Content align="end" class="w-36">
724
- <DropdownMenu.Item onclick={() => { densityOverride = 'comfortable'; }} class={currentDensity === 'comfortable' ? 'font-semibold text-primary' : ''}>
725
- {i18n.t('common.densityComfortable')}
1230
+ <DropdownMenu.Item onclick={() => { densityOverride = "comfortable"; }} class={currentDensity === "comfortable" ? "font-semibold text-primary" : ""}>
1231
+ {i18n.t("common.densityComfortable")}
726
1232
  </DropdownMenu.Item>
727
- <DropdownMenu.Item onclick={() => { densityOverride = 'compact'; }} class={currentDensity === 'compact' ? 'font-semibold text-primary' : ''}>
728
- {i18n.t('common.densityCompact')}
1233
+ <DropdownMenu.Item onclick={() => { densityOverride = "compact"; }} class={currentDensity === "compact" ? "font-semibold text-primary" : ""}>
1234
+ {i18n.t("common.densityCompact")}
729
1235
  </DropdownMenu.Item>
730
1236
  </DropdownMenu.Content>
731
1237
  </DropdownMenu.Root>
@@ -735,16 +1241,16 @@
735
1241
  <DropdownMenu.Root>
736
1242
  <DropdownMenu.Trigger>
737
1243
  {#snippet child({ props })}
738
- <TooltipButton tooltip={i18n.t('common.columns')} variant="outline" size="sm" class="h-9 px-2.5" {...props}>
1244
+ <TooltipButton tooltip={i18n.t("common.columns")} variant="outline" size="sm" class="h-9 px-2.5" {...props}>
739
1245
  <SlidersHorizontal class="h-4 w-4" />
740
1246
  </TooltipButton>
741
1247
  {/snippet}
742
1248
  </DropdownMenu.Trigger>
743
1249
  <DropdownMenu.Content align="end" class="w-48">
744
- {#each table_getAllLeafColumns(tbl).filter((column: Column<TableFeatures, BaseRecord, unknown>) => !column.id.startsWith('_')) as column, _i (_i)}
1250
+ {#each table_getAllLeafColumns(tbl).filter((column) => !column.id.startsWith("_")) as column, _i (_i)}
745
1251
  <DropdownMenu.CheckboxItem
746
1252
  checked={tableColumnVisibility.current[column.id] ?? true}
747
- onCheckedChange={(v) => column_toggleVisibility(column, !!v)}
1253
+ onCheckedChange={(v) => setColumnVisibility(column.id, !!v)}
748
1254
  >
749
1255
  {visibleFields.find(f => f.key === column.id)?.label ?? column.id}
750
1256
  </DropdownMenu.CheckboxItem>
@@ -752,49 +1258,134 @@
752
1258
  </DropdownMenu.Content>
753
1259
  </DropdownMenu.Root>
754
1260
 
1261
+ <!-- Saved Views -->
1262
+ <Popover.Root bind:open={savedViewsOpen}>
1263
+ <Popover.Trigger>
1264
+ {#snippet child({ props })}
1265
+ <Button variant="outline" size="sm" class="h-9 px-2.5" {...props} aria-label={i18n.t("common.savedViews")}>
1266
+ <Bookmark class="h-4 w-4" data-icon="inline-start" /> {activeSavedViewName ?? i18n.t("common.savedViews")}
1267
+ </Button>
1268
+ {/snippet}
1269
+ </Popover.Trigger>
1270
+ <Popover.Content align="end" class="w-80">
1271
+ <div class="space-y-3">
1272
+ <div>
1273
+ <h4 class="font-medium text-sm">{i18n.t("common.savedViews")}</h4>
1274
+ <p class="mt-1 text-xs text-muted-foreground">{i18n.t("common.savedViewsHint")}</p>
1275
+ </div>
1276
+ <div class="space-y-1">
1277
+ <label class="text-xs text-muted-foreground" for="saved-list-view">{i18n.t("common.currentView")}</label>
1278
+ <select
1279
+ id="saved-list-view"
1280
+ class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
1281
+ value={activeSavedViewId ?? ""}
1282
+ onchange={(event) => {
1283
+ const id = event.currentTarget.value;
1284
+ const view = savedViews.find((candidate) => candidate.id === id);
1285
+ if (view) applySavedView(view);
1286
+ else {
1287
+ activeSavedViewId = undefined;
1288
+ persistActiveSavedView();
1289
+ }
1290
+ }}
1291
+ >
1292
+ <option value="">{i18n.t("common.currentView")}</option>
1293
+ {#each savedViews as view (view.id)}
1294
+ <option value={view.id}>{view.name}</option>
1295
+ {/each}
1296
+ </select>
1297
+ </div>
1298
+ <div class="flex gap-2">
1299
+ <Input
1300
+ aria-label={i18n.t("common.viewName")}
1301
+ placeholder={i18n.t("common.viewName")}
1302
+ maxlength={60}
1303
+ bind:value={savedViewName}
1304
+ class="h-9"
1305
+ />
1306
+ <Button size="sm" class="shrink-0" disabled={!savedViewName.trim()} onclick={saveCurrentView}>
1307
+ <Check class="h-4 w-4" data-icon="inline-start" /> {i18n.t("common.saveView")}
1308
+ </Button>
1309
+ </div>
1310
+ {#if savedViews.length > 0}
1311
+ <div class="space-y-1 border-t border-border pt-2">
1312
+ {#each savedViews as view (view.id)}
1313
+ <div class="flex items-center justify-between gap-2 text-sm">
1314
+ <span class="min-w-0 truncate">{view.name}</span>
1315
+ <button
1316
+ type="button"
1317
+ class="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
1318
+ aria-label="{i18n.t("common.delete")} {view.name}"
1319
+ title="{i18n.t("common.delete")} {view.name}"
1320
+ onclick={() => deleteSavedView(view.id)}
1321
+ >
1322
+ <Trash2 class="size-3.5" />
1323
+ </button>
1324
+ </div>
1325
+ {/each}
1326
+ </div>
1327
+ {/if}
1328
+ </div>
1329
+ </Popover.Content>
1330
+ </Popover.Root>
1331
+
755
1332
  {#if showRefresh}
756
1333
  <TooltipButton
757
- tooltip={i18n.t('common.refresh')}
1334
+ tooltip={i18n.t("common.refresh")}
758
1335
  variant="outline"
759
1336
  size="sm"
760
1337
  class="h-9 px-2.5"
761
1338
  onclick={() => listResult.refetch()}
762
1339
  >
763
- <RefreshCw class="h-4 w-4 {query.isFetching ? 'animate-spin' : ''}" />
1340
+ <RefreshCw class="h-4 w-4 {query.isFetching ? "animate-spin" : ""}" />
764
1341
  </TooltipButton>
765
1342
  {/if}
1343
+
1344
+ {#if !showHeader && canCreate}
1345
+ <Button onclick={() => navigation.create(resourceName)}>
1346
+ <Plus class="h-4 w-4" data-icon="inline-start" /> {i18n.t("common.create")}
1347
+ </Button>
1348
+ {/if}
766
1349
  </div>
767
1350
  </div>
768
1351
 
769
1352
  <!-- Active Filter Tags -->
770
- {#if showFilterTags && (appliedSearchText.trim() || activeFilterCount > 0)}
771
- <div class="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground pt-0.5">
1353
+ {#if showFilterTags && ((appliedSearchText || searchText).trim() || activeFilterItems.length > 0)}
1354
+ <div
1355
+ class="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground pt-0.5 border-y border-border py-2"
1356
+ aria-label={i18n.t('common.filterTags')}
1357
+ data-svadmin-active-filters
1358
+ >
772
1359
  <span class="font-medium text-foreground">{i18n.t('common.filterTags')}:</span>
773
- {#if appliedSearchText.trim()}
1360
+ {#if (appliedSearchText || searchText).trim()}
774
1361
  <Badge variant="secondary" class="gap-1 pr-1 font-normal text-xs">
775
- <span>{i18n.t('common.search')}: "{appliedSearchText}"</span>
776
- <button type="button" class="hover:text-foreground cursor-pointer" aria-label={`${i18n.t('common.clear')}: ${i18n.t('common.search')}`} onclick={() => { searchText = ''; appliedSearchText = ''; pagination = { ...pagination, current: 1 }; }}>
777
- <X class="h-3 w-3" />
1362
+ <span>{i18n.t('common.search')}: "{(appliedSearchText || searchText).trim()}"</span>
1363
+ <button
1364
+ type="button"
1365
+ class="hover:text-foreground cursor-pointer inline-flex size-4 items-center justify-center rounded-sm"
1366
+ aria-label="{i18n.t('common.clear')}: {i18n.t('common.search')}"
1367
+ title="{i18n.t('common.clear')}: {i18n.t('common.search')}"
1368
+ onclick={() => { searchText = ""; appliedSearchText = ""; pagination = { ...pagination, current: 1 }; }}
1369
+ >
1370
+ <X class="size-3" />
778
1371
  </button>
779
1372
  </Badge>
780
1373
  {/if}
781
- {#each Object.entries(filterValues) as [key, val] (key)}
782
- {#if val.trim()}
783
- {@const field = filterableFields.find(f => f.key === key)}
784
- <Badge variant="secondary" class="gap-1 pr-1 font-normal text-xs">
785
- <span>{field?.label ?? key}: {val}</span>
786
- <button type="button" class="hover:text-foreground cursor-pointer" aria-label={`${i18n.t('common.clear')}: ${field?.label ?? key}`} onclick={() => removeFilterKey(key)}>
787
- <X class="h-3 w-3" />
788
- </button>
789
- </Badge>
790
- {/if}
1374
+ {#each activeFilterItems as item (`${item.label}-${item.index}`)}
1375
+ <Badge variant="outline" class="max-w-full gap-1 pr-1 font-normal">
1376
+ <span class="truncate">{item.label}</span>
1377
+ <button
1378
+ type="button"
1379
+ class="inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring cursor-pointer"
1380
+ aria-label="{i18n.t('common.clear')}: {item.label}"
1381
+ title="{i18n.t('common.clear')}: {item.label}"
1382
+ onclick={() => removeActiveFilter(item.index)}
1383
+ >
1384
+ <X class="size-3" />
1385
+ </button>
1386
+ </Badge>
791
1387
  {/each}
792
- <Button variant="ghost" size="sm" class="h-6 px-2 text-xs text-muted-foreground hover:text-foreground" onclick={() => {
793
- searchText = '';
794
- appliedSearchText = '';
795
- filterValues = {};
796
- pagination = { ...pagination, current: 1 };
797
- }}>
1388
+ <Button variant="ghost" size="sm" class="h-6 px-2 text-xs text-muted-foreground hover:text-foreground" onclick={clearFilters}>
798
1389
  {i18n.t('common.clearAllFilters')}
799
1390
  </Button>
800
1391
  </div>
@@ -831,8 +1422,7 @@
831
1422
  {@const visibleHeaders = headerGroup.headers.filter((header: Header<TableFeatures, BaseRecord, unknown>) => isColumnVisible(header.column.id))}
832
1423
  <DraggableHeader
833
1424
  columns={visibleHeaders.map((header) => ({ id: header.column.id, header }))}
834
- resourceName={resourceName}
835
- onReorder={(newOrder) => columnOrderAtom.set(newOrder.map((column) => column.id))}
1425
+ onReorder={setColumnOrder}
836
1426
  >
837
1427
  {#snippet header(col, _index, dragProps)}
838
1428
  {@const header = col.header as typeof headerGroup.headers[0]}
@@ -842,10 +1432,21 @@
842
1432
  style={header_getSize(header) != null && header_getSize(header) !== 150 ? `width:${header_getSize(header)}px` : undefined}
843
1433
  >
844
1434
  {#if header.id === '_select'}
845
- <Checkbox
846
- checked={table_getIsAllRowsSelected(tbl)}
847
- onCheckedChange={() => table_toggleAllRowsSelected(tbl)}
848
- />
1435
+ {#if batchActions}
1436
+ <Checkbox
1437
+ aria-label={i18n.t('common.selectAll')}
1438
+ checked={table_getIsAllRowsSelected(tbl)}
1439
+ onCheckedChange={toggleAllRowsSelection}
1440
+ />
1441
+ {:else}
1442
+ <CanAccess resource={resourceName} action="delete" params={{ ids: tableView.rows.map(rowIdValue) }}>
1443
+ <Checkbox
1444
+ aria-label={i18n.t('common.selectAll')}
1445
+ checked={table_getIsAllRowsSelected(tbl)}
1446
+ onCheckedChange={toggleAllRowsSelection}
1447
+ />
1448
+ </CanAccess>
1449
+ {/if}
849
1450
  {:else if header.id === '_expand'}
850
1451
  <!-- empty -->
851
1452
  {:else if header.id === '_actions'}
@@ -855,7 +1456,7 @@
855
1456
  variant="ghost"
856
1457
  size="sm"
857
1458
  class="flex items-center gap-1 hover:text-foreground -ml-2 h-auto py-1 px-2 uppercase tracking-wide text-[0.7rem] font-semibold"
858
- onclick={() => column_toggleSorting(header.column)}
1459
+ onclick={() => toggleColumnSort(header.column)}
859
1460
  >
860
1461
  {visibleFields.find(f => f.key === header.id)?.label ?? header.id}
861
1462
  <span class="text-xs opacity-50">
@@ -881,14 +1482,25 @@
881
1482
  <ContextMenu.Root>
882
1483
  <ContextMenu.Trigger>
883
1484
  {#snippet child({ props })}
884
- <Table.Row {...props} class="transition-all duration-300 border-b border-border/10 {row_getIsSelected(row) ? 'bg-primary/5' : 'hover:bg-muted/20'}">
1485
+ <Table.Row {...props} class="transition-all duration-300 border-b border-border/10 {rowIsSelected(row.id) ? 'bg-primary/5' : 'hover:bg-muted/20'}">
885
1486
  {#each visibleCells as cell, _i (_i)}
886
1487
  <Table.Cell>
887
1488
  {#if cell.column.id === '_select'}
888
- <Checkbox
889
- checked={row_getIsSelected(row)}
890
- onCheckedChange={() => row_toggleSelected(row)}
891
- />
1489
+ {#if batchActions}
1490
+ <Checkbox
1491
+ aria-label={i18n.t('common.selectRow', { id })}
1492
+ checked={rowIsSelected(row.id)}
1493
+ onCheckedChange={() => toggleRowSelection(row)}
1494
+ />
1495
+ {:else}
1496
+ <CanAccess resource={resourceName} action="delete" params={{ id }}>
1497
+ <Checkbox
1498
+ aria-label={i18n.t('common.selectRow', { id })}
1499
+ checked={rowIsSelected(row.id)}
1500
+ onCheckedChange={() => toggleRowSelection(row)}
1501
+ />
1502
+ </CanAccess>
1503
+ {/if}
892
1504
  {:else if cell.column.id === '_expand'}
893
1505
  <TooltipButton tooltip={rowIsExpanded(row.id) ? i18n.t('common.collapse') : i18n.t('common.expand')} variant="ghost" size="icon" class="h-7 w-7" onclick={() => toggleRowExpanded(row.id)}>
894
1506
  {#if rowIsExpanded(row.id)}
@@ -902,15 +1514,26 @@
902
1514
  {#if rowActions}
903
1515
  {@render rowActions({ record, id })}
904
1516
  {:else}
1517
+ {#if canShow}
1518
+ <CanAccess resource={resourceName} action="show" params={{ id }}>
1519
+ <TooltipButton tooltip={i18n.t('common.detail')} variant="ghost" size="icon-sm" onclick={() => openDetail(id)}>
1520
+ <Eye class="h-4 w-4" />
1521
+ </TooltipButton>
1522
+ </CanAccess>
1523
+ {/if}
905
1524
  {#if canEdit}
906
- <TooltipButton tooltip={i18n.t('common.edit')} variant="ghost" size="icon-sm" onclick={() => adminContext.navigate(`/${resourceName}/edit/${id}`)}>
907
- <Pencil class="h-4 w-4" />
908
- </TooltipButton>
1525
+ <CanAccess resource={resourceName} action="edit" params={{ id }}>
1526
+ <TooltipButton tooltip={i18n.t('common.edit')} variant="ghost" size="icon-sm" onclick={() => navigation.edit(resourceName, id)}>
1527
+ <Pencil class="h-4 w-4" />
1528
+ </TooltipButton>
1529
+ </CanAccess>
909
1530
  {/if}
910
1531
  {#if canDelete}
911
- <TooltipButton tooltip={i18n.t('common.delete')} variant="ghost" size="icon-sm" onclick={() => confirmDelete(id)} class="hover:text-destructive">
912
- <Trash2 class="h-4 w-4" />
913
- </TooltipButton>
1532
+ <CanAccess resource={resourceName} action="delete" params={{ id }}>
1533
+ <TooltipButton tooltip={i18n.t('common.delete')} variant="ghost" size="icon-sm" onclick={() => confirmDelete(id)} class="hover:text-destructive">
1534
+ <Trash2 class="h-4 w-4" />
1535
+ </TooltipButton>
1536
+ </CanAccess>
914
1537
  {/if}
915
1538
  {/if}
916
1539
  </div>
@@ -934,13 +1557,18 @@
934
1557
  {@const opt = field.options.find(o => o.value === cell_getValue(cell))}
935
1558
  <Badge variant="outline">{opt?.label ?? cell_getValue(cell) ?? '—'}</Badge>
936
1559
  {:else if canEdit && field && ['text', 'number', 'email', 'url'].includes(field.type)}
937
- <InlineEdit
938
- {resourceName}
939
- recordId={id}
940
- {field}
941
- value={cell_getValue(cell)}
942
- onSave={() => listResult.refetch()}
943
- />
1560
+ <CanAccess resource={resourceName} action="edit" params={{ id }}>
1561
+ <InlineEdit
1562
+ {resourceName}
1563
+ recordId={id}
1564
+ {field}
1565
+ value={cell_getValue(cell)}
1566
+ onSave={() => listResult.refetch()}
1567
+ />
1568
+ {#snippet fallback()}
1569
+ <span class="block truncate" title={String(cell_getValue(cell) ?? '—')}>{cell_getValue(cell) ?? '—'}</span>
1570
+ {/snippet}
1571
+ </CanAccess>
944
1572
  {:else if field?.key === primaryKey}
945
1573
  <span class="block truncate font-mono text-xs" title={String(cell_getValue(cell) ?? '—')}>{cell_getValue(cell) ?? '—'}</span>
946
1574
  {:else}
@@ -954,23 +1582,29 @@
954
1582
  </ContextMenu.Trigger>
955
1583
  <ContextMenu.Content class="w-48">
956
1584
  {#if canEdit}
957
- <ContextMenu.Item onclick={() => adminContext.navigate(`/${resourceName}/edit/${id}`)} class="gap-2">
958
- <Pencil class="h-4 w-4" /> {i18n.t('common.edit')}
959
- </ContextMenu.Item>
1585
+ <CanAccess resource={resourceName} action="edit" params={{ id }}>
1586
+ <ContextMenu.Item onclick={() => navigation.edit(resourceName, id)} class="gap-2">
1587
+ <Pencil class="h-4 w-4" /> {i18n.t('common.edit')}
1588
+ </ContextMenu.Item>
1589
+ </CanAccess>
960
1590
  {/if}
961
1591
  {#if canShow}
962
- <ContextMenu.Item onclick={() => adminContext.navigate(`/${resourceName}/show/${id}`)} class="gap-2">
963
- <Eye class="h-4 w-4" /> {i18n.t('common.detail')}
964
- </ContextMenu.Item>
1592
+ <CanAccess resource={resourceName} action="show" params={{ id }}>
1593
+ <ContextMenu.Item onclick={() => openDetail(id)} class="gap-2">
1594
+ <Eye class="h-4 w-4" /> {i18n.t('common.detail')}
1595
+ </ContextMenu.Item>
1596
+ </CanAccess>
965
1597
  {/if}
966
1598
  <ContextMenu.Item onclick={() => navigator.clipboard?.writeText(String(id))} class="gap-2">
967
1599
  <Copy class="h-4 w-4" /> {i18n.t('common.copyId')}
968
1600
  </ContextMenu.Item>
969
1601
  {#if canDelete}
970
- <ContextMenu.Separator />
971
- <ContextMenu.Item onclick={() => confirmDelete(id)} class="gap-2 text-destructive">
972
- <Trash2 class="h-4 w-4" /> {i18n.t('common.delete')}
973
- </ContextMenu.Item>
1602
+ <CanAccess resource={resourceName} action="delete" params={{ id }}>
1603
+ <ContextMenu.Separator />
1604
+ <ContextMenu.Item onclick={() => confirmDelete(id)} class="gap-2 text-destructive">
1605
+ <Trash2 class="h-4 w-4" /> {i18n.t('common.delete')}
1606
+ </ContextMenu.Item>
1607
+ </CanAccess>
974
1608
  {/if}
975
1609
  </ContextMenu.Content>
976
1610
  </ContextMenu.Root>
@@ -994,7 +1628,7 @@
994
1628
  <p class="text-sm font-medium text-muted-foreground mb-1">{i18n.t('common.noData')}</p>
995
1629
  <p class="text-xs text-muted-foreground/60 mb-4">{i18n.t('common.noDataHint')}</p>
996
1630
  {#if canCreate}
997
- <Button variant="outline" size="sm" class="gap-2" onclick={() => adminContext.navigate(`/${resourceName}/create`)}>
1631
+ <Button variant="outline" size="sm" class="gap-2" onclick={() => navigation.create(resourceName)}>
998
1632
  <Plus class="h-3.5 w-3.5" />
999
1633
  {i18n.t('common.create')}
1000
1634
  </Button>
@@ -1014,16 +1648,27 @@
1014
1648
  {@const record = row.original}
1015
1649
  {@const id = record[primaryKey] as string | number}
1016
1650
  <div
1017
- class="rounded-[20px] shadow-[0_4px_20px_rgb(0,0,0,0.03)] ring-1 ring-border/20 bg-card p-5 transition-all {row_getIsSelected(row) ? 'ring-2 ring-primary/50 bg-primary/5' : ''}"
1651
+ class="rounded-[20px] shadow-[0_4px_20px_rgb(0,0,0,0.03)] ring-1 ring-border/20 bg-card p-5 transition-all {rowIsSelected(row.id) ? 'ring-2 ring-primary/50 bg-primary/5' : ''}"
1018
1652
  >
1019
1653
  <!-- Card header: ID + select + actions -->
1020
1654
  <div class="flex items-center justify-between mb-3">
1021
1655
  <div class="flex items-center gap-2">
1022
1656
  {#if selectable && (canDelete || batchActions)}
1023
- <Checkbox
1024
- checked={row_getIsSelected(row)}
1025
- onCheckedChange={() => row_toggleSelected(row)}
1026
- />
1657
+ {#if batchActions}
1658
+ <Checkbox
1659
+ aria-label={i18n.t('common.selectRow', { id })}
1660
+ checked={rowIsSelected(row.id)}
1661
+ onCheckedChange={() => toggleRowSelection(row)}
1662
+ />
1663
+ {:else}
1664
+ <CanAccess resource={resourceName} action="delete" params={{ id }}>
1665
+ <Checkbox
1666
+ aria-label={i18n.t('common.selectRow', { id })}
1667
+ checked={rowIsSelected(row.id)}
1668
+ onCheckedChange={() => toggleRowSelection(row)}
1669
+ />
1670
+ </CanAccess>
1671
+ {/if}
1027
1672
  {/if}
1028
1673
  <span class="text-xs font-mono text-muted-foreground">#{id}</span>
1029
1674
  </div>
@@ -1032,19 +1677,25 @@
1032
1677
  {@render rowActions({ record, id })}
1033
1678
  {:else}
1034
1679
  {#if canEdit}
1035
- <TooltipButton tooltip={i18n.t('common.edit')} variant="ghost" size="icon-sm" onclick={() => adminContext.navigate(`/${resourceName}/edit/${id}`)}>
1036
- <Pencil class="h-4 w-4" />
1037
- </TooltipButton>
1680
+ <CanAccess resource={resourceName} action="edit" params={{ id }}>
1681
+ <TooltipButton tooltip={i18n.t('common.edit')} variant="ghost" size="icon-sm" onclick={() => navigation.edit(resourceName, id)}>
1682
+ <Pencil class="h-4 w-4" />
1683
+ </TooltipButton>
1684
+ </CanAccess>
1038
1685
  {/if}
1039
1686
  {#if canShow}
1040
- <TooltipButton tooltip={i18n.t('common.detail')} variant="ghost" size="icon-sm" onclick={() => adminContext.navigate(`/${resourceName}/show/${id}`)}>
1041
- <Eye class="h-4 w-4" />
1042
- </TooltipButton>
1687
+ <CanAccess resource={resourceName} action="show" params={{ id }}>
1688
+ <TooltipButton tooltip={i18n.t('common.detail')} variant="ghost" size="icon-sm" onclick={() => openDetail(id)}>
1689
+ <Eye class="h-4 w-4" />
1690
+ </TooltipButton>
1691
+ </CanAccess>
1043
1692
  {/if}
1044
1693
  {#if canDelete}
1045
- <TooltipButton tooltip={i18n.t('common.delete')} variant="ghost" size="icon-sm" onclick={() => confirmDelete(id)} class="hover:text-destructive">
1046
- <Trash2 class="h-4 w-4" />
1047
- </TooltipButton>
1694
+ <CanAccess resource={resourceName} action="delete" params={{ id }}>
1695
+ <TooltipButton tooltip={i18n.t('common.delete')} variant="ghost" size="icon-sm" onclick={() => confirmDelete(id)} class="hover:text-destructive">
1696
+ <Trash2 class="h-4 w-4" />
1697
+ </TooltipButton>
1698
+ </CanAccess>
1048
1699
  {/if}
1049
1700
  {/if}
1050
1701
  </div>
@@ -1106,6 +1757,7 @@
1106
1757
  onchange={(e) => {
1107
1758
  const size = Number((e.target as HTMLSelectElement).value);
1108
1759
  if (!isNaN(size)) {
1760
+ markSavedViewDirty();
1109
1761
  pagination = { ...pagination, pageSize: size, current: 1 };
1110
1762
  }
1111
1763
  }}
@@ -1154,6 +1806,16 @@
1154
1806
  open={confirmOpen}
1155
1807
  message={confirmMessage}
1156
1808
  confirmText={i18n.t('common.delete')}
1809
+ confirming={confirmPending}
1157
1810
  onconfirm={confirmAction}
1158
1811
  oncancel={() => { confirmOpen = false; }}
1159
1812
  />
1813
+
1814
+ {#if detailRecordId != null}
1815
+ <RecordDetailDrawer
1816
+ resourceName={resourceName}
1817
+ open={detailOpen}
1818
+ recordId={detailRecordId}
1819
+ onClose={closeDetail}
1820
+ />
1821
+ {/if}