@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
@@ -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());
@@ -95,14 +97,27 @@
95
97
  {i18n.t('common.home')}
96
98
  </Command.Item>
97
99
  {#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>
100
+ {#if adminContext.accessControlProvider}
101
+ <CanAccess resource={r.name} action="list">
102
+ <Command.Item
103
+ value={r.name}
104
+ onSelect={() => act(() => navigation.list(r.name))}
105
+ class={itemClass}
106
+ >
107
+ <FileText class="h-4 w-4 text-muted-foreground" />
108
+ {r.label}
109
+ </Command.Item>
110
+ </CanAccess>
111
+ {:else}
112
+ <Command.Item
113
+ value={r.name}
114
+ onSelect={() => act(() => navigation.list(r.name))}
115
+ class={itemClass}
116
+ >
117
+ <FileText class="h-4 w-4 text-muted-foreground" />
118
+ {r.label}
119
+ </Command.Item>
120
+ {/if}
106
121
  {/each}
107
122
  </Command.Group>
108
123
 
@@ -111,14 +126,21 @@
111
126
  <!-- Actions -->
112
127
  <Command.Group heading={i18n.t('common.actions')}>
113
128
  {#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>
129
+ {#if r.canCreate !== false}
130
+ {#if adminContext.accessControlProvider}
131
+ <CanAccess resource={r.name} action="create">
132
+ <Command.Item value={"create-" + r.name} onSelect={() => act(() => navigation.create(r.name))} class={itemClass}>
133
+ <Plus class="h-4 w-4 text-muted-foreground" />
134
+ {i18n.t('common.create')} {r.label}
135
+ </Command.Item>
136
+ </CanAccess>
137
+ {:else}
138
+ <Command.Item value={"create-" + r.name} onSelect={() => act(() => navigation.create(r.name))} class={itemClass}>
139
+ <Plus class="h-4 w-4 text-muted-foreground" />
140
+ {i18n.t('common.create')} {r.label}
141
+ </Command.Item>
142
+ {/if}
143
+ {/if}
122
144
  {/each}
123
145
  <Command.Item
124
146
  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()}
@@ -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'}
@@ -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}