@svadmin/ui 0.0.6 → 0.3.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/package.json +3 -2
- package/src/components/AdminApp.svelte +4 -3
- package/src/components/Authenticated.svelte +44 -13
- package/src/components/AutoForm.svelte +5 -5
- package/src/components/AutoSaveIndicator.svelte +30 -0
- package/src/components/AutoTable.svelte +81 -10
- package/src/components/Breadcrumbs.svelte +2 -0
- package/src/components/CatchAllNavigate.svelte +21 -0
- package/src/components/ConfirmDialog.svelte +1 -1
- package/src/components/CreatePage.svelte +42 -0
- package/src/components/EditPage.svelte +52 -0
- package/src/components/EmptyState.svelte +17 -31
- package/src/components/ErrorComponent.svelte +35 -0
- package/src/components/Header.svelte +28 -59
- package/src/components/ListPage.svelte +47 -0
- package/src/components/LiveIndicator.svelte +115 -0
- package/src/components/NavigateToResource.svelte +20 -0
- package/src/components/PageSkeleton.svelte +70 -0
- package/src/components/ShowPage.svelte +3 -3
- package/src/components/Sidebar.svelte +29 -10
- package/src/components/ThemedTitle.svelte +32 -0
- package/src/components/Toast.svelte +21 -11
- package/src/components/buttons/CloneButton.svelte +1 -1
- package/src/components/buttons/CreateButton.svelte +1 -1
- package/src/components/buttons/DeleteButton.svelte +3 -4
- package/src/components/buttons/EditButton.svelte +1 -1
- package/src/components/buttons/ImportButton.svelte +29 -7
- package/src/components/buttons/RefreshButton.svelte +5 -4
- package/src/components/fields/BooleanField.svelte +13 -0
- package/src/components/fields/DateField.svelte +6 -0
- package/src/components/fields/EmailField.svelte +15 -0
- package/src/components/fields/FileField.svelte +19 -0
- package/src/components/fields/ImageField.svelte +14 -0
- package/src/components/fields/MarkdownField.svelte +19 -0
- package/src/components/fields/NumberField.svelte +6 -0
- package/src/components/fields/TagField.svelte +17 -0
- package/src/components/fields/TextField.svelte +16 -0
- package/src/components/fields/UrlField.svelte +20 -0
- package/src/components/ui/avatar/avatar.svelte +2 -1
- package/src/index.ts +22 -0
- package/src/router-state.svelte.ts +16 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/ui",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
23
|
"svelte": "^5.0.0",
|
|
24
|
-
"@svadmin/core": "0.0
|
|
24
|
+
"@svadmin/core": "^0.3.0",
|
|
25
|
+
"@tanstack/svelte-query": "^5.0.0",
|
|
25
26
|
"bits-ui": "^2.0.0",
|
|
26
27
|
"tailwind-variants": "^3.0.0",
|
|
27
28
|
"clsx": "^2.0.0",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
3
|
import type { DataProvider, AuthProvider, ResourceDefinition, ThemeMode, RouterProvider } from '@svadmin/core';
|
|
4
4
|
import { setDataProvider, setAuthProvider, setResources, setLocale, setTheme, setRouterProvider, getAuthProvider, createHashRouterProvider } from '@svadmin/core';
|
|
5
|
+
import { t } from '@svadmin/core/i18n';
|
|
5
6
|
import { navigate } from '@svadmin/core/router';
|
|
6
7
|
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
|
|
7
8
|
import Layout from './Layout.svelte';
|
|
@@ -109,8 +110,8 @@
|
|
|
109
110
|
{@render dashboard()}
|
|
110
111
|
{:else}
|
|
111
112
|
<div class="space-y-4">
|
|
112
|
-
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
|
113
|
-
<p class="text-gray-500">
|
|
113
|
+
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{t('common.welcome', { title })}</h1>
|
|
114
|
+
<p class="text-gray-500">{t('common.dashboardHint')}</p>
|
|
114
115
|
</div>
|
|
115
116
|
{/if}
|
|
116
117
|
{:else if route === '/:resource'}
|
|
@@ -133,7 +134,7 @@
|
|
|
133
134
|
</Layout>
|
|
134
135
|
{:else}
|
|
135
136
|
<div class="flex h-screen items-center justify-center">
|
|
136
|
-
<p class="text-gray-500">
|
|
137
|
+
<p class="text-gray-500">{t('common.redirecting')}</p>
|
|
137
138
|
</div>
|
|
138
139
|
{/if}
|
|
139
140
|
<Toast />
|
|
@@ -1,26 +1,57 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import type { Snippet } from 'svelte';
|
|
3
2
|
import { useIsAuthenticated } from '@svadmin/core';
|
|
3
|
+
import { onMount, type Snippet } from 'svelte';
|
|
4
|
+
import { navigate } from '@svadmin/core';
|
|
4
5
|
|
|
5
|
-
let {
|
|
6
|
-
children
|
|
6
|
+
let {
|
|
7
|
+
children,
|
|
8
|
+
fallback,
|
|
9
|
+
loading,
|
|
10
|
+
redirectTo = '/login',
|
|
11
|
+
appendCurrentPathToQuery = true,
|
|
12
|
+
} = $props<{
|
|
13
|
+
children?: Snippet;
|
|
7
14
|
fallback?: Snippet;
|
|
8
15
|
loading?: Snippet;
|
|
16
|
+
redirectTo?: string;
|
|
17
|
+
appendCurrentPathToQuery?: boolean;
|
|
9
18
|
}>();
|
|
10
19
|
|
|
11
|
-
const
|
|
20
|
+
const isAuth = useIsAuthenticated();
|
|
21
|
+
|
|
22
|
+
function triggerRedirect() {
|
|
23
|
+
if (fallback) return;
|
|
24
|
+
let url = redirectTo;
|
|
25
|
+
if (appendCurrentPathToQuery && typeof window !== 'undefined') {
|
|
26
|
+
const current = encodeURIComponent(window.location.pathname + window.location.search);
|
|
27
|
+
url += `?to=${current}`;
|
|
28
|
+
}
|
|
29
|
+
navigate(url);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
onMount(() => {
|
|
33
|
+
if (!isAuth.isLoading && !isAuth.isAuthenticated) {
|
|
34
|
+
triggerRedirect();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
$effect(() => {
|
|
39
|
+
if (!isAuth.isLoading && !isAuth.isAuthenticated) {
|
|
40
|
+
triggerRedirect();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
12
43
|
</script>
|
|
13
44
|
|
|
14
|
-
{#if
|
|
45
|
+
{#if isAuth.isLoading}
|
|
15
46
|
{#if loading}
|
|
16
47
|
{@render loading()}
|
|
17
|
-
{:else}
|
|
18
|
-
<div class="flex items-center justify-center min-h-[200px]">
|
|
19
|
-
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
|
20
|
-
</div>
|
|
21
48
|
{/if}
|
|
22
|
-
{:else if
|
|
23
|
-
{
|
|
24
|
-
{
|
|
25
|
-
{
|
|
49
|
+
{:else if isAuth.isAuthenticated}
|
|
50
|
+
{#if children}
|
|
51
|
+
{@render children()}
|
|
52
|
+
{/if}
|
|
53
|
+
{:else}
|
|
54
|
+
{#if fallback}
|
|
55
|
+
{@render fallback()}
|
|
56
|
+
{/if}
|
|
26
57
|
{/if}
|
|
@@ -62,8 +62,8 @@
|
|
|
62
62
|
}
|
|
63
63
|
formData = defaults;
|
|
64
64
|
initialized = true;
|
|
65
|
-
} else if (existingQuery && existingQuery?.data) {
|
|
66
|
-
formData = { ...existingQuery.data as Record<string, unknown> };
|
|
65
|
+
} else if (existingQuery && existingQuery.query.data?.data) {
|
|
66
|
+
formData = { ...existingQuery.query.data.data as Record<string, unknown> };
|
|
67
67
|
initialized = true;
|
|
68
68
|
}
|
|
69
69
|
});
|
|
@@ -125,9 +125,9 @@
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
if (mode === 'create') {
|
|
128
|
-
await createMut.mutateAsync(cleanData);
|
|
128
|
+
await createMut.mutation.mutateAsync({ variables: cleanData });
|
|
129
129
|
} else if (id != null) {
|
|
130
|
-
await updateMut.mutateAsync({ id, variables: cleanData });
|
|
130
|
+
await updateMut.mutation.mutateAsync({ id, variables: cleanData });
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
isDirty = false;
|
|
@@ -150,7 +150,7 @@
|
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
const isLoading = $derived(mode === 'edit' && existingQuery ? existingQuery
|
|
153
|
+
const isLoading = $derived(mode === 'edit' && existingQuery ? existingQuery.query.isLoading : false);
|
|
154
154
|
|
|
155
155
|
const pageTitle = $derived(
|
|
156
156
|
mode === 'create'
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Loader2, Check, X, Cloud } from 'lucide-svelte';
|
|
3
|
+
import { t } from '@svadmin/core/i18n';
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
status = 'idle',
|
|
7
|
+
error,
|
|
8
|
+
} = $props<{
|
|
9
|
+
/** The current auto-save status */
|
|
10
|
+
status?: 'idle' | 'loading' | 'success' | 'error';
|
|
11
|
+
/** Optional error message */
|
|
12
|
+
error?: string;
|
|
13
|
+
}>();
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<div class="flex items-center gap-2 text-sm text-muted-foreground transition-all duration-300">
|
|
17
|
+
{#if status === 'idle'}
|
|
18
|
+
<Cloud class="h-4 w-4" />
|
|
19
|
+
<span>{t('autoSave.idle')}</span>
|
|
20
|
+
{:else if status === 'loading'}
|
|
21
|
+
<Loader2 class="h-4 w-4 animate-spin text-primary" />
|
|
22
|
+
<span>{t('autoSave.saving')}</span>
|
|
23
|
+
{:else if status === 'success'}
|
|
24
|
+
<Check class="h-4 w-4 text-green-500" />
|
|
25
|
+
<span class="text-green-500">{t('autoSave.saved')}</span>
|
|
26
|
+
{:else if status === 'error'}
|
|
27
|
+
<X class="h-4 w-4 text-destructive" />
|
|
28
|
+
<span class="text-destructive" title={error}>{t('autoSave.error')}</span>
|
|
29
|
+
{/if}
|
|
30
|
+
</div>
|
|
@@ -10,10 +10,20 @@
|
|
|
10
10
|
import { Checkbox } from './ui/checkbox/index.js';
|
|
11
11
|
import { Badge } from './ui/badge/index.js';
|
|
12
12
|
import * as Table from './ui/table/index.js';
|
|
13
|
-
import { Plus, Pencil, Trash2, ChevronLeft, ChevronRight, Search, Loader2, Download, Upload } from 'lucide-svelte';
|
|
13
|
+
import { Plus, Pencil, Trash2, ChevronLeft, ChevronRight, Search, Loader2, Download, Upload, ChevronDown, ChevronUp, SlidersHorizontal } from 'lucide-svelte';
|
|
14
14
|
import ConfirmDialog from './ConfirmDialog.svelte';
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
import type { Snippet } from 'svelte';
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
resourceName,
|
|
20
|
+
expandedRowRender,
|
|
21
|
+
selectable = true,
|
|
22
|
+
} = $props<{
|
|
23
|
+
resourceName: string;
|
|
24
|
+
expandedRowRender?: Snippet<[record: Record<string, unknown>]>;
|
|
25
|
+
selectable?: boolean;
|
|
26
|
+
}>();
|
|
17
27
|
|
|
18
28
|
const resource = getResource(resourceName);
|
|
19
29
|
const primaryKey = $derived(resource.primaryKey ?? 'id');
|
|
@@ -33,6 +43,25 @@
|
|
|
33
43
|
let filters = $state<Filter[]>([]);
|
|
34
44
|
let searchText = $state(urlState.search ?? '');
|
|
35
45
|
|
|
46
|
+
// Column visibility
|
|
47
|
+
let columnVisibility = $state<Record<string, boolean>>({});
|
|
48
|
+
function initColumnVisibility() {
|
|
49
|
+
const vis: Record<string, boolean> = {};
|
|
50
|
+
for (const f of resource.fields) vis[f.key] = f.showInList !== false;
|
|
51
|
+
return vis;
|
|
52
|
+
}
|
|
53
|
+
columnVisibility = initColumnVisibility();
|
|
54
|
+
const visibleFields = $derived(resource.fields.filter(f => columnVisibility[f.key] !== false && f.showInList !== false));
|
|
55
|
+
let showColumnPicker = $state(false);
|
|
56
|
+
|
|
57
|
+
// Expanded rows
|
|
58
|
+
let expandedRowIds = $state<Set<string | number>>(new Set());
|
|
59
|
+
function toggleExpand(id: string | number) {
|
|
60
|
+
const next = new Set(expandedRowIds);
|
|
61
|
+
if (next.has(id)) next.delete(id); else next.add(id);
|
|
62
|
+
expandedRowIds = next;
|
|
63
|
+
}
|
|
64
|
+
|
|
36
65
|
// Batch selection
|
|
37
66
|
let selectedIds = $state<Set<string | number>>(new Set());
|
|
38
67
|
let selectAll = $state(false);
|
|
@@ -54,7 +83,7 @@
|
|
|
54
83
|
});
|
|
55
84
|
|
|
56
85
|
const searchableFields = resource.fields.filter(f => f.searchable);
|
|
57
|
-
const listFields =
|
|
86
|
+
const listFields = $derived(visibleFields);
|
|
58
87
|
|
|
59
88
|
const activeFilters = $derived.by(() => {
|
|
60
89
|
const result: Filter[] = [...filters];
|
|
@@ -64,14 +93,16 @@
|
|
|
64
93
|
return result;
|
|
65
94
|
});
|
|
66
95
|
|
|
67
|
-
const
|
|
96
|
+
const listResult = useList({
|
|
68
97
|
resource: resourceName,
|
|
69
98
|
pagination,
|
|
70
99
|
sorters,
|
|
71
100
|
filters: activeFilters,
|
|
72
101
|
});
|
|
102
|
+
const query = listResult.query;
|
|
73
103
|
|
|
74
|
-
const
|
|
104
|
+
const deleteResult = useDelete({ resource: resourceName });
|
|
105
|
+
const deleteMutation = deleteResult.mutation;
|
|
75
106
|
|
|
76
107
|
function toggleSort(field: string) {
|
|
77
108
|
const existing = sorters.find(s => s.field === field);
|
|
@@ -111,7 +142,7 @@
|
|
|
111
142
|
function confirmDelete(id: string | number) {
|
|
112
143
|
confirmMessage = t('common.deleteConfirm');
|
|
113
144
|
confirmAction = async () => {
|
|
114
|
-
await deleteMutation.mutateAsync(id);
|
|
145
|
+
await deleteMutation.mutateAsync({ id, resource: resourceName });
|
|
115
146
|
confirmOpen = false;
|
|
116
147
|
};
|
|
117
148
|
confirmOpen = true;
|
|
@@ -121,7 +152,7 @@
|
|
|
121
152
|
confirmMessage = t('common.batchDeleteConfirm', { count: selectedIds.size });
|
|
122
153
|
confirmAction = async () => {
|
|
123
154
|
for (const id of selectedIds) {
|
|
124
|
-
await deleteMutation.mutateAsync(id);
|
|
155
|
+
await deleteMutation.mutateAsync({ id, resource: resourceName });
|
|
125
156
|
}
|
|
126
157
|
selectedIds = new Set();
|
|
127
158
|
selectAll = false;
|
|
@@ -194,6 +225,25 @@
|
|
|
194
225
|
<Trash2 class="h-4 w-4" data-icon="inline-start" /> {t('common.batchDelete', { count: selectedIds.size })}
|
|
195
226
|
</Button>
|
|
196
227
|
{/if}
|
|
228
|
+
<!-- Column Visibility Picker -->
|
|
229
|
+
<div class="relative">
|
|
230
|
+
<Button variant="outline" size="sm" onclick={() => showColumnPicker = !showColumnPicker}>
|
|
231
|
+
<SlidersHorizontal class="h-4 w-4" data-icon="inline-start" /> {t('common.columns') || 'Columns'}
|
|
232
|
+
</Button>
|
|
233
|
+
{#if showColumnPicker}
|
|
234
|
+
<div class="absolute right-0 top-full mt-1 z-50 w-48 rounded-lg border bg-popover p-2 shadow-lg">
|
|
235
|
+
{#each resource.fields.filter(f => f.showInList !== false) as field}
|
|
236
|
+
<label class="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent cursor-pointer">
|
|
237
|
+
<Checkbox
|
|
238
|
+
checked={columnVisibility[field.key] !== false}
|
|
239
|
+
onCheckedChange={(v) => { columnVisibility = { ...columnVisibility, [field.key]: !!v }; }}
|
|
240
|
+
/>
|
|
241
|
+
{field.label}
|
|
242
|
+
</label>
|
|
243
|
+
{/each}
|
|
244
|
+
</div>
|
|
245
|
+
{/if}
|
|
246
|
+
</div>
|
|
197
247
|
{#if canCreate}
|
|
198
248
|
<Button onclick={() => navigate(`/${resourceName}/create`)}>
|
|
199
249
|
<Plus class="h-4 w-4" data-icon="inline-start" /> {t('common.create')}
|
|
@@ -230,11 +280,14 @@
|
|
|
230
280
|
<Table.Root>
|
|
231
281
|
<Table.Header>
|
|
232
282
|
<Table.Row class="bg-muted/50 hover:bg-muted/50">
|
|
233
|
-
{#if canDelete}
|
|
283
|
+
{#if selectable && canDelete}
|
|
234
284
|
<Table.Head class="w-10">
|
|
235
285
|
<Checkbox checked={selectAll} onCheckedChange={() => toggleSelectAll()} />
|
|
236
286
|
</Table.Head>
|
|
237
287
|
{/if}
|
|
288
|
+
{#if expandedRowRender}
|
|
289
|
+
<Table.Head class="w-10"></Table.Head>
|
|
290
|
+
{/if}
|
|
238
291
|
{#each listFields as field}
|
|
239
292
|
<Table.Head style={field.width ? `width:${field.width}` : undefined}>
|
|
240
293
|
<button class="flex items-center gap-1 hover:text-foreground" onclick={() => toggleSort(field.key)}>
|
|
@@ -250,11 +303,22 @@
|
|
|
250
303
|
{#each query.data?.data ?? [] as record}
|
|
251
304
|
{@const id = record[primaryKey] as string | number}
|
|
252
305
|
<Table.Row class="transition-colors {selectedIds.has(id) ? 'bg-accent' : ''}">
|
|
253
|
-
{#if canDelete}
|
|
306
|
+
{#if selectable && canDelete}
|
|
254
307
|
<Table.Cell>
|
|
255
308
|
<Checkbox checked={selectedIds.has(id)} onCheckedChange={() => toggleSelect(id)} />
|
|
256
309
|
</Table.Cell>
|
|
257
310
|
{/if}
|
|
311
|
+
{#if expandedRowRender}
|
|
312
|
+
<Table.Cell>
|
|
313
|
+
<button class="p-1 hover:bg-accent rounded" onclick={() => toggleExpand(id)}>
|
|
314
|
+
{#if expandedRowIds.has(id)}
|
|
315
|
+
<ChevronUp class="h-4 w-4" />
|
|
316
|
+
{:else}
|
|
317
|
+
<ChevronDown class="h-4 w-4" />
|
|
318
|
+
{/if}
|
|
319
|
+
</button>
|
|
320
|
+
</Table.Cell>
|
|
321
|
+
{/if}
|
|
258
322
|
{#each listFields as field}
|
|
259
323
|
<Table.Cell>
|
|
260
324
|
{#if field.type === 'boolean'}
|
|
@@ -299,9 +363,16 @@
|
|
|
299
363
|
</div>
|
|
300
364
|
</Table.Cell>
|
|
301
365
|
</Table.Row>
|
|
366
|
+
{#if expandedRowRender && expandedRowIds.has(id)}
|
|
367
|
+
<Table.Row class="bg-muted/30">
|
|
368
|
+
<Table.Cell colspan={listFields.length + (selectable && canDelete ? 1 : 0) + (expandedRowRender ? 1 : 0) + 1}>
|
|
369
|
+
{@render expandedRowRender(record)}
|
|
370
|
+
</Table.Cell>
|
|
371
|
+
</Table.Row>
|
|
372
|
+
{/if}
|
|
302
373
|
{:else}
|
|
303
374
|
<Table.Row>
|
|
304
|
-
<Table.Cell colspan={listFields.length + (canDelete ? 2 : 1)} class="h-24 text-center text-muted-foreground">
|
|
375
|
+
<Table.Cell colspan={listFields.length + (selectable && canDelete ? 2 : 1) + (expandedRowRender ? 1 : 0)} class="h-24 text-center text-muted-foreground">
|
|
305
376
|
{t('common.noData')}
|
|
306
377
|
</Table.Cell>
|
|
307
378
|
</Table.Row>
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
result.push({ label: t('common.create'), href: `#/${res.name}/create` });
|
|
30
30
|
} else if (segments[1] === 'edit' && segments[2]) {
|
|
31
31
|
result.push({ label: `${t('common.edit')} #${segments[2]}`, href: `#/${res.name}/edit/${segments[2]}` });
|
|
32
|
+
} else if (segments[1] === 'show' && segments[2]) {
|
|
33
|
+
result.push({ label: `${t('common.detail')} #${segments[2]}`, href: `#/${res.name}/show/${segments[2]}` });
|
|
32
34
|
}
|
|
33
35
|
}
|
|
34
36
|
return result;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { useIsAuthenticated } from '@svadmin/core';
|
|
3
|
+
import { navigate } from '@svadmin/core/router';
|
|
4
|
+
import NavigateToResource from './NavigateToResource.svelte';
|
|
5
|
+
|
|
6
|
+
const auth = useIsAuthenticated();
|
|
7
|
+
|
|
8
|
+
$effect(() => {
|
|
9
|
+
if (!auth.isLoading && !auth.isAuthenticated) {
|
|
10
|
+
navigate('/login');
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
{#if auth.isLoading}
|
|
16
|
+
<div class="flex h-screen w-screen items-center justify-center">
|
|
17
|
+
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-r-transparent"></div>
|
|
18
|
+
</div>
|
|
19
|
+
{:else if auth.isAuthenticated}
|
|
20
|
+
<NavigateToResource />
|
|
21
|
+
{/if}
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
<Button variant="outline" onclick={oncancel}>
|
|
38
38
|
{resolvedCancelText}
|
|
39
39
|
</Button>
|
|
40
|
-
<Button variant={variantMap[variant]} onclick={onconfirm}>
|
|
40
|
+
<Button variant={variantMap[variant as keyof typeof variantMap]} onclick={onconfirm}>
|
|
41
41
|
{resolvedConfirmText}
|
|
42
42
|
</Button>
|
|
43
43
|
</Dialog.Footer>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getResource } from '@svadmin/core';
|
|
3
|
+
import { navigate } from '@svadmin/core/router';
|
|
4
|
+
import { t } from '@svadmin/core/i18n';
|
|
5
|
+
import type { Snippet } from 'svelte';
|
|
6
|
+
import PageHeader from './PageHeader.svelte';
|
|
7
|
+
import AutoForm from './AutoForm.svelte';
|
|
8
|
+
import { ArrowLeft } from 'lucide-svelte';
|
|
9
|
+
import { Button } from './ui/button/index.js';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
resourceName: string;
|
|
13
|
+
title?: string;
|
|
14
|
+
headerActions?: Snippet;
|
|
15
|
+
class?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
resourceName,
|
|
20
|
+
title,
|
|
21
|
+
headerActions,
|
|
22
|
+
class: className = '',
|
|
23
|
+
}: Props = $props();
|
|
24
|
+
|
|
25
|
+
const resource = getResource(resourceName);
|
|
26
|
+
const pageTitle = title ?? `${t('common.create')}${resource.label}`;
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<div class="space-y-6 {className}">
|
|
30
|
+
<PageHeader title={pageTitle}>
|
|
31
|
+
{#snippet actions()}
|
|
32
|
+
<Button variant="ghost" size="icon" onclick={() => navigate(`/${resourceName}`)}>
|
|
33
|
+
<ArrowLeft class="h-5 w-5" />
|
|
34
|
+
</Button>
|
|
35
|
+
{#if headerActions}
|
|
36
|
+
{@render headerActions()}
|
|
37
|
+
{/if}
|
|
38
|
+
{/snippet}
|
|
39
|
+
</PageHeader>
|
|
40
|
+
|
|
41
|
+
<AutoForm {resourceName} mode="create" />
|
|
42
|
+
</div>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getResource } from '@svadmin/core';
|
|
3
|
+
import { navigate } from '@svadmin/core/router';
|
|
4
|
+
import { t } from '@svadmin/core/i18n';
|
|
5
|
+
import type { Snippet } from 'svelte';
|
|
6
|
+
import PageHeader from './PageHeader.svelte';
|
|
7
|
+
import AutoForm from './AutoForm.svelte';
|
|
8
|
+
import { ArrowLeft, Trash2 } from 'lucide-svelte';
|
|
9
|
+
import { Button } from './ui/button/index.js';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
resourceName: string;
|
|
13
|
+
id: string | number;
|
|
14
|
+
title?: string;
|
|
15
|
+
canDelete?: boolean;
|
|
16
|
+
headerActions?: Snippet;
|
|
17
|
+
class?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let {
|
|
21
|
+
resourceName,
|
|
22
|
+
id,
|
|
23
|
+
title,
|
|
24
|
+
canDelete,
|
|
25
|
+
headerActions,
|
|
26
|
+
class: className = '',
|
|
27
|
+
}: Props = $props();
|
|
28
|
+
|
|
29
|
+
const resource = getResource(resourceName);
|
|
30
|
+
const pageTitle = title ?? `${t('common.edit')} ${resource.label} #${id}`;
|
|
31
|
+
const showDelete = canDelete ?? resource.canDelete !== false;
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<div class="space-y-6 {className}">
|
|
35
|
+
<PageHeader title={pageTitle}>
|
|
36
|
+
{#snippet actions()}
|
|
37
|
+
<Button variant="ghost" size="icon" onclick={() => navigate(`/${resourceName}`)}>
|
|
38
|
+
<ArrowLeft class="h-5 w-5" />
|
|
39
|
+
</Button>
|
|
40
|
+
{#if showDelete}
|
|
41
|
+
<Button variant="destructive" size="sm">
|
|
42
|
+
<Trash2 class="h-4 w-4" data-icon="inline-start" /> {t('common.delete')}
|
|
43
|
+
</Button>
|
|
44
|
+
{/if}
|
|
45
|
+
{#if headerActions}
|
|
46
|
+
{@render headerActions()}
|
|
47
|
+
{/if}
|
|
48
|
+
{/snippet}
|
|
49
|
+
</PageHeader>
|
|
50
|
+
|
|
51
|
+
<AutoForm {resourceName} mode="edit" {id} />
|
|
52
|
+
</div>
|
|
@@ -1,41 +1,27 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import
|
|
3
|
-
import { t } from '@svadmin/core
|
|
4
|
-
import { Button } from './ui/button/index.js';
|
|
5
|
-
import { InboxIcon } from 'lucide-svelte';
|
|
2
|
+
import { PackageOpen } from 'lucide-svelte';
|
|
3
|
+
import { t } from '@svadmin/core';
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
let {
|
|
6
|
+
title = t('empty.title'),
|
|
7
|
+
description = t('empty.description'),
|
|
8
|
+
children,
|
|
9
|
+
} = $props<{
|
|
9
10
|
title?: string;
|
|
10
11
|
description?: string;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
let {
|
|
16
|
-
icon: Icon = InboxIcon,
|
|
17
|
-
title,
|
|
18
|
-
description,
|
|
19
|
-
action,
|
|
20
|
-
class: className = '',
|
|
21
|
-
}: Props = $props();
|
|
12
|
+
children?: import('svelte').Snippet;
|
|
13
|
+
}>();
|
|
22
14
|
</script>
|
|
23
15
|
|
|
24
|
-
<div class="flex flex-col items-center justify-center
|
|
25
|
-
<div class="
|
|
26
|
-
<
|
|
16
|
+
<div class="flex flex-col items-center justify-center p-8 text-center min-h-[300px] bg-card rounded-lg border border-dashed border-border/50 shadow-sm transition-all duration-300">
|
|
17
|
+
<div class="h-20 w-20 rounded-full bg-muted flex items-center justify-center mb-4">
|
|
18
|
+
<PackageOpen class="h-10 w-10 text-muted-foreground" />
|
|
27
19
|
</div>
|
|
28
|
-
<h3 class="text-lg font-semibold
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
{description}
|
|
34
|
-
</p>
|
|
35
|
-
{/if}
|
|
36
|
-
{#if action}
|
|
37
|
-
<div class="mt-4">
|
|
38
|
-
{@render action()}
|
|
20
|
+
<h3 class="text-lg font-semibold tracking-tight">{title}</h3>
|
|
21
|
+
<p class="text-sm text-muted-foreground mt-2 max-w-sm">{description}</p>
|
|
22
|
+
{#if children}
|
|
23
|
+
<div class="mt-6">
|
|
24
|
+
{@render children()}
|
|
39
25
|
</div>
|
|
40
26
|
{/if}
|
|
41
27
|
</div>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { AlertTriangle, AlertCircle } from 'lucide-svelte';
|
|
3
|
+
import { t } from '@svadmin/core';
|
|
4
|
+
import { Button } from './ui/button';
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
error,
|
|
8
|
+
title = t('error.title'),
|
|
9
|
+
description = t('error.description'),
|
|
10
|
+
retry,
|
|
11
|
+
} = $props<{
|
|
12
|
+
error?: Error | null;
|
|
13
|
+
title?: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
retry?: () => void;
|
|
16
|
+
}>();
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<div class="flex flex-col items-center justify-center p-8 text-center min-h-[300px] bg-destructive/5 rounded-lg border border-destructive/20 text-destructive transition-all duration-300">
|
|
20
|
+
<div class="h-16 w-16 rounded-full bg-destructive/10 flex items-center justify-center mb-4">
|
|
21
|
+
<AlertTriangle class="h-8 w-8 text-destructive" />
|
|
22
|
+
</div>
|
|
23
|
+
<h3 class="text-lg font-semibold">{title}</h3>
|
|
24
|
+
<p class="text-sm opacity-90 mt-2 max-w-md">
|
|
25
|
+
{error?.message || description}
|
|
26
|
+
</p>
|
|
27
|
+
{#if retry}
|
|
28
|
+
<div class="mt-6">
|
|
29
|
+
<Button variant="outline" class="border-destructive/30 hover:bg-destructive/10 text-destructive" onclick={retry}>
|
|
30
|
+
<AlertCircle class="mr-2 h-4 w-4" />
|
|
31
|
+
{t('buttons.retry')}
|
|
32
|
+
</Button>
|
|
33
|
+
</div>
|
|
34
|
+
{/if}
|
|
35
|
+
</div>
|