@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
@@ -42,7 +42,7 @@ interface Props {
42
42
  sorters?: Sort[];
43
43
  /** Custom batch actions to render when rows are selected */
44
44
  batchActions?: Snippet<[{
45
- selectedIds: string[];
45
+ selectedIds: (string | number)[];
46
46
  }]>;
47
47
  }
48
48
  declare const AutoTable: import("svelte").Component<Props, {}, "">;
@@ -1,9 +1,10 @@
1
1
  <script lang="ts">
2
- import { captureAdminContext, getResources, toggleTheme } from '@svadmin/core';
2
+ import { captureAdminContext, getResources, toggleTheme, useNavigation } from '@svadmin/core';
3
3
  import { useTranslation } from '@svadmin/core/i18n';
4
4
  import { Command } from './ui/command/index.js';
5
5
  import * as Dialog from './ui/dialog/index.js';
6
6
  import { Search, LayoutDashboard, Plus, Sun, FileText, Sparkles } from '@lucide/svelte';
7
+ import CanAccess from './CanAccess.svelte';
7
8
 
8
9
  const i18n = useTranslation();
9
10
 
@@ -15,6 +16,7 @@
15
16
 
16
17
  let { open = $bindable(false), onAskAI }: Props = $props();
17
18
  const adminContext = captureAdminContext();
19
+ const navigation = useNavigation();
18
20
  let searchValue = $state('');
19
21
 
20
22
  const resources = $derived(getResources());
@@ -43,6 +45,7 @@
43
45
 
44
46
  <Dialog.Dialog bind:open>
45
47
  <Dialog.DialogContent class="overflow-hidden p-0 sm:max-w-[560px]">
48
+ <Dialog.DialogTitle class="sr-only">{i18n.t('common.search')}</Dialog.DialogTitle>
46
49
  <Command.Root class="flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground [&_[data-command-group-heading]]:text-muted-foreground [&_[data-command-group-heading]]:px-2 [&_[data-command-group-heading]]:py-1.5 [&_[data-command-group-heading]]:text-xs [&_[data-command-group-heading]]:font-semibold [&_[data-command-group-heading]]:uppercase [&_[data-command-group-heading]]:tracking-wider">
47
50
  <div class="flex items-center border-b px-3" data-cmdk-input-wrapper="">
48
51
  <Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
@@ -95,14 +98,27 @@
95
98
  {i18n.t('common.home')}
96
99
  </Command.Item>
97
100
  {#each resources as r, _i (_i)}
98
- <Command.Item
99
- value={r.name}
100
- onSelect={() => act(() => adminContext.navigate(`/${r.name}`))}
101
- class={itemClass}
102
- >
103
- <FileText class="h-4 w-4 text-muted-foreground" />
104
- {r.label}
105
- </Command.Item>
101
+ {#if adminContext.accessControlProvider}
102
+ <CanAccess resource={r.name} action="list">
103
+ <Command.Item
104
+ value={r.name}
105
+ onSelect={() => act(() => navigation.list(r.name))}
106
+ class={itemClass}
107
+ >
108
+ <FileText class="h-4 w-4 text-muted-foreground" />
109
+ {r.label}
110
+ </Command.Item>
111
+ </CanAccess>
112
+ {:else}
113
+ <Command.Item
114
+ value={r.name}
115
+ onSelect={() => act(() => navigation.list(r.name))}
116
+ class={itemClass}
117
+ >
118
+ <FileText class="h-4 w-4 text-muted-foreground" />
119
+ {r.label}
120
+ </Command.Item>
121
+ {/if}
106
122
  {/each}
107
123
  </Command.Group>
108
124
 
@@ -111,14 +127,21 @@
111
127
  <!-- Actions -->
112
128
  <Command.Group heading={i18n.t('common.actions')}>
113
129
  {#each resources as r, _i (_i)}
114
- <Command.Item
115
- value={"create-" + r.name}
116
- onSelect={() => act(() => adminContext.navigate(`/${r.name}/create`))}
117
- class={itemClass}
118
- >
119
- <Plus class="h-4 w-4 text-muted-foreground" />
120
- {i18n.t('common.create')} {r.label}
121
- </Command.Item>
130
+ {#if r.canCreate !== false}
131
+ {#if adminContext.accessControlProvider}
132
+ <CanAccess resource={r.name} action="create">
133
+ <Command.Item value={"create-" + r.name} onSelect={() => act(() => navigation.create(r.name))} class={itemClass}>
134
+ <Plus class="h-4 w-4 text-muted-foreground" />
135
+ {i18n.t('common.create')} {r.label}
136
+ </Command.Item>
137
+ </CanAccess>
138
+ {:else}
139
+ <Command.Item value={"create-" + r.name} onSelect={() => act(() => navigation.create(r.name))} class={itemClass}>
140
+ <Plus class="h-4 w-4 text-muted-foreground" />
141
+ {i18n.t('common.create')} {r.label}
142
+ </Command.Item>
143
+ {/if}
144
+ {/if}
122
145
  {/each}
123
146
  <Command.Item
124
147
  value="toggle-theme"
@@ -7,13 +7,14 @@
7
7
 
8
8
  let { open = $bindable(false), title, message = '',
9
9
  confirmText, cancelText, variant = 'danger',
10
- onconfirm, oncancel } = $props<{
10
+ confirming = false, onconfirm, oncancel } = $props<{
11
11
  open?: boolean;
12
12
  title?: string;
13
13
  message?: string;
14
14
  confirmText?: string;
15
15
  cancelText?: string;
16
16
  variant?: 'danger' | 'warning' | 'info';
17
+ confirming?: boolean;
17
18
  onconfirm: () => void;
18
19
  oncancel: () => void;
19
20
  }>();
@@ -52,14 +53,14 @@
52
53
  <AlertDialog.Footer>
53
54
  <AlertDialog.Cancel>
54
55
  {#snippet child({ props })}
55
- <Button variant="outline" {...props}>
56
+ <Button variant="outline" {...props} disabled={confirming}>
56
57
  {resolvedCancelText}
57
58
  </Button>
58
59
  {/snippet}
59
60
  </AlertDialog.Cancel>
60
61
  <AlertDialog.Action>
61
62
  {#snippet child({ props })}
62
- <Button variant={variantMap[variant as keyof typeof variantMap]} {...props} onclick={handleConfirm}>
63
+ <Button variant={variantMap[variant as keyof typeof variantMap]} {...props} disabled={confirming} onclick={handleConfirm}>
63
64
  {resolvedConfirmText}
64
65
  </Button>
65
66
  {/snippet}
@@ -5,6 +5,7 @@ type $$ComponentProps = {
5
5
  confirmText?: string;
6
6
  cancelText?: string;
7
7
  variant?: 'danger' | 'warning' | 'info';
8
+ confirming?: boolean;
8
9
  onconfirm: () => void;
9
10
  oncancel: () => void;
10
11
  };
@@ -181,16 +181,16 @@ Keep it concise, professional, and use bullet points.`;
181
181
  </h4>
182
182
  <div class="space-y-2">
183
183
  {#if ctx.currentView === 'list'}
184
- <button class="w-full flex items-center gap-2 text-left p-2 rounded-lg hover:bg-muted border border-transparent hover:border-border transition-colors text-sm">
184
+ <button type="button" class="w-full flex items-center gap-2 text-left p-2 rounded-lg hover:bg-muted border border-transparent hover:border-border transition-colors text-sm cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
185
185
  <div class="bg-background rounded shadow-sm p-1"><Check class="h-3.5 w-3.5 text-muted-foreground" /></div>
186
186
  <span>Generate monthly report</span>
187
187
  </button>
188
- <button class="w-full flex items-center gap-2 text-left p-2 rounded-lg hover:bg-muted border border-transparent hover:border-border transition-colors text-sm">
188
+ <button type="button" class="w-full flex items-center gap-2 text-left p-2 rounded-lg hover:bg-muted border border-transparent hover:border-border transition-colors text-sm cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
189
189
  <div class="bg-background rounded shadow-sm p-1"><Check class="h-3.5 w-3.5 text-muted-foreground" /></div>
190
190
  <span>Find anomalies in {ctx.currentResource}</span>
191
191
  </button>
192
192
  {:else if ctx.currentView === 'edit' || ctx.currentView === 'create'}
193
- <button class="w-full flex items-center gap-2 text-left p-2 rounded-lg hover:bg-muted border border-transparent hover:border-border transition-colors text-sm">
193
+ <button type="button" class="w-full flex items-center gap-2 text-left p-2 rounded-lg hover:bg-muted border border-transparent hover:border-border transition-colors text-sm cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
194
194
  <div class="bg-background rounded shadow-sm p-1"><Check class="h-3.5 w-3.5 text-muted-foreground" /></div>
195
195
  <span>Auto-fill form from context</span>
196
196
  </button>
@@ -1,11 +1,10 @@
1
1
  <script lang="ts">
2
- /* eslint-disable @typescript-eslint/no-explicit-any */
3
2
  /**
4
3
  * DraggableHeader — Wraps table header cells with HTML5 Drag and Drop
5
- * to enable column reorder. Persists order to localStorage.
4
+ * to enable column reorder. The parent owns persistence and restoration.
6
5
  *
7
6
  * Usage:
8
- * <DraggableHeader columns={columns} resourceName="posts" onReorder={handleReorder}>
7
+ * <DraggableHeader columns={columns} onReorder={handleReorder}>
9
8
  * {#snippet header(column, index)}
10
9
  * <th>...</th>
11
10
  * {/snippet}
@@ -18,40 +17,28 @@
18
17
  [key: string]: unknown;
19
18
  }
20
19
 
20
+ interface DragHeaderProps {
21
+ draggable: true;
22
+ ondragstart: (event: DragEvent) => void;
23
+ ondragover: (event: DragEvent) => void;
24
+ ondrop: (event: DragEvent) => void;
25
+ ondragend: () => void;
26
+ 'data-dragging': boolean;
27
+ 'data-drop-target': boolean;
28
+ class: string;
29
+ }
30
+
21
31
  interface Props {
22
32
  columns: Column[];
23
- resourceName: string;
24
33
  onReorder: (newOrder: Column[]) => void;
25
- header: Snippet<[Column, number, Record<string, any>]>;
34
+ header: Snippet<[Column, number, DragHeaderProps]>;
26
35
  }
27
36
 
28
- let { columns, resourceName, onReorder, header }: Props = $props();
37
+ let { columns, onReorder, header }: Props = $props();
29
38
 
30
39
  let dragIndex = $state<number | null>(null);
31
40
  let dropIndex = $state<number | null>(null);
32
41
 
33
- let _restored = false;
34
- $effect(() => {
35
- if (_restored || columns.length === 0) return;
36
- const storageKey = `svadmin-colorder-${resourceName}`;
37
- if (typeof window === 'undefined') return;
38
- _restored = true;
39
- try {
40
- const saved = localStorage.getItem(storageKey);
41
- if (saved) {
42
- const savedOrder: string[] = JSON.parse(saved);
43
- const reordered = savedOrder
44
- .map(id => columns.find(c => c.id === id))
45
- .filter((c): c is Column => c !== undefined);
46
- // Add any new columns not in saved order
47
- const remaining = columns.filter(c => !savedOrder.includes(c.id));
48
- if (reordered.length > 0) {
49
- onReorder([...reordered, ...remaining]);
50
- }
51
- }
52
- } catch { /* ignore */ }
53
- });
54
-
55
42
  function handleDragStart(e: DragEvent, index: number) {
56
43
  dragIndex = index;
57
44
  if (e.dataTransfer) {
@@ -80,14 +67,6 @@
80
67
  const [moved] = newColumns.splice(dragIndex, 1);
81
68
  newColumns.splice(index, 0, moved);
82
69
 
83
- // Persist to localStorage
84
- const storageKey = `svadmin-colorder-${resourceName}`;
85
- if (typeof window !== 'undefined') {
86
- try {
87
- localStorage.setItem(storageKey, JSON.stringify(newColumns.map(c => c.id)));
88
- } catch { /* ignore */ }
89
- }
90
-
91
70
  onReorder(newColumns);
92
71
  dragIndex = null;
93
72
  dropIndex = null;
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * DraggableHeader — Wraps table header cells with HTML5 Drag and Drop
3
- * to enable column reorder. Persists order to localStorage.
3
+ * to enable column reorder. The parent owns persistence and restoration.
4
4
  *
5
5
  * Usage:
6
- * <DraggableHeader columns={columns} resourceName="posts" onReorder={handleReorder}>
6
+ * <DraggableHeader columns={columns} onReorder={handleReorder}>
7
7
  * {#snippet header(column, index)}
8
8
  * <th>...</th>
9
9
  * {/snippet}
@@ -14,11 +14,20 @@ interface Column {
14
14
  id: string;
15
15
  [key: string]: unknown;
16
16
  }
17
+ interface DragHeaderProps {
18
+ draggable: true;
19
+ ondragstart: (event: DragEvent) => void;
20
+ ondragover: (event: DragEvent) => void;
21
+ ondrop: (event: DragEvent) => void;
22
+ ondragend: () => void;
23
+ 'data-dragging': boolean;
24
+ 'data-drop-target': boolean;
25
+ class: string;
26
+ }
17
27
  interface Props {
18
28
  columns: Column[];
19
- resourceName: string;
20
29
  onReorder: (newOrder: Column[]) => void;
21
- header: Snippet<[Column, number, Record<string, any>]>;
30
+ header: Snippet<[Column, number, DragHeaderProps]>;
22
31
  }
23
32
  declare const DraggableHeader: import("svelte").Component<Props, {}, "">;
24
33
  type DraggableHeader = ReturnType<typeof DraggableHeader>;
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { captureAdminContext, getResource } from '@svadmin/core';
2
+ import { getResource, useNavigation } from '@svadmin/core';
3
3
  import { useTranslation } from '@svadmin/core/i18n';
4
4
 
5
5
  import type { Snippet } from 'svelte';
@@ -35,7 +35,7 @@
35
35
  onSuccess,
36
36
  class: className = '',
37
37
  }: Props = $props();
38
- const adminContext = captureAdminContext();
38
+ const navigation = useNavigation();
39
39
  let navigateGuard = $state<(fn: () => void) => void>((fn) => fn());
40
40
 
41
41
  const resource = $derived(getResource(resourceName));
@@ -47,14 +47,14 @@
47
47
  <PageHeader
48
48
  title={pageTitle}
49
49
  {density}
50
- onBack={() => navigateGuard(() => adminContext.navigate(`/${resourceName}`))}
50
+ onBack={() => navigateGuard(() => navigation.list(resourceName))}
51
51
  >
52
52
  {#snippet actions()}
53
53
  <ListButton resource={resourceName} hideText onBeforeNavigate={navigateGuard} />
54
54
  <ShowButton resource={resourceName} recordItemId={id} hideText onBeforeNavigate={navigateGuard} />
55
55
  <RefreshButton resource={resourceName} hideText />
56
56
  {#if showDelete !== false}
57
- <DeleteButton resource={resourceName} recordItemId={id} hideText onSuccess={() => adminContext.navigate(`/${resourceName}`)} />
57
+ <DeleteButton resource={resourceName} recordItemId={id} hideText onSuccess={() => navigation.list(resourceName)} />
58
58
  {/if}
59
59
  {#if headerActions}
60
60
  {@render headerActions()}
@@ -17,8 +17,10 @@
17
17
  </script>
18
18
 
19
19
  <div
20
- class="flex flex-col items-center justify-center rounded-lg bg-card/50 px-8 py-10 text-center shadow-sm"
21
- in:scale={{ start: 0.95, duration: 250, opacity: 0 }}
20
+ class="flex flex-col items-center justify-center rounded-lg bg-card/50 px-8 py-10 text-center shadow-xs"
21
+ in:scale={{ start: 0.95, duration: 200, opacity: 0 }}
22
+ role="status"
23
+ aria-live="polite"
22
24
  >
23
25
  <div class="h-20 w-20 rounded-full bg-muted flex items-center justify-center mb-4">
24
26
  <PackageOpen class="h-10 w-10 text-muted-foreground" />
@@ -170,7 +170,14 @@
170
170
  </div>
171
171
 
172
172
  <!-- Mobile sidebar via Sheet -->
173
- <Sheet.Root bind:open={mobileMenuOpen} side="left">
173
+ <Sheet.Root
174
+ bind:open={mobileMenuOpen}
175
+ side="left"
176
+ role="dialog"
177
+ aria-modal="true"
178
+ aria-labelledby={`${layoutScope}-mobile-navigation-title`}
179
+ >
180
+ <Sheet.Title id={`${layoutScope}-mobile-navigation-title`} class="sr-only">{title}</Sheet.Title>
174
181
  <div class="md:hidden">
175
182
  <Sidebar collapsed={false} {identity} {title} {menu} {routeMode} onToggle={() => { mobileMenuOpen = false; }} onLogout={handleLogout} />
176
183
  </div>
@@ -1,15 +1,11 @@
1
1
  <script lang="ts">
2
- import { captureAdminContext, getResource } from '@svadmin/core';
3
- import { useTranslation } from '@svadmin/core/i18n';
2
+ import { getResource } from '@svadmin/core';
4
3
 
5
4
  import type { Snippet } from 'svelte';
6
5
  import type { FieldDefinition } from '@svadmin/core';
7
6
  import PageHeader from './PageHeader.svelte';
8
7
  import AutoTable from './AutoTable.svelte';
9
- import { Button } from './ui/button/index.js';
10
- import { Plus } from '@lucide/svelte';
11
-
12
- const i18n = useTranslation();
8
+ import CreateButton from './buttons/CreateButton.svelte';
13
9
 
14
10
  interface Props {
15
11
  resourceName: string;
@@ -25,7 +21,7 @@
25
21
  /** Optional filter toolbar slot between status tabs and table */
26
22
  filterToolbar?: Snippet;
27
23
  /** Passthrough: custom batch actions to render when rows are selected */
28
- batchActions?: Snippet<[{ selectedIds: string[] }]>;
24
+ batchActions?: Snippet<[{ selectedIds: (string | number)[] }]>;
29
25
  /** Passthrough: custom cell renderer per field */
30
26
  cellRenderer?: Snippet<[{ field: FieldDefinition; value: unknown; record: Record<string, unknown> }]>;
31
27
  /** Passthrough: custom row actions */
@@ -53,8 +49,6 @@
53
49
  expandedRowRender,
54
50
  class: className = '',
55
51
  }: Props = $props();
56
- const adminContext = captureAdminContext();
57
-
58
52
  const resource = $derived(getResource(resourceName));
59
53
  const pageTitle = $derived(title ?? resource.label);
60
54
  const showCreate = $derived(canCreate ?? resource.canCreate !== false);
@@ -64,9 +58,7 @@
64
58
  <PageHeader title={pageTitle} {density}>
65
59
  {#snippet actions()}
66
60
  {#if showCreate}
67
- <Button size={density === 'compact' ? 'sm' : 'default'} onclick={() => adminContext.navigate(`/${resourceName}/create`)}>
68
- <Plus class="h-4 w-4" data-icon="inline-start" /> {i18n.t('common.create')}
69
- </Button>
61
+ <CreateButton resource={resourceName} />
70
62
  {/if}
71
63
  {#if headerActions}
72
64
  {@render headerActions()}
@@ -15,7 +15,7 @@ interface Props {
15
15
  filterToolbar?: Snippet;
16
16
  /** Passthrough: custom batch actions to render when rows are selected */
17
17
  batchActions?: Snippet<[{
18
- selectedIds: string[];
18
+ selectedIds: (string | number)[];
19
19
  }]>;
20
20
  /** Passthrough: custom cell renderer per field */
21
21
  cellRenderer?: Snippet<[{
@@ -0,0 +1,121 @@
1
+ <script lang="ts">
2
+ import { captureAdminContext, getResource, useCan, useNavigation, useShow } from '@svadmin/core';
3
+ import { useTranslation } from '@svadmin/core/i18n';
4
+ import { Eye, Pencil } from '@lucide/svelte';
5
+ import CanAccess from './CanAccess.svelte';
6
+ import DetailDrawer from './DetailDrawer.svelte';
7
+ import { getDisplayComponent } from './fieldComponentMap';
8
+ import { Button } from './ui/button/index.js';
9
+ import { Skeleton } from './ui/skeleton/index.js';
10
+
11
+ let {
12
+ resourceName,
13
+ open = $bindable(false),
14
+ recordId,
15
+ onClose,
16
+ } = $props<{
17
+ resourceName: string;
18
+ open?: boolean;
19
+ recordId?: string | number;
20
+ onClose?: () => void;
21
+ }>();
22
+
23
+ const i18n = useTranslation();
24
+ const adminContext = captureAdminContext();
25
+ const navigation = useNavigation();
26
+ const resource = $derived(getResource(resourceName));
27
+ const showFields = $derived(resource.fields.filter((field) => field.showInShow !== false));
28
+ const accessControlEnabled = $derived(!!adminContext.accessControlProvider);
29
+ const showPermission = useCan(() => ({
30
+ resource: resourceName,
31
+ action: 'show',
32
+ params: recordId == null ? undefined : { id: recordId },
33
+ queryOptions: { enabled: open && recordId != null && accessControlEnabled },
34
+ }));
35
+ const permissionPending = $derived(
36
+ accessControlEnabled && open && recordId != null && showPermission.isLoading
37
+ );
38
+ const canReadRecord = $derived(
39
+ resource.canShow !== false && (!accessControlEnabled || (!permissionPending && showPermission.allowed))
40
+ );
41
+ const query = useShow({
42
+ get resource() { return resourceName; },
43
+ get id() { return recordId; },
44
+ get queryOptions() { return { enabled: open && recordId != null && canReadRecord }; },
45
+ });
46
+
47
+ function navigateTo(action: 'show' | 'edit'): void {
48
+ if (recordId == null) return;
49
+ navigation[action](resourceName, recordId);
50
+ open = false;
51
+ }
52
+ </script>
53
+
54
+ <DetailDrawer
55
+ bind:open
56
+ title="{resource.label} {i18n.t('common.detail')}"
57
+ description={recordId == null ? undefined : `#${recordId}`}
58
+ closeLabel={i18n.t('common.close')}
59
+ width="w-full sm:max-w-xl"
60
+ {onClose}
61
+ data-svadmin-record-detail
62
+ >
63
+ {#if permissionPending || (canReadRecord && query.isLoading)}
64
+ <div class="divide-y divide-border/60" aria-label={i18n.t('common.loading')}>
65
+ {#each showFields.slice(0, 6) as field (field.key)}
66
+ <div class="grid gap-2 py-4 sm:grid-cols-[minmax(8rem,1fr)_2fr] sm:gap-6">
67
+ <Skeleton class="h-4 w-24" />
68
+ <Skeleton class="h-4 w-full max-w-64" />
69
+ </div>
70
+ {/each}
71
+ </div>
72
+ {:else if !canReadRecord}
73
+ <div class="py-12 text-center" data-svadmin-access-denied>
74
+ <p class="text-sm text-muted-foreground">{i18n.t('common.accessDenied')}</p>
75
+ </div>
76
+ {:else if query.data?.data}
77
+ {@const record = query.data.data as Record<string, unknown>}
78
+ <dl class="divide-y divide-border/60">
79
+ {#each showFields as field (field.key)}
80
+ {@const value = record[field.key]}
81
+ {@const DisplayComponent = getDisplayComponent(field.type)}
82
+ <div class="grid gap-1.5 py-4 sm:grid-cols-[minmax(8rem,1fr)_2fr] sm:gap-6">
83
+ <dt class="text-xs font-medium text-muted-foreground sm:text-sm">{field.label}</dt>
84
+ <dd class="min-w-0 break-words text-sm text-foreground">
85
+ {#if DisplayComponent && value != null}
86
+ <DisplayComponent {value} options={field.options} resourceName={field.resource} />
87
+ {:else}
88
+ {value != null ? String(value) : '—'}
89
+ {/if}
90
+ </dd>
91
+ </div>
92
+ {/each}
93
+ </dl>
94
+ {:else}
95
+ <div class="py-12 text-center">
96
+ <p class="text-sm text-muted-foreground">{i18n.t('common.noData')}</p>
97
+ {#if query.isError}
98
+ <p class="mt-2 text-sm text-destructive">{(query.error as Error)?.message ?? i18n.t('common.operationFailed')}</p>
99
+ {/if}
100
+ </div>
101
+ {/if}
102
+
103
+ {#snippet footer()}
104
+ {#if canReadRecord && recordId != null && resource.canShow !== false}
105
+ <CanAccess resource={resourceName} action="show" params={{ id: recordId }}>
106
+ <Button variant="outline" onclick={() => navigateTo('show')}>
107
+ <Eye class="h-4 w-4" data-icon="inline-start" />
108
+ {i18n.t('common.openFullDetail')}
109
+ </Button>
110
+ </CanAccess>
111
+ {/if}
112
+ {#if canReadRecord && recordId != null && resource.canEdit !== false}
113
+ <CanAccess resource={resourceName} action="edit" params={{ id: recordId }}>
114
+ <Button onclick={() => navigateTo('edit')}>
115
+ <Pencil class="h-4 w-4" data-icon="inline-start" />
116
+ {i18n.t('common.edit')}
117
+ </Button>
118
+ </CanAccess>
119
+ {/if}
120
+ {/snippet}
121
+ </DetailDrawer>
@@ -0,0 +1,9 @@
1
+ type $$ComponentProps = {
2
+ resourceName: string;
3
+ open?: boolean;
4
+ recordId?: string | number;
5
+ onClose?: () => void;
6
+ };
7
+ declare const RecordDetailDrawer: import("svelte").Component<$$ComponentProps, {}, "open">;
8
+ type RecordDetailDrawer = ReturnType<typeof RecordDetailDrawer>;
9
+ export default RecordDetailDrawer;
@@ -0,0 +1,68 @@
1
+ <script lang="ts">
2
+ import type { Action } from '@svadmin/core';
3
+ import { captureAdminContext, useCan } from '@svadmin/core';
4
+ import type { Snippet } from 'svelte';
5
+ import DataState from './content/DataState.svelte';
6
+
7
+ interface Props {
8
+ resourceName: string;
9
+ action: Action;
10
+ id?: string | number;
11
+ requireSourceRead?: boolean;
12
+ children: Snippet;
13
+ }
14
+
15
+ let {
16
+ resourceName,
17
+ action,
18
+ id,
19
+ requireSourceRead = false,
20
+ children,
21
+ }: Props = $props();
22
+
23
+ const adminContext = captureAdminContext();
24
+ const resource = $derived(adminContext.getResource(resourceName));
25
+ const accessControlEnabled = $derived(Boolean(adminContext.accessControlProvider));
26
+ const actionAllowedByResource = $derived(
27
+ action === 'create'
28
+ ? resource.canCreate !== false
29
+ : action === 'edit'
30
+ ? resource.canEdit !== false
31
+ : action === 'show'
32
+ ? resource.canShow !== false
33
+ : true,
34
+ );
35
+ const sourceReadAllowedByResource = $derived(!requireSourceRead || resource.canShow !== false);
36
+ const params = $derived(id === undefined ? undefined : { id });
37
+ const permission = useCan(() => ({
38
+ resource: resourceName,
39
+ action,
40
+ params,
41
+ queryOptions: { enabled: accessControlEnabled },
42
+ }));
43
+ const sourceReadPermission = useCan(() => ({
44
+ resource: resourceName,
45
+ action: 'show',
46
+ params,
47
+ queryOptions: { enabled: accessControlEnabled && requireSourceRead },
48
+ }));
49
+ const permissionPending = $derived(
50
+ accessControlEnabled
51
+ && (permission.isLoading || (requireSourceRead && sourceReadPermission.isLoading)),
52
+ );
53
+ const allowed = $derived(
54
+ actionAllowedByResource
55
+ && sourceReadAllowedByResource
56
+ && (!accessControlEnabled || permission.allowed)
57
+ && (!requireSourceRead || !accessControlEnabled || sourceReadPermission.allowed),
58
+ );
59
+ const denialReason = $derived(permission.reason ?? sourceReadPermission.reason);
60
+ </script>
61
+
62
+ {#if permissionPending}
63
+ <DataState state="loading" />
64
+ {:else if allowed}
65
+ {@render children()}
66
+ {:else}
67
+ <DataState state="forbidden" description={denialReason} />
68
+ {/if}
@@ -0,0 +1,12 @@
1
+ import type { Action } from '@svadmin/core';
2
+ import type { Snippet } from 'svelte';
3
+ interface Props {
4
+ resourceName: string;
5
+ action: Action;
6
+ id?: string | number;
7
+ requireSourceRead?: boolean;
8
+ children: Snippet;
9
+ }
10
+ declare const ResourceAccessGuard: import("svelte").Component<Props, {}, "">;
11
+ type ResourceAccessGuard = ReturnType<typeof ResourceAccessGuard>;
12
+ export default ResourceAccessGuard;
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { captureAdminContext } from '@svadmin/core';
2
+ import { getResource, useCan, useNavigation } from '@svadmin/core';
3
3
  import type { Component } from 'svelte';
4
4
  import AutoTable from './AutoTable.svelte';
5
5
  import { Badge } from './ui/badge/index.js';
@@ -75,7 +75,10 @@
75
75
  highlightsLabel = 'Focus queue',
76
76
  workspaceStyle = 'operations',
77
77
  }: Props = $props();
78
- const adminContext = captureAdminContext();
78
+ const navigation = useNavigation();
79
+ const resource = $derived(getResource(resourceName));
80
+ const createPermission = useCan(() => ({ resource: resourceName, action: 'create' }));
81
+ const showCreate = $derived(resource.canCreate !== false && createPermission.allowed);
79
82
  </script>
80
83
 
81
84
  <div class="space-y-6" data-svadmin-resource-operations={resourceName} data-svadmin-workspace-style={workspaceStyle} data-svadmin-layout-identity={workspaceStyle}>
@@ -84,7 +87,9 @@
84
87
  {#if Icon}<span class="flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-card text-primary"><Icon class="size-4" /></span>{/if}
85
88
  <div class="min-w-0"><Badge variant="outline">{eyebrow}</Badge><h1 class="mt-2 text-xl font-semibold text-foreground">{title}</h1><p class="mt-1 max-w-3xl text-sm leading-6 text-muted-foreground">{description}</p></div>
86
89
  </div>
87
- <Button class="shrink-0" size="sm" onclick={() => adminContext.navigate(`/${resourceName}/create`)}>{actionLabel}</Button>
90
+ {#if showCreate}
91
+ <Button class="shrink-0" size="sm" onclick={() => navigation.create(resourceName)}>{actionLabel}</Button>
92
+ {/if}
88
93
  </header>
89
94
 
90
95
  {#if workspaceStyle === 'inventory'}