@svadmin/ui 0.57.0 → 0.59.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 (40) hide show
  1. package/dist/components/AICommandBar.svelte +34 -10
  2. package/dist/components/AdminApp.svelte +16 -5
  3. package/dist/components/AutoForm.svelte +4 -4
  4. package/dist/components/AutoTable.svelte +884 -217
  5. package/dist/components/AutoTable.svelte.d.ts +1 -1
  6. package/dist/components/CommandPalette.svelte +40 -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/EmptyState.svelte +4 -2
  14. package/dist/components/Layout.svelte +8 -1
  15. package/dist/components/ListPage.svelte +4 -12
  16. package/dist/components/ListPage.svelte.d.ts +1 -1
  17. package/dist/components/RecordDetailDrawer.svelte +121 -0
  18. package/dist/components/RecordDetailDrawer.svelte.d.ts +9 -0
  19. package/dist/components/ResourceAccessGuard.svelte +68 -0
  20. package/dist/components/ResourceAccessGuard.svelte.d.ts +12 -0
  21. package/dist/components/ResourceOperationsPage.svelte +8 -3
  22. package/dist/components/ShowPage.svelte +5 -6
  23. package/dist/components/StatsCard.svelte +5 -3
  24. package/dist/components/TaskQueueDrawer.svelte +10 -1
  25. package/dist/components/TenantSwitcher.svelte +1 -0
  26. package/dist/components/Toast.svelte +35 -1
  27. package/dist/components/UndoableNotification.svelte +12 -6
  28. package/dist/components/UndoableNotification.svelte.d.ts +2 -0
  29. package/dist/components/buttons/CloneButton.svelte +9 -2
  30. package/dist/components/buttons/CreateButton.svelte +9 -2
  31. package/dist/components/buttons/DeleteButton.svelte +9 -2
  32. package/dist/components/buttons/EditButton.svelte +9 -2
  33. package/dist/components/buttons/ListButton.svelte +1 -1
  34. package/dist/components/buttons/ShowButton.svelte +9 -2
  35. package/dist/components/saved-list-views.d.ts +35 -0
  36. package/dist/components/saved-list-views.js +168 -0
  37. package/dist/components/ui/input/input.svelte +2 -2
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.js +2 -0
  40. package/package.json +2 -2
@@ -1,12 +1,11 @@
1
1
  <script lang="ts">
2
- import { captureAdminContext, useShow, getResource } from '@svadmin/core';
2
+ import { useNavigation, useShow, getResource } from '@svadmin/core';
3
3
  import { useTranslation } from '@svadmin/core/i18n';
4
4
  import type { Snippet } from 'svelte';
5
5
  import * as Card from './ui/card/index.js';
6
6
  import { Skeleton } from './ui/skeleton/index.js';
7
7
  import PageHeader from './PageHeader.svelte';
8
8
  import { getDisplayComponent } from './fieldComponentMap.js';
9
- import ListButton from './buttons/ListButton.svelte';
10
9
  import EditButton from './buttons/EditButton.svelte';
11
10
  import DeleteButton from './buttons/DeleteButton.svelte';
12
11
  import CloneButton from './buttons/CloneButton.svelte';
@@ -38,7 +37,7 @@
38
37
  class: className = '',
39
38
  }: Props = $props();
40
39
 
41
- const adminContext = captureAdminContext();
40
+ const navigation = useNavigation();
42
41
  const isCompact = $derived(density === 'compact');
43
42
 
44
43
  const resource = $derived(getResource(resourceName));
@@ -60,10 +59,10 @@
60
59
  <PageHeader
61
60
  title="{resource.label} {i18n.t('common.detail')} #{id}"
62
61
  {density}
63
- onBack={() => adminContext.navigate(`/${resourceName}`)}
62
+ onBack={() => navigation.list(resourceName)}
63
+ backLabel={i18n.t('common.backToList')}
64
64
  >
65
65
  {#snippet actions()}
66
- <ListButton resource={resourceName} hideText />
67
66
  {#if resource.canEdit !== false}
68
67
  <EditButton resource={resourceName} recordItemId={id} hideText />
69
68
  {/if}
@@ -71,7 +70,7 @@
71
70
  <CloneButton resource={resourceName} recordItemId={id} hideText />
72
71
  {/if}
73
72
  {#if resource.canDelete !== false}
74
- <DeleteButton resource={resourceName} recordItemId={id} hideText onSuccess={() => adminContext.navigate(`/${resourceName}`)} />
73
+ <DeleteButton resource={resourceName} recordItemId={id} hideText onSuccess={() => navigation.list(resourceName)} />
75
74
  {/if}
76
75
  <RefreshButton resource={resourceName} hideText />
77
76
  {#if headerActions}
@@ -60,11 +60,13 @@
60
60
  <p class="text-xl font-mono font-semibold text-foreground tracking-tight">{value}</p>
61
61
  {#if trend}
62
62
  <span
63
- class="text-xs font-medium {trend.value >= 0 ? 'text-success' : 'text-destructive'}"
63
+ class="text-xs font-medium inline-flex items-center gap-0.5 {trend.value >= 0 ? 'text-success' : 'text-destructive'}"
64
+ aria-label="{trend.value >= 0 ? '上升' : '下降'} {Math.abs(trend.value)}%"
64
65
  >
65
- {trend.value >= 0 ? '↑' : '↓'}{Math.abs(trend.value)}%
66
+ <span aria-hidden="true">{trend.value >= 0 ? '↑' : '↓'}</span>
67
+ <span>{Math.abs(trend.value)}%</span>
66
68
  {#if trend.label}
67
- <span class="text-muted-foreground">{trend.label}</span>
69
+ <span class="text-muted-foreground ml-0.5">{trend.label}</span>
68
70
  {/if}
69
71
  </span>
70
72
  {/if}
@@ -227,10 +227,19 @@
227
227
  {/if}
228
228
  </Button>
229
229
 
230
- <Sheet.Root bind:open side="right" class="w-full max-w-6xl border-l border-border bg-card p-0" onClose={() => open = false}>
230
+ <Sheet.Root
231
+ bind:open
232
+ side="right"
233
+ class="w-full max-w-6xl border-l border-border bg-card p-0"
234
+ role="dialog"
235
+ aria-modal="true"
236
+ aria-labelledby="svadmin-task-queue-title"
237
+ onClose={() => open = false}
238
+ >
231
239
  <Sheet.Content class="h-full gap-0 p-0">
232
240
  <div class="flex h-full flex-col">
233
241
  <div class="border-b border-border bg-muted/30 px-4 py-3">
242
+ <Sheet.Title id="svadmin-task-queue-title" class="sr-only">{resolvedTitle}</Sheet.Title>
234
243
  <div class="flex flex-wrap items-center gap-2">
235
244
  <ListTodo class="h-5 w-5 text-muted-foreground" />
236
245
  <h2 class="text-sm font-semibold">{resolvedTitle}</h2>
@@ -31,6 +31,7 @@
31
31
  <DropdownMenu.Trigger>
32
32
  {#snippet child({ props }: { props: Record<string, unknown> })}
33
33
  <button
34
+ type="button"
34
35
  {...props}
35
36
  class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-sidebar-accent/50
36
37
  {collapsed ? 'justify-center' : ''}"
@@ -1,8 +1,17 @@
1
1
  <script lang="ts">
2
2
  import { onMount } from 'svelte';
3
3
  import { Toaster, toast as sonner, type ToasterProps } from 'svelte-sonner';
4
- import { getResolvedTheme, getToastQueue, consumeToastQueue, getPromiseQueue, consumePromiseQueue } from '@svadmin/core';
4
+ import {
5
+ consumePromiseQueue,
6
+ consumeToastQueue,
7
+ getPromiseQueue,
8
+ getResolvedTheme,
9
+ getToastQueue,
10
+ getToasts,
11
+ removeToast,
12
+ } from '@svadmin/core';
5
13
  import { registerToastHost, type ToastHostRegistration } from './toast-host.svelte.js';
14
+ import UndoableNotification from './UndoableNotification.svelte';
6
15
 
7
16
  let host = $state<ToastHostRegistration | null>(null);
8
17
  const isActiveHost = $derived(host?.isActive() ?? false);
@@ -56,8 +65,33 @@
56
65
  },
57
66
  }
58
67
  } satisfies ToasterProps);
68
+
69
+ function undo(id: number, callback?: () => void): void {
70
+ removeToast(id);
71
+ callback?.();
72
+ }
73
+
74
+ function commit(id: number, callback?: () => void): void {
75
+ removeToast(id);
76
+ callback?.();
77
+ }
59
78
  </script>
60
79
 
61
80
  {#if isActiveHost}
62
81
  <Toaster {...toasterProps} />
82
+ <div
83
+ class="pointer-events-none fixed bottom-4 left-4 right-4 z-[100] flex flex-col-reverse gap-2 sm:bottom-6 sm:left-1/2 sm:right-auto sm:w-[min(32rem,calc(100vw-3rem))] sm:-translate-x-1/2"
84
+ data-svadmin-undo-stack
85
+ >
86
+ {#each getToasts().slice(-3) as item (item.id)}
87
+ <UndoableNotification
88
+ message={item.message}
89
+ duration={item.duration}
90
+ embedded
91
+ managedExternally
92
+ onUndo={() => undo(item.id, item.onUndo)}
93
+ onTimeout={() => commit(item.id, item.onTimeout)}
94
+ />
95
+ {/each}
96
+ </div>
63
97
  {/if}
@@ -8,9 +8,11 @@
8
8
 
9
9
  const i18n = useTranslation();
10
10
 
11
- let { message, duration = 5000, onUndo, onTimeout } = $props<{
11
+ let { message, duration = 5000, embedded = false, managedExternally = false, onUndo, onTimeout } = $props<{
12
12
  message: string;
13
13
  duration?: number;
14
+ embedded?: boolean;
15
+ managedExternally?: boolean;
14
16
  onUndo: () => void;
15
17
  onTimeout: () => void;
16
18
  }>();
@@ -27,7 +29,7 @@
27
29
  if (remaining <= 0) {
28
30
  remaining = 0;
29
31
  clearInterval(interval);
30
- if (!dismissed) {
32
+ if (!dismissed && !managedExternally) {
31
33
  dismissed = true;
32
34
  onTimeout();
33
35
  }
@@ -51,17 +53,21 @@
51
53
 
52
54
  {#if !dismissed}
53
55
  <div
54
- class="fixed bottom-6 left-1/2 -translate-x-1/2 z-[100] min-w-80 max-w-lg rounded-lg border bg-card shadow-lg overflow-hidden"
56
+ class={embedded
57
+ ? 'pointer-events-auto relative w-full overflow-hidden rounded-lg border bg-card shadow-lg'
58
+ : 'fixed bottom-4 left-4 right-4 z-[100] overflow-hidden rounded-lg border bg-card shadow-lg sm:bottom-6 sm:left-1/2 sm:right-auto sm:w-[min(32rem,calc(100vw-3rem))] sm:-translate-x-1/2'}
59
+ role="status"
60
+ aria-live="polite"
55
61
  transition:slide={{ duration: 250 }}
56
62
  >
57
- <div class="flex items-center justify-between px-4 py-3 gap-3">
58
- <p class="text-sm text-foreground">{message}</p>
63
+ <div class="flex items-center justify-between gap-3 px-4 py-3">
64
+ <p class="min-w-0 break-words text-sm text-foreground">{message}</p>
59
65
  <div class="flex items-center gap-1 flex-shrink-0">
60
66
  <Button variant="ghost" size="sm" onclick={handleUndo} class="font-semibold text-primary">
61
67
  <Undo2 class="h-3.5 w-3.5 mr-1" />
62
68
  {i18n.t('common.undo')}
63
69
  </Button>
64
- <Button variant="ghost" size="icon" class="h-7 w-7" onclick={handleDismiss}>
70
+ <Button variant="ghost" size="icon" class="h-7 w-7" onclick={handleDismiss} aria-label={i18n.t('common.close')}>
65
71
  <X class="h-3.5 w-3.5" />
66
72
  </Button>
67
73
  </div>
@@ -1,6 +1,8 @@
1
1
  type $$ComponentProps = {
2
2
  message: string;
3
3
  duration?: number;
4
+ embedded?: boolean;
5
+ managedExternally?: boolean;
4
6
  onUndo: () => void;
5
7
  onTimeout: () => void;
6
8
  };
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
- import { useNavigation, useCan, useTranslation } from '@svadmin/core';
3
+ import { getResource, useNavigation, useCan, useTranslation } from '@svadmin/core';
4
4
  import { Button } from '../ui/button/index.js';
5
5
  import { Copy } from '@lucide/svelte';
6
6
  import type { ButtonAccessControl } from './access-control';
@@ -27,6 +27,13 @@
27
27
  }>();
28
28
 
29
29
  const nav = useNavigation();
30
+ const resourceDefinition = $derived.by(() => {
31
+ try {
32
+ return getResource(resource);
33
+ } catch {
34
+ return null;
35
+ }
36
+ });
30
37
  const can = useCan(() => ({
31
38
  resource,
32
39
  action: 'create',
@@ -34,8 +41,8 @@
34
41
  meta: accessControl?.meta,
35
42
  queryOptions: { enabled: accessControl?.enabled ?? true }
36
43
  }));
37
- const hidden = $derived(accessControl?.hideIfUnauthorized && !can.allowed);
38
44
  const displayText = $derived(label ?? i18n.t('common.clone'));
45
+ const hidden = $derived(resourceDefinition?.canCreate === false || (accessControl?.hideIfUnauthorized && !can.allowed));
39
46
  </script>
40
47
 
41
48
  {#if !hidden}
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
- import { useNavigation, useCan, useTranslation } from '@svadmin/core';
3
+ import { getResource, useNavigation, useCan, useTranslation } from '@svadmin/core';
4
4
  import { Button } from '../ui/button/index.js';
5
5
  import { Plus } from '@lucide/svelte';
6
6
  import type { ButtonAccessControl } from './access-control';
@@ -24,6 +24,13 @@
24
24
  }>();
25
25
 
26
26
  const nav = useNavigation();
27
+ const resourceDefinition = $derived.by(() => {
28
+ try {
29
+ return getResource(resource);
30
+ } catch {
31
+ return null;
32
+ }
33
+ });
27
34
  const can = useCan(() => ({
28
35
  resource,
29
36
  action: 'create',
@@ -31,8 +38,8 @@
31
38
  meta: accessControl?.meta,
32
39
  queryOptions: { enabled: accessControl?.enabled ?? true }
33
40
  }));
34
- const hidden = $derived(accessControl?.hideIfUnauthorized && !can.allowed);
35
41
  const displayText = $derived(label ?? i18n.t('common.create'));
42
+ const hidden = $derived(resourceDefinition?.canCreate === false || (accessControl?.hideIfUnauthorized && !can.allowed));
36
43
  </script>
37
44
 
38
45
  {#if !hidden}
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
- import { useDelete, useCan, useTranslation } from '@svadmin/core';
3
+ import { getResource, useDelete, useCan, useTranslation } from '@svadmin/core';
4
4
  import { Button } from '../ui/button/index.js';
5
5
  import { Trash2 } from '@lucide/svelte';
6
6
  import type { ButtonAccessControl } from './access-control';
@@ -31,6 +31,13 @@
31
31
  }>();
32
32
 
33
33
  const deleteMut = useDelete({ get resource() { return resource; }, get mutationMode() { return undoable ? 'undoable' as const : 'pessimistic' as const; } });
34
+ const resourceDefinition = $derived.by(() => {
35
+ try {
36
+ return getResource(resource);
37
+ } catch {
38
+ return null;
39
+ }
40
+ });
34
41
  const can = useCan(() => ({
35
42
  resource,
36
43
  action: 'delete',
@@ -38,8 +45,8 @@
38
45
  meta: accessControl?.meta,
39
46
  queryOptions: { enabled: accessControl?.enabled ?? true }
40
47
  }));
41
- const hidden = $derived(accessControl?.hideIfUnauthorized && !can.allowed);
42
48
  const displayText = $derived(label ?? i18n.t('common.delete'));
49
+ const hidden = $derived(resourceDefinition?.canDelete === false || (accessControl?.hideIfUnauthorized && !can.allowed));
43
50
  let confirming = $state(false);
44
51
 
45
52
  async function handleDelete() {
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
- import { useNavigation, useCan, useTranslation } from '@svadmin/core';
3
+ import { getResource, useNavigation, useCan, useTranslation } from '@svadmin/core';
4
4
  import { Button } from '../ui/button/index.js';
5
5
  import { Pencil } from '@lucide/svelte';
6
6
  import type { ButtonAccessControl } from './access-control';
@@ -27,6 +27,13 @@
27
27
  }>();
28
28
 
29
29
  const nav = useNavigation();
30
+ const resourceDefinition = $derived.by(() => {
31
+ try {
32
+ return getResource(resource);
33
+ } catch {
34
+ return null;
35
+ }
36
+ });
30
37
  const can = useCan(() => ({
31
38
  resource,
32
39
  action: 'edit',
@@ -34,8 +41,8 @@
34
41
  meta: accessControl?.meta,
35
42
  queryOptions: { enabled: accessControl?.enabled ?? true }
36
43
  }));
37
- const hidden = $derived(accessControl?.hideIfUnauthorized && !can.allowed);
38
44
  const displayText = $derived(label ?? i18n.t('common.edit'));
45
+ const hidden = $derived(resourceDefinition?.canEdit === false || (accessControl?.hideIfUnauthorized && !can.allowed));
39
46
  </script>
40
47
 
41
48
  {#if !hidden}
@@ -19,7 +19,7 @@
19
19
  const res = $derived((() => {
20
20
  try { return getResource(resourceName); } catch { return null; }
21
21
  })());
22
- const displayLabel = $derived(label ?? res?.label ?? i18n.t('common.list') ?? resourceName);
22
+ const displayLabel = $derived(label ?? (hideText ? i18n.t('common.backToList') : (res?.label ?? i18n.t('common.list') ?? resourceName)));
23
23
 
24
24
  function navigateToList() {
25
25
  const navigate = () => nav.list(resourceName);
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
- import { useNavigation, useCan, useTranslation } from '@svadmin/core';
3
+ import { getResource, useNavigation, useCan, useTranslation } from '@svadmin/core';
4
4
  import { Button } from '../ui/button/index.js';
5
5
  import { Eye } from '@lucide/svelte';
6
6
  import type { ButtonAccessControl } from './access-control';
@@ -29,6 +29,13 @@
29
29
  }>();
30
30
 
31
31
  const nav = useNavigation();
32
+ const resourceDefinition = $derived.by(() => {
33
+ try {
34
+ return getResource(resource);
35
+ } catch {
36
+ return null;
37
+ }
38
+ });
32
39
  const can = useCan(() => ({
33
40
  resource,
34
41
  action: 'show',
@@ -36,7 +43,7 @@
36
43
  meta: accessControl?.meta,
37
44
  queryOptions: { enabled: accessControl?.enabled ?? true }
38
45
  }));
39
- const hidden = $derived(accessControl?.hideIfUnauthorized && !can.allowed);
46
+ const hidden = $derived(resourceDefinition?.canShow === false || (accessControl?.hideIfUnauthorized && !can.allowed));
40
47
  const displayText = $derived(label ?? i18n.t('common.detail'));
41
48
 
42
49
  function navigateToRecord() {
@@ -0,0 +1,35 @@
1
+ import type { Filter, Sort } from '@svadmin/core';
2
+ export interface SavedListViewState {
3
+ search: string;
4
+ filters: Filter[];
5
+ sorters: Sort[];
6
+ pagination: {
7
+ current: number;
8
+ pageSize: number;
9
+ };
10
+ columnVisibility: Record<string, boolean>;
11
+ columnOrder: string[];
12
+ }
13
+ export interface SavedListView {
14
+ id: string;
15
+ name: string;
16
+ state: SavedListViewState;
17
+ }
18
+ export interface ListPreferenceScope {
19
+ resourceName: string;
20
+ providerName: string;
21
+ tenantIdentity?: string | number;
22
+ }
23
+ export declare function readSavedListViews(raw: string | null, allowedColumnIds: Set<string>): SavedListView[];
24
+ export declare function serializeSavedListViews(views: SavedListView[]): string;
25
+ export declare function cloneSavedListViewState(state: SavedListViewState): SavedListViewState;
26
+ export declare function listPreferenceScopeId(scope: ListPreferenceScope): string;
27
+ export declare function canMigrateLegacyListPreferences(scope: ListPreferenceScope): boolean;
28
+ export declare function savedListViewsStorageKey(scope: ListPreferenceScope): string;
29
+ export declare function activeSavedListViewStorageKey(scope: ListPreferenceScope): string;
30
+ export declare function columnVisibilityStorageKey(scope: ListPreferenceScope): string;
31
+ export declare function columnOrderStorageKey(scope: ListPreferenceScope): string;
32
+ export declare function legacySavedListViewsStorageKey(resourceName: string): string;
33
+ export declare function legacyActiveSavedListViewStorageKey(resourceName: string): string;
34
+ export declare function legacyColumnVisibilityStorageKey(resourceName: string): string;
35
+ export declare function legacyColumnOrderStorageKey(resourceName: string): string;
@@ -0,0 +1,168 @@
1
+ const STORAGE_VERSION = 1;
2
+ const MAX_SAVED_VIEWS = 25;
3
+ const MAX_FILTER_NODES = 200;
4
+ const CRUD_OPERATORS = new Set([
5
+ 'eq', 'ne', 'lt', 'gt', 'lte', 'gte',
6
+ 'contains', 'ncontains', 'startswith', 'endswith',
7
+ 'in', 'nin', 'null', 'nnull', 'between', 'nbetween',
8
+ ]);
9
+ function isRecord(value) {
10
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
11
+ }
12
+ function isPositiveInteger(value, max) {
13
+ return typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= max;
14
+ }
15
+ function parseFilter(value, allowedFieldIds, budget, depth = 0) {
16
+ if (depth > 20 || budget.remaining-- <= 0 || !isRecord(value))
17
+ return undefined;
18
+ if (typeof value.field === 'string') {
19
+ if (!allowedFieldIds.has(value.field) || typeof value.operator !== 'string' || !CRUD_OPERATORS.has(value.operator) || !('value' in value)) {
20
+ return undefined;
21
+ }
22
+ return {
23
+ field: value.field,
24
+ operator: value.operator,
25
+ value: value.value,
26
+ };
27
+ }
28
+ if ((value.operator !== 'and' && value.operator !== 'or') || !Array.isArray(value.value))
29
+ return undefined;
30
+ const children = value.value.map((entry) => parseFilter(entry, allowedFieldIds, budget, depth + 1));
31
+ if (children.some((entry) => entry === undefined))
32
+ return undefined;
33
+ return { operator: value.operator, value: children };
34
+ }
35
+ function parseSorters(value, allowedFieldIds) {
36
+ const sorters = [];
37
+ for (const sorter of value) {
38
+ if (!isRecord(sorter) || typeof sorter.field !== 'string' || !allowedFieldIds.has(sorter.field))
39
+ return undefined;
40
+ if (sorter.order !== 'asc' && sorter.order !== 'desc')
41
+ return undefined;
42
+ sorters.push({ field: sorter.field, order: sorter.order });
43
+ }
44
+ return sorters;
45
+ }
46
+ function parseColumnVisibility(value, allowedColumnIds) {
47
+ if (!isRecord(value))
48
+ return undefined;
49
+ const visibility = {};
50
+ for (const [columnId, visible] of Object.entries(value)) {
51
+ if (!allowedColumnIds.has(columnId) || typeof visible !== 'boolean')
52
+ continue;
53
+ visibility[columnId] = visible;
54
+ }
55
+ return visibility;
56
+ }
57
+ function parseColumnOrder(value, allowedColumnIds) {
58
+ if (!Array.isArray(value))
59
+ return undefined;
60
+ return [...new Set(value.filter((columnId) => (typeof columnId === 'string' && allowedColumnIds.has(columnId))))];
61
+ }
62
+ function parseState(value, allowedColumnIds) {
63
+ if (!isRecord(value))
64
+ return undefined;
65
+ if (typeof value.search !== 'string' || value.search.length > 1_000)
66
+ return undefined;
67
+ if (!Array.isArray(value.filters) || !Array.isArray(value.sorters))
68
+ return undefined;
69
+ if (!isRecord(value.pagination) || !isPositiveInteger(value.pagination.current, 1_000_000) || !isPositiveInteger(value.pagination.pageSize, 1_000)) {
70
+ return undefined;
71
+ }
72
+ const allowedFieldIds = new Set([...allowedColumnIds].filter((columnId) => !columnId.startsWith('_')));
73
+ const filterBudget = { remaining: MAX_FILTER_NODES };
74
+ const filters = value.filters.map((entry) => parseFilter(entry, allowedFieldIds, filterBudget));
75
+ if (filters.some((entry) => entry === undefined))
76
+ return undefined;
77
+ const sorters = parseSorters(value.sorters, allowedFieldIds);
78
+ const columnVisibility = parseColumnVisibility(value.columnVisibility, allowedColumnIds);
79
+ const columnOrder = parseColumnOrder(value.columnOrder, allowedColumnIds);
80
+ if (!sorters || !columnVisibility || !columnOrder)
81
+ return undefined;
82
+ return {
83
+ search: value.search,
84
+ filters: filters,
85
+ sorters,
86
+ pagination: {
87
+ current: value.pagination.current,
88
+ pageSize: value.pagination.pageSize,
89
+ },
90
+ columnVisibility,
91
+ columnOrder,
92
+ };
93
+ }
94
+ export function readSavedListViews(raw, allowedColumnIds) {
95
+ if (!raw)
96
+ return [];
97
+ try {
98
+ const parsed = JSON.parse(raw);
99
+ if (!isRecord(parsed) || parsed.version !== STORAGE_VERSION || !Array.isArray(parsed.views))
100
+ return [];
101
+ const views = [];
102
+ const seenIds = new Set();
103
+ for (const entry of parsed.views.slice(0, MAX_SAVED_VIEWS)) {
104
+ if (!isRecord(entry) || typeof entry.id !== 'string' || !entry.id.trim() || entry.id.length > 120)
105
+ continue;
106
+ if (seenIds.has(entry.id))
107
+ continue;
108
+ if (typeof entry.name !== 'string' || !entry.name.trim() || entry.name.length > 60)
109
+ continue;
110
+ const state = parseState(entry.state, allowedColumnIds);
111
+ if (!state)
112
+ continue;
113
+ seenIds.add(entry.id);
114
+ views.push({ id: entry.id, name: entry.name.trim(), state });
115
+ }
116
+ return views;
117
+ }
118
+ catch (error) {
119
+ if (error instanceof SyntaxError)
120
+ return [];
121
+ throw error;
122
+ }
123
+ }
124
+ export function serializeSavedListViews(views) {
125
+ return JSON.stringify({ version: STORAGE_VERSION, views: views.slice(0, MAX_SAVED_VIEWS) });
126
+ }
127
+ export function cloneSavedListViewState(state) {
128
+ return JSON.parse(JSON.stringify(state));
129
+ }
130
+ function encodeScopeValue(value) {
131
+ if (value === undefined)
132
+ return 'u';
133
+ return `${typeof value === 'number' ? 'n' : 's'}:${encodeURIComponent(String(value))}`;
134
+ }
135
+ export function listPreferenceScopeId(scope) {
136
+ return [
137
+ `r:${encodeURIComponent(scope.resourceName)}`,
138
+ `p:${encodeURIComponent(scope.providerName)}`,
139
+ `t:${encodeScopeValue(scope.tenantIdentity)}`,
140
+ ].join('|');
141
+ }
142
+ export function canMigrateLegacyListPreferences(scope) {
143
+ return scope.providerName === 'default' && scope.tenantIdentity === undefined;
144
+ }
145
+ export function savedListViewsStorageKey(scope) {
146
+ return `svadmin-list-views-v2-${listPreferenceScopeId(scope)}`;
147
+ }
148
+ export function activeSavedListViewStorageKey(scope) {
149
+ return `svadmin-list-view-active-v2-${listPreferenceScopeId(scope)}`;
150
+ }
151
+ export function columnVisibilityStorageKey(scope) {
152
+ return `svadmin-columns-v2-${listPreferenceScopeId(scope)}`;
153
+ }
154
+ export function columnOrderStorageKey(scope) {
155
+ return `svadmin-colorder-v2-${listPreferenceScopeId(scope)}`;
156
+ }
157
+ export function legacySavedListViewsStorageKey(resourceName) {
158
+ return `svadmin-list-views-${resourceName}`;
159
+ }
160
+ export function legacyActiveSavedListViewStorageKey(resourceName) {
161
+ return `svadmin-list-view-active-${resourceName}`;
162
+ }
163
+ export function legacyColumnVisibilityStorageKey(resourceName) {
164
+ return `svadmin-columns-${resourceName}`;
165
+ }
166
+ export function legacyColumnOrderStorageKey(resourceName) {
167
+ return `svadmin-colorder-${resourceName}`;
168
+ }
@@ -32,7 +32,7 @@
32
32
  bind:this={ref}
33
33
  data-slot={dataSlot}
34
34
  class={cn(
35
- "dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-9 rounded-lg border bg-transparent px-3 py-1.5 text-base transition-colors file:h-7 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
35
+ "dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-9 rounded-lg border bg-transparent p-1 pr-3 text-xs md:text-sm text-muted-foreground transition-colors file:mr-3 file:inline-flex file:h-7 file:cursor-pointer file:items-center file:rounded-md file:border file:border-border/60 file:bg-muted file:px-2.5 file:text-xs file:font-medium file:text-foreground file:transition-colors hover:file:bg-muted/80 focus-visible:ring-3 aria-invalid:ring-3 placeholder:text-muted-foreground w-full min-w-0 outline-none cursor-pointer disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
36
36
  className
37
37
  )}
38
38
  type="file"
@@ -44,7 +44,7 @@
44
44
  bind:this={ref}
45
45
  data-slot={dataSlot}
46
46
  class={cn(
47
- "dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-9 rounded-lg border bg-transparent px-3 py-1.5 text-base transition-colors file:h-7 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
47
+ "dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-9 rounded-lg border bg-transparent px-3 py-1.5 text-base transition-colors focus-visible:ring-3 aria-invalid:ring-3 md:text-sm placeholder:text-muted-foreground w-full min-w-0 outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
48
48
  className
49
49
  )}
50
50
  {type}
package/dist/index.d.ts CHANGED
@@ -15,6 +15,8 @@ export { default as EmptyState } from './components/EmptyState.svelte';
15
15
  export { default as StatsCard } from './components/StatsCard.svelte';
16
16
  export { default as PageHeader } from './components/PageHeader.svelte';
17
17
  export { default as ResourceOperationsPage } from './components/ResourceOperationsPage.svelte';
18
+ export { default as RecordDetailDrawer } from './components/RecordDetailDrawer.svelte';
19
+ export { default as ResourceAccessGuard } from './components/ResourceAccessGuard.svelte';
18
20
  export { default as LoginPage } from './components/LoginPage.svelte';
19
21
  export { default as AuthPageShell } from './components/AuthPageShell.svelte';
20
22
  export { default as RegisterPage } from './components/RegisterPage.svelte';
package/dist/index.js CHANGED
@@ -18,6 +18,8 @@ export { default as EmptyState } from './components/EmptyState.svelte';
18
18
  export { default as StatsCard } from './components/StatsCard.svelte';
19
19
  export { default as PageHeader } from './components/PageHeader.svelte';
20
20
  export { default as ResourceOperationsPage } from './components/ResourceOperationsPage.svelte';
21
+ export { default as RecordDetailDrawer } from './components/RecordDetailDrawer.svelte';
22
+ export { default as ResourceAccessGuard } from './components/ResourceAccessGuard.svelte';
21
23
  export { default as LoginPage } from './components/LoginPage.svelte';
22
24
  export { default as AuthPageShell } from './components/AuthPageShell.svelte';
23
25
  export { default as RegisterPage } from './components/RegisterPage.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.57.0",
3
+ "version": "0.59.0",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -39,7 +39,7 @@
39
39
  "./components/*": "./dist/components/*"
40
40
  },
41
41
  "peerDependencies": {
42
- "@svadmin/core": "^0.41.1",
42
+ "@svadmin/core": "^0.42.0",
43
43
  "@tanstack/svelte-query": "^6.1.38",
44
44
  "highlight.js": "^11.11.1",
45
45
  "isomorphic-dompurify": "^3.22.0",