@svadmin/ui 0.56.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.
- package/dist/app.css +28 -0
- package/dist/app.theme.css +28 -0
- package/dist/components/AICommandBar.svelte +33 -10
- package/dist/components/AdminApp.svelte +16 -5
- package/dist/components/AutoForm.svelte +4 -4
- package/dist/components/AutoTable.svelte +866 -204
- package/dist/components/AutoTable.svelte.d.ts +1 -1
- package/dist/components/CommandPalette.svelte +39 -17
- package/dist/components/ConfirmDialog.svelte +4 -3
- package/dist/components/ConfirmDialog.svelte.d.ts +1 -0
- package/dist/components/CopilotPanel.svelte +3 -3
- package/dist/components/DraggableHeader.svelte +15 -36
- package/dist/components/DraggableHeader.svelte.d.ts +13 -4
- package/dist/components/EditPage.svelte +4 -4
- package/dist/components/EmptyState.svelte +1 -1
- package/dist/components/ErrorComponent.svelte +1 -1
- package/dist/components/FieldRenderer.svelte +2 -1
- package/dist/components/ListPage.svelte +4 -12
- package/dist/components/ListPage.svelte.d.ts +1 -1
- package/dist/components/RecordDetailDrawer.svelte +121 -0
- package/dist/components/RecordDetailDrawer.svelte.d.ts +9 -0
- package/dist/components/ResourceAccessGuard.svelte +68 -0
- package/dist/components/ResourceAccessGuard.svelte.d.ts +12 -0
- package/dist/components/ResourceOperationsPage.svelte +8 -3
- package/dist/components/ShowPage.svelte +5 -6
- package/dist/components/TaskQueueDrawer.svelte +1 -1
- package/dist/components/TenantSwitcher.svelte +1 -0
- package/dist/components/Toast.svelte +35 -1
- package/dist/components/UndoableNotification.svelte +12 -6
- package/dist/components/UndoableNotification.svelte.d.ts +2 -0
- package/dist/components/account/MembersStarterPage.svelte +1 -1
- package/dist/components/buttons/CloneButton.svelte +9 -2
- package/dist/components/buttons/CreateButton.svelte +9 -2
- package/dist/components/buttons/DeleteButton.svelte +9 -2
- package/dist/components/buttons/EditButton.svelte +9 -2
- package/dist/components/buttons/ListButton.svelte +1 -1
- package/dist/components/buttons/ShowButton.svelte +9 -2
- package/dist/components/content/DataState.svelte +1 -1
- package/dist/components/content/FilterToolbar.svelte +6 -2
- package/dist/components/content/MediaThumbnail.svelte +109 -0
- package/dist/components/content/MediaThumbnail.svelte.d.ts +22 -0
- package/dist/components/content/ProjectCard.svelte +2 -1
- package/dist/components/content/SystemErrorState.svelte +1 -1
- package/dist/components/content/index.d.ts +2 -0
- package/dist/components/content/index.js +1 -0
- package/dist/components/fields/ImageField.svelte +19 -4
- package/dist/components/fields/ImageField.svelte.d.ts +4 -0
- package/dist/components/saved-list-views.d.ts +35 -0
- package/dist/components/saved-list-views.js +168 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- 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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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}
|
|
@@ -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.
|
|
4
|
+
* to enable column reorder. The parent owns persistence and restoration.
|
|
6
5
|
*
|
|
7
6
|
* Usage:
|
|
8
|
-
* <DraggableHeader columns={columns}
|
|
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,
|
|
34
|
+
header: Snippet<[Column, number, DragHeaderProps]>;
|
|
26
35
|
}
|
|
27
36
|
|
|
28
|
-
let { columns,
|
|
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.
|
|
3
|
+
* to enable column reorder. The parent owns persistence and restoration.
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
|
-
* <DraggableHeader columns={columns}
|
|
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,
|
|
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 {
|
|
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
|
|
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(() =>
|
|
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={() =>
|
|
57
|
+
<DeleteButton resource={resourceName} recordItemId={id} hideText onSuccess={() => navigation.list(resourceName)} />
|
|
58
58
|
{/if}
|
|
59
59
|
{#if headerActions}
|
|
60
60
|
{@render headerActions()}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
</script>
|
|
18
18
|
|
|
19
19
|
<div
|
|
20
|
-
class="flex flex-col items-center justify-center
|
|
20
|
+
class="flex flex-col items-center justify-center rounded-lg bg-card/50 px-8 py-10 text-center shadow-sm"
|
|
21
21
|
in:scale={{ start: 0.95, duration: 250, opacity: 0 }}
|
|
22
22
|
>
|
|
23
23
|
<div class="h-20 w-20 rounded-full bg-muted flex items-center justify-center mb-4">
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
}>();
|
|
20
20
|
</script>
|
|
21
21
|
|
|
22
|
-
<div class="flex flex-col items-center justify-center
|
|
22
|
+
<div class="flex flex-col items-center justify-center px-8 py-10 text-center">
|
|
23
23
|
<Alert.Root variant="destructive" class="max-w-md text-left">
|
|
24
24
|
<AlertTriangle class="h-5 w-5" />
|
|
25
25
|
<Alert.Title>{title}</Alert.Title>
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import TooltipButton from './TooltipButton.svelte';
|
|
18
18
|
import ComboboxField from './ComboboxField.svelte';
|
|
19
19
|
import ArrayField from './fields/ArrayField.svelte';
|
|
20
|
+
import MediaThumbnail from './content/MediaThumbnail.svelte';
|
|
20
21
|
import { Plus, X } from '@lucide/svelte';
|
|
21
22
|
import type { Snippet } from 'svelte';
|
|
22
23
|
import { getRichTextEditor } from '../editor-config.svelte.js';
|
|
@@ -429,7 +430,7 @@
|
|
|
429
430
|
{disabled}
|
|
430
431
|
/>
|
|
431
432
|
{#if url}
|
|
432
|
-
<
|
|
433
|
+
<div class="size-9 shrink-0"><MediaThumbnail src={url} alt="preview" size="full" fit="cover" showOverlay={false} /></div>
|
|
433
434
|
{/if}
|
|
434
435
|
<TooltipButton tooltip={i18n.t('common.removeImage')} variant="ghost" size="icon" class="h-8 w-8 shrink-0" onclick={() => removeImage(i)}>
|
|
435
436
|
<X class="h-3.5 w-3.5" />
|
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
<
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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'}
|