@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.
Files changed (41) hide show
  1. package/package.json +3 -2
  2. package/src/components/AdminApp.svelte +4 -3
  3. package/src/components/Authenticated.svelte +44 -13
  4. package/src/components/AutoForm.svelte +5 -5
  5. package/src/components/AutoSaveIndicator.svelte +30 -0
  6. package/src/components/AutoTable.svelte +81 -10
  7. package/src/components/Breadcrumbs.svelte +2 -0
  8. package/src/components/CatchAllNavigate.svelte +21 -0
  9. package/src/components/ConfirmDialog.svelte +1 -1
  10. package/src/components/CreatePage.svelte +42 -0
  11. package/src/components/EditPage.svelte +52 -0
  12. package/src/components/EmptyState.svelte +17 -31
  13. package/src/components/ErrorComponent.svelte +35 -0
  14. package/src/components/Header.svelte +28 -59
  15. package/src/components/ListPage.svelte +47 -0
  16. package/src/components/LiveIndicator.svelte +115 -0
  17. package/src/components/NavigateToResource.svelte +20 -0
  18. package/src/components/PageSkeleton.svelte +70 -0
  19. package/src/components/ShowPage.svelte +3 -3
  20. package/src/components/Sidebar.svelte +29 -10
  21. package/src/components/ThemedTitle.svelte +32 -0
  22. package/src/components/Toast.svelte +21 -11
  23. package/src/components/buttons/CloneButton.svelte +1 -1
  24. package/src/components/buttons/CreateButton.svelte +1 -1
  25. package/src/components/buttons/DeleteButton.svelte +3 -4
  26. package/src/components/buttons/EditButton.svelte +1 -1
  27. package/src/components/buttons/ImportButton.svelte +29 -7
  28. package/src/components/buttons/RefreshButton.svelte +5 -4
  29. package/src/components/fields/BooleanField.svelte +13 -0
  30. package/src/components/fields/DateField.svelte +6 -0
  31. package/src/components/fields/EmailField.svelte +15 -0
  32. package/src/components/fields/FileField.svelte +19 -0
  33. package/src/components/fields/ImageField.svelte +14 -0
  34. package/src/components/fields/MarkdownField.svelte +19 -0
  35. package/src/components/fields/NumberField.svelte +6 -0
  36. package/src/components/fields/TagField.svelte +17 -0
  37. package/src/components/fields/TextField.svelte +16 -0
  38. package/src/components/fields/UrlField.svelte +20 -0
  39. package/src/components/ui/avatar/avatar.svelte +2 -1
  40. package/src/index.ts +22 -0
  41. 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.6",
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.5",
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">Welcome to {title}</h1>
113
- <p class="text-gray-500">Select a resource from the sidebar to get started.</p>
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">Redirecting to login...</p>
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 { children, fallback, loading } = $props<{
6
- children: Snippet;
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 auth = useIsAuthenticated();
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 auth.isLoading}
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 auth.isAuthenticated}
23
- {@render children()}
24
- {:else if fallback}
25
- {@render fallback()}
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?.isLoading : false);
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
- let { resourceName } = $props<{ resourceName: string }>();
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 = resource.fields.filter(f => f.showInList !== false);
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 query = useList({
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 deleteMutation = useDelete(resourceName);
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 type { Snippet } from 'svelte';
3
- import { t } from '@svadmin/core/i18n';
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
- interface Props {
8
- icon?: typeof InboxIcon;
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
- action?: Snippet;
12
- class?: string;
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 py-16 text-center {className}">
25
- <div class="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
26
- <Icon class="h-8 w-8 text-muted-foreground" />
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 text-foreground">
29
- {title ?? t('common.noData')}
30
- </h3>
31
- {#if description}
32
- <p class="mt-1 text-sm text-muted-foreground max-w-sm">
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>