@svadmin/ui 0.0.1

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 (89) hide show
  1. package/package.json +44 -0
  2. package/src/app.css +113 -0
  3. package/src/components/AdminApp.svelte +127 -0
  4. package/src/components/AutoForm.svelte +192 -0
  5. package/src/components/AutoTable.svelte +343 -0
  6. package/src/components/Breadcrumbs.svelte +51 -0
  7. package/src/components/ConfirmDialog.svelte +45 -0
  8. package/src/components/EmptyState.svelte +41 -0
  9. package/src/components/ErrorBoundary.svelte +51 -0
  10. package/src/components/FieldRenderer.svelte +154 -0
  11. package/src/components/Header.svelte +66 -0
  12. package/src/components/Layout.svelte +66 -0
  13. package/src/components/PageHeader.svelte +39 -0
  14. package/src/components/ShowPage.svelte +71 -0
  15. package/src/components/Sidebar.svelte +169 -0
  16. package/src/components/StatsCard.svelte +49 -0
  17. package/src/components/Toast.svelte +48 -0
  18. package/src/components/ui/alert/alert-description.svelte +17 -0
  19. package/src/components/ui/alert/alert-title.svelte +17 -0
  20. package/src/components/ui/alert/alert.svelte +44 -0
  21. package/src/components/ui/alert/index.ts +14 -0
  22. package/src/components/ui/avatar/avatar.svelte +62 -0
  23. package/src/components/ui/avatar/index.ts +2 -0
  24. package/src/components/ui/badge/badge.svelte +49 -0
  25. package/src/components/ui/badge/index.ts +2 -0
  26. package/src/components/ui/button/button.svelte +82 -0
  27. package/src/components/ui/button/index.ts +17 -0
  28. package/src/components/ui/card/card-action.svelte +23 -0
  29. package/src/components/ui/card/card-content.svelte +20 -0
  30. package/src/components/ui/card/card-description.svelte +20 -0
  31. package/src/components/ui/card/card-footer.svelte +20 -0
  32. package/src/components/ui/card/card-header.svelte +23 -0
  33. package/src/components/ui/card/card-title.svelte +15 -0
  34. package/src/components/ui/card/card.svelte +22 -0
  35. package/src/components/ui/card/index.ts +25 -0
  36. package/src/components/ui/checkbox/checkbox.svelte +39 -0
  37. package/src/components/ui/checkbox/index.ts +6 -0
  38. package/src/components/ui/dialog/dialog-close.svelte +11 -0
  39. package/src/components/ui/dialog/dialog-content.svelte +48 -0
  40. package/src/components/ui/dialog/dialog-description.svelte +17 -0
  41. package/src/components/ui/dialog/dialog-footer.svelte +32 -0
  42. package/src/components/ui/dialog/dialog-header.svelte +20 -0
  43. package/src/components/ui/dialog/dialog-overlay.svelte +17 -0
  44. package/src/components/ui/dialog/dialog-portal.svelte +7 -0
  45. package/src/components/ui/dialog/dialog-title.svelte +17 -0
  46. package/src/components/ui/dialog/dialog-trigger.svelte +11 -0
  47. package/src/components/ui/dialog/dialog.svelte +7 -0
  48. package/src/components/ui/dialog/index.ts +34 -0
  49. package/src/components/ui/dropdown-menu/dropdown-menu-content.svelte +30 -0
  50. package/src/components/ui/dropdown-menu/dropdown-menu-item.svelte +36 -0
  51. package/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte +10 -0
  52. package/src/components/ui/dropdown-menu/dropdown-menu.svelte +41 -0
  53. package/src/components/ui/dropdown-menu/index.ts +15 -0
  54. package/src/components/ui/input/index.ts +7 -0
  55. package/src/components/ui/input/input.svelte +48 -0
  56. package/src/components/ui/select/index.ts +2 -0
  57. package/src/components/ui/select/select.svelte +34 -0
  58. package/src/components/ui/separator/index.ts +7 -0
  59. package/src/components/ui/separator/separator.svelte +23 -0
  60. package/src/components/ui/sheet/index.ts +2 -0
  61. package/src/components/ui/sheet/sheet.svelte +77 -0
  62. package/src/components/ui/skeleton/index.ts +2 -0
  63. package/src/components/ui/skeleton/skeleton.svelte +21 -0
  64. package/src/components/ui/switch/index.ts +2 -0
  65. package/src/components/ui/switch/switch.svelte +49 -0
  66. package/src/components/ui/table/index.ts +28 -0
  67. package/src/components/ui/table/table-body.svelte +15 -0
  68. package/src/components/ui/table/table-caption.svelte +20 -0
  69. package/src/components/ui/table/table-cell.svelte +15 -0
  70. package/src/components/ui/table/table-footer.svelte +20 -0
  71. package/src/components/ui/table/table-head.svelte +15 -0
  72. package/src/components/ui/table/table-header.svelte +20 -0
  73. package/src/components/ui/table/table-row.svelte +15 -0
  74. package/src/components/ui/table/table.svelte +17 -0
  75. package/src/components/ui/tabs/index.ts +15 -0
  76. package/src/components/ui/tabs/tabs-content.svelte +30 -0
  77. package/src/components/ui/tabs/tabs-list.svelte +26 -0
  78. package/src/components/ui/tabs/tabs-trigger.svelte +37 -0
  79. package/src/components/ui/tabs/tabs.svelte +27 -0
  80. package/src/components/ui/textarea/index.ts +2 -0
  81. package/src/components/ui/textarea/textarea.svelte +24 -0
  82. package/src/components/ui/tooltip/index.ts +19 -0
  83. package/src/components/ui/tooltip/tooltip-content.svelte +52 -0
  84. package/src/components/ui/tooltip/tooltip-portal.svelte +7 -0
  85. package/src/components/ui/tooltip/tooltip-provider.svelte +7 -0
  86. package/src/components/ui/tooltip/tooltip-trigger.svelte +7 -0
  87. package/src/components/ui/tooltip/tooltip.svelte +10 -0
  88. package/src/index.ts +43 -0
  89. package/src/utils.ts +28 -0
@@ -0,0 +1,343 @@
1
+ <script lang="ts">
2
+ import { useList, useDelete, getResource } from '@svadmin/core';
3
+ import type { Pagination, Sort, Filter } from '@svadmin/core';
4
+ import { navigate } from '@svadmin/core/router';
5
+ import { canAccess } from '@svadmin/core/permissions';
6
+ import { readURLState, writeURLState } from '@svadmin/core';
7
+ import { t } from '@svadmin/core/i18n';
8
+ import { Button } from './ui/button/index.js';
9
+ import { Input } from './ui/input/index.js';
10
+ import { Checkbox } from './ui/checkbox/index.js';
11
+ import { Badge } from './ui/badge/index.js';
12
+ import * as Table from './ui/table/index.js';
13
+ import { Plus, Pencil, Trash2, ChevronLeft, ChevronRight, Search, Loader2, Download, Upload } from 'lucide-svelte';
14
+ import ConfirmDialog from './ConfirmDialog.svelte';
15
+
16
+ let { resourceName } = $props<{ resourceName: string }>();
17
+
18
+ const resource = getResource(resourceName);
19
+ const primaryKey = $derived(resource.primaryKey ?? 'id');
20
+
21
+ // URL state init
22
+ const urlState = readURLState();
23
+
24
+ let pagination = $state<Pagination>({
25
+ current: urlState.page ?? 1,
26
+ pageSize: urlState.pageSize ?? resource.pageSize ?? 10,
27
+ });
28
+ let sorters = $state<Sort[]>(
29
+ urlState.sortField
30
+ ? [{ field: urlState.sortField, order: urlState.sortOrder ?? 'asc' }]
31
+ : resource.defaultSort ? [resource.defaultSort] : []
32
+ );
33
+ let filters = $state<Filter[]>([]);
34
+ let searchText = $state(urlState.search ?? '');
35
+
36
+ // Batch selection
37
+ let selectedIds = $state<Set<string | number>>(new Set());
38
+ let selectAll = $state(false);
39
+
40
+ // Confirm dialog
41
+ let confirmOpen = $state(false);
42
+ let confirmMessage = $state('');
43
+ let confirmAction = $state<() => void>(() => {});
44
+
45
+ // URL sync
46
+ $effect(() => {
47
+ writeURLState({
48
+ page: pagination.current,
49
+ pageSize: pagination.pageSize,
50
+ sortField: sorters[0]?.field,
51
+ sortOrder: sorters[0]?.order,
52
+ search: searchText || undefined,
53
+ });
54
+ });
55
+
56
+ const searchableFields = resource.fields.filter(f => f.searchable);
57
+ const listFields = resource.fields.filter(f => f.showInList !== false);
58
+
59
+ const activeFilters = $derived.by(() => {
60
+ const result: Filter[] = [...filters];
61
+ if (searchText.trim() && searchableFields.length > 0) {
62
+ result.push({ field: searchableFields[0].key, operator: 'contains', value: searchText });
63
+ }
64
+ return result;
65
+ });
66
+
67
+ const query = useList({
68
+ resource: resourceName,
69
+ pagination,
70
+ sorters,
71
+ filters: activeFilters,
72
+ });
73
+
74
+ const deleteMutation = useDelete(resourceName);
75
+
76
+ function toggleSort(field: string) {
77
+ const existing = sorters.find(s => s.field === field);
78
+ if (!existing) {
79
+ sorters = [{ field, order: 'asc' }];
80
+ } else if (existing.order === 'asc') {
81
+ sorters = [{ field, order: 'desc' }];
82
+ } else {
83
+ sorters = [];
84
+ }
85
+ }
86
+
87
+ function getSortIcon(field: string): string {
88
+ const s = sorters.find(ss => ss.field === field);
89
+ if (!s) return '⇅';
90
+ return s.order === 'asc' ? '↑' : '↓';
91
+ }
92
+
93
+ // Batch operations
94
+ function toggleSelect(id: string | number) {
95
+ const next = new Set(selectedIds);
96
+ if (next.has(id)) next.delete(id); else next.add(id);
97
+ selectedIds = next;
98
+ }
99
+
100
+ function toggleSelectAll() {
101
+ if (selectAll) {
102
+ selectedIds = new Set();
103
+ selectAll = false;
104
+ } else {
105
+ const allIds = ($query.data?.data ?? []).map(r => r[primaryKey] as string | number);
106
+ selectedIds = new Set(allIds);
107
+ selectAll = true;
108
+ }
109
+ }
110
+
111
+ function confirmDelete(id: string | number) {
112
+ confirmMessage = t('common.deleteConfirm');
113
+ confirmAction = async () => {
114
+ await $deleteMutation.mutateAsync(id);
115
+ confirmOpen = false;
116
+ };
117
+ confirmOpen = true;
118
+ }
119
+
120
+ function confirmBatchDelete() {
121
+ confirmMessage = t('common.batchDeleteConfirm', { count: selectedIds.size });
122
+ confirmAction = async () => {
123
+ for (const id of selectedIds) {
124
+ await $deleteMutation.mutateAsync(id);
125
+ }
126
+ selectedIds = new Set();
127
+ selectAll = false;
128
+ confirmOpen = false;
129
+ };
130
+ confirmOpen = true;
131
+ }
132
+
133
+ // CSV Export
134
+ function exportCSV() {
135
+ const data = $query.data?.data ?? [];
136
+ if (data.length === 0) return;
137
+
138
+ const headers = listFields.map(f => f.label);
139
+ const rows = data.map(record =>
140
+ listFields.map(f => {
141
+ const val = record[f.key];
142
+ if (val == null) return '';
143
+ if (typeof val === 'object') return JSON.stringify(val);
144
+ return String(val);
145
+ })
146
+ );
147
+
148
+ const csv = [headers.join(','), ...rows.map(r => r.map(v => `"${v.replace(/"/g, '""')}"`).join(','))].join('\n');
149
+ const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
150
+ const link = document.createElement('a');
151
+ link.href = URL.createObjectURL(blob);
152
+ link.download = `${resourceName}_${new Date().toISOString().slice(0, 10)}.csv`;
153
+ link.click();
154
+ URL.revokeObjectURL(link.href);
155
+ }
156
+
157
+ // CSV Import
158
+ function handleImportCSV() {
159
+ const input = document.createElement('input');
160
+ input.type = 'file';
161
+ input.accept = '.csv';
162
+ input.onchange = async (e: Event) => {
163
+ const file = (e.target as HTMLInputElement).files?.[0];
164
+ if (!file) return;
165
+ alert(`Import: selected ${file.name}, batch create API needed`);
166
+ };
167
+ input.click();
168
+ }
169
+
170
+ // Permissions
171
+ const canCreate = canAccess(resourceName, 'create').can && resource.canCreate !== false;
172
+ const canEdit = canAccess(resourceName, 'edit').can && resource.canEdit !== false;
173
+ const canDelete = canAccess(resourceName, 'delete').can && resource.canDelete !== false;
174
+ const canExport = canAccess(resourceName, 'export').can;
175
+
176
+ const totalPages = $derived(Math.ceil(($query.data?.total ?? 0) / (pagination.pageSize ?? 10)));
177
+ </script>
178
+
179
+ <div class="space-y-4">
180
+ <!-- Header -->
181
+ <div class="flex items-center justify-between">
182
+ <h1 class="text-2xl font-bold text-foreground">{resource.label}</h1>
183
+ <div class="flex items-center gap-2">
184
+ {#if canExport}
185
+ <Button variant="outline" size="sm" onclick={exportCSV}>
186
+ <Download class="h-4 w-4" data-icon="inline-start" /> {t('common.export')}
187
+ </Button>
188
+ <Button variant="outline" size="sm" onclick={handleImportCSV}>
189
+ <Upload class="h-4 w-4" data-icon="inline-start" /> {t('common.import')}
190
+ </Button>
191
+ {/if}
192
+ {#if selectedIds.size > 0 && canDelete}
193
+ <Button variant="destructive" size="sm" onclick={confirmBatchDelete}>
194
+ <Trash2 class="h-4 w-4" data-icon="inline-start" /> {t('common.batchDelete', { count: selectedIds.size })}
195
+ </Button>
196
+ {/if}
197
+ {#if canCreate}
198
+ <Button onclick={() => navigate(`/${resourceName}/create`)}>
199
+ <Plus class="h-4 w-4" data-icon="inline-start" /> {t('common.create')}
200
+ </Button>
201
+ {/if}
202
+ </div>
203
+ </div>
204
+
205
+ <!-- Search -->
206
+ {#if searchableFields.length > 0}
207
+ <div class="relative max-w-sm">
208
+ <Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
209
+ <Input
210
+ type="text"
211
+ bind:value={searchText}
212
+ oninput={() => { pagination = { ...pagination, current: 1 }; }}
213
+ placeholder={t('common.search')}
214
+ class="pl-10"
215
+ />
216
+ </div>
217
+ {/if}
218
+
219
+ <!-- Table -->
220
+ <div class="rounded-xl border border-border bg-card shadow-sm">
221
+ {#if $query.isLoading}
222
+ <div class="flex h-64 items-center justify-center">
223
+ <Loader2 class="h-6 w-6 animate-spin text-primary" />
224
+ </div>
225
+ {:else if $query.error}
226
+ <div class="flex h-64 items-center justify-center text-destructive text-sm">
227
+ {t('common.loadFailed', { message: ($query.error as Error).message })}
228
+ </div>
229
+ {:else}
230
+ <Table.Root>
231
+ <Table.Header>
232
+ <Table.Row class="bg-muted/50 hover:bg-muted/50">
233
+ {#if canDelete}
234
+ <Table.Head class="w-10">
235
+ <Checkbox checked={selectAll} onCheckedChange={() => toggleSelectAll()} />
236
+ </Table.Head>
237
+ {/if}
238
+ {#each listFields as field}
239
+ <Table.Head style={field.width ? `width:${field.width}` : undefined}>
240
+ <button class="flex items-center gap-1 hover:text-foreground" onclick={() => toggleSort(field.key)}>
241
+ {field.label}
242
+ <span class="text-xs opacity-50">{getSortIcon(field.key)}</span>
243
+ </button>
244
+ </Table.Head>
245
+ {/each}
246
+ <Table.Head class="text-right">{t('common.actions')}</Table.Head>
247
+ </Table.Row>
248
+ </Table.Header>
249
+ <Table.Body>
250
+ {#each $query.data?.data ?? [] as record}
251
+ {@const id = record[primaryKey] as string | number}
252
+ <Table.Row class={selectedIds.has(id) ? 'bg-accent' : ''}>
253
+ {#if canDelete}
254
+ <Table.Cell>
255
+ <Checkbox checked={selectedIds.has(id)} onCheckedChange={() => toggleSelect(id)} />
256
+ </Table.Cell>
257
+ {/if}
258
+ {#each listFields as field}
259
+ <Table.Cell>
260
+ {#if field.type === 'boolean'}
261
+ <span class="inline-block h-2 w-2 rounded-full {record[field.key] ? 'bg-green-500' : 'bg-muted-foreground/30'}"></span>
262
+ {:else if field.type === 'date' && record[field.key]}
263
+ {new Date(record[field.key] as string).toLocaleDateString('zh-CN')}
264
+ {:else if field.type === 'tags' && Array.isArray(record[field.key])}
265
+ <div class="flex flex-wrap gap-1">
266
+ {#each (record[field.key] as string[]).slice(0, 3) as tag}
267
+ <Badge variant="secondary">{tag}</Badge>
268
+ {/each}
269
+ </div>
270
+ {:else if field.type === 'select' && field.options}
271
+ {@const opt = field.options.find(o => o.value === record[field.key])}
272
+ <Badge variant="outline">{opt?.label ?? record[field.key] ?? '—'}</Badge>
273
+ {:else}
274
+ {record[field.key] ?? '—'}
275
+ {/if}
276
+ </Table.Cell>
277
+ {/each}
278
+ <Table.Cell class="text-right">
279
+ <div class="flex items-center justify-end gap-1">
280
+ {#if canEdit}
281
+ <Button
282
+ variant="ghost" size="icon-sm"
283
+ onclick={() => navigate(`/${resourceName}/edit/${id}`)}
284
+ title={t('common.edit')}
285
+ >
286
+ <Pencil class="h-4 w-4" />
287
+ </Button>
288
+ {/if}
289
+ {#if canDelete}
290
+ <Button
291
+ variant="ghost" size="icon-sm"
292
+ onclick={() => confirmDelete(id)}
293
+ title={t('common.delete')}
294
+ class="hover:text-destructive"
295
+ >
296
+ <Trash2 class="h-4 w-4" />
297
+ </Button>
298
+ {/if}
299
+ </div>
300
+ </Table.Cell>
301
+ </Table.Row>
302
+ {:else}
303
+ <Table.Row>
304
+ <Table.Cell colspan={listFields.length + (canDelete ? 2 : 1)} class="h-24 text-center text-muted-foreground">
305
+ {t('common.noData')}
306
+ </Table.Cell>
307
+ </Table.Row>
308
+ {/each}
309
+ </Table.Body>
310
+ </Table.Root>
311
+ {/if}
312
+ </div>
313
+
314
+ <!-- Pagination -->
315
+ <div class="flex items-center justify-between text-sm text-muted-foreground">
316
+ <span>{t('common.total', { total: $query.data?.total ?? 0 })}</span>
317
+ <div class="flex items-center gap-2">
318
+ <Button
319
+ variant="outline" size="icon-sm"
320
+ onclick={() => { pagination = { ...pagination, current: Math.max(1, (pagination.current ?? 1) - 1) }; }}
321
+ disabled={(pagination.current ?? 1) <= 1}
322
+ >
323
+ <ChevronLeft class="h-4 w-4" />
324
+ </Button>
325
+ <span>{t('common.page', { current: pagination.current ?? 1, total: totalPages || 1 })}</span>
326
+ <Button
327
+ variant="outline" size="icon-sm"
328
+ onclick={() => { pagination = { ...pagination, current: Math.min(totalPages, (pagination.current ?? 1) + 1) }; }}
329
+ disabled={(pagination.current ?? 1) >= totalPages}
330
+ >
331
+ <ChevronRight class="h-4 w-4" />
332
+ </Button>
333
+ </div>
334
+ </div>
335
+ </div>
336
+
337
+ <ConfirmDialog
338
+ open={confirmOpen}
339
+ message={confirmMessage}
340
+ confirmText={t('common.delete')}
341
+ onconfirm={confirmAction}
342
+ oncancel={() => { confirmOpen = false; }}
343
+ />
@@ -0,0 +1,51 @@
1
+ <script lang="ts">
2
+ import { getResources } from '@svadmin/core';
3
+ import { currentPath } from '@svadmin/core/router';
4
+ import { t } from '@svadmin/core/i18n';
5
+
6
+ const resources = getResources();
7
+ let path = $state(currentPath());
8
+
9
+ $effect(() => {
10
+ function onHash() { path = currentPath(); }
11
+ window.addEventListener('hashchange', onHash);
12
+ return () => window.removeEventListener('hashchange', onHash);
13
+ });
14
+
15
+ interface Crumb { label: string; href: string; }
16
+
17
+ const crumbs = $derived.by(() => {
18
+ const result: Crumb[] = [{ label: t('common.home'), href: '#/' }];
19
+ if (path === '/') return result;
20
+
21
+ const segments = path.split('/').filter(Boolean);
22
+ const resourceName = segments[0];
23
+ const res = resources.find(r => r.name === resourceName);
24
+
25
+ if (res) {
26
+ result.push({ label: res.label, href: `#/${res.name}` });
27
+
28
+ if (segments[1] === 'create') {
29
+ result.push({ label: t('common.create'), href: `#/${res.name}/create` });
30
+ } else if (segments[1] === 'edit' && segments[2]) {
31
+ result.push({ label: `${t('common.edit')} #${segments[2]}`, href: `#/${res.name}/edit/${segments[2]}` });
32
+ }
33
+ }
34
+ return result;
35
+ });
36
+ </script>
37
+
38
+ {#if crumbs.length > 1}
39
+ <nav class="mb-4 flex items-center gap-1.5 text-sm text-gray-500">
40
+ {#each crumbs as crumb, i}
41
+ {#if i > 0}
42
+ <span class="text-gray-300">/</span>
43
+ {/if}
44
+ {#if i === crumbs.length - 1}
45
+ <span class="font-medium text-gray-700">{crumb.label}</span>
46
+ {:else}
47
+ <a href={crumb.href} class="hover:text-primary transition">{crumb.label}</a>
48
+ {/if}
49
+ {/each}
50
+ </nav>
51
+ {/if}
@@ -0,0 +1,45 @@
1
+ <script lang="ts">
2
+ import * as Dialog from './ui/dialog/index.js';
3
+ import { Button } from './ui/button/index.js';
4
+ import { t } from '@svadmin/core/i18n';
5
+
6
+ let { open = false, title, message = '',
7
+ confirmText, cancelText, variant = 'danger',
8
+ onconfirm, oncancel } = $props<{
9
+ open: boolean;
10
+ title?: string;
11
+ message?: string;
12
+ confirmText?: string;
13
+ cancelText?: string;
14
+ variant?: 'danger' | 'warning' | 'info';
15
+ onconfirm: () => void;
16
+ oncancel: () => void;
17
+ }>();
18
+
19
+ const variantMap = {
20
+ danger: 'destructive' as const,
21
+ warning: 'destructive' as const,
22
+ info: 'default' as const,
23
+ };
24
+
25
+ const resolvedTitle = $derived(title ?? t('common.confirmAction'));
26
+ const resolvedConfirmText = $derived(confirmText ?? t('common.confirm'));
27
+ const resolvedCancelText = $derived(cancelText ?? t('common.cancel'));
28
+ </script>
29
+
30
+ <Dialog.Root bind:open onOpenChange={(v: boolean) => { if (!v) oncancel(); }}>
31
+ <Dialog.Content showCloseButton={false} class="sm:max-w-md">
32
+ <Dialog.Header>
33
+ <Dialog.Title>{resolvedTitle}</Dialog.Title>
34
+ <Dialog.Description>{message}</Dialog.Description>
35
+ </Dialog.Header>
36
+ <Dialog.Footer>
37
+ <Button variant="outline" onclick={oncancel}>
38
+ {resolvedCancelText}
39
+ </Button>
40
+ <Button variant={variantMap[variant]} onclick={onconfirm}>
41
+ {resolvedConfirmText}
42
+ </Button>
43
+ </Dialog.Footer>
44
+ </Dialog.Content>
45
+ </Dialog.Root>
@@ -0,0 +1,41 @@
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';
6
+
7
+ interface Props {
8
+ icon?: typeof InboxIcon;
9
+ title?: string;
10
+ 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();
22
+ </script>
23
+
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" />
27
+ </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()}
39
+ </div>
40
+ {/if}
41
+ </div>
@@ -0,0 +1,51 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { AlertTriangle } from 'lucide-svelte';
4
+ import { Button } from './ui/button/index.js';
5
+ import { t } from '@svadmin/core/i18n';
6
+
7
+ let { children }: { children: Snippet } = $props();
8
+
9
+ let error = $state<Error | null>(null);
10
+
11
+ function handleError(e: ErrorEvent) {
12
+ error = e.error instanceof Error ? e.error : new Error(String(e.error));
13
+ e.preventDefault();
14
+ }
15
+
16
+ function handleRejection(e: PromiseRejectionEvent) {
17
+ error = e.reason instanceof Error ? e.reason : new Error(String(e.reason));
18
+ e.preventDefault();
19
+ }
20
+
21
+ $effect(() => {
22
+ window.addEventListener('error', handleError);
23
+ window.addEventListener('unhandledrejection', handleRejection);
24
+ return () => {
25
+ window.removeEventListener('error', handleError);
26
+ window.removeEventListener('unhandledrejection', handleRejection);
27
+ };
28
+ });
29
+
30
+ function handleRetry() {
31
+ error = null;
32
+ window.location.reload();
33
+ }
34
+ </script>
35
+
36
+ {#if error}
37
+ <div class="flex h-screen items-center justify-center bg-background">
38
+ <div class="max-w-md text-center space-y-4">
39
+ <div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
40
+ <AlertTriangle class="h-8 w-8 text-destructive" />
41
+ </div>
42
+ <h2 class="text-xl font-bold text-foreground">{t('common.error')}</h2>
43
+ <p class="text-sm text-muted-foreground">{error.message}</p>
44
+ <Button onclick={handleRetry}>
45
+ {t('common.retry')}
46
+ </Button>
47
+ </div>
48
+ </div>
49
+ {:else}
50
+ {@render children()}
51
+ {/if}
@@ -0,0 +1,154 @@
1
+ <script lang="ts">
2
+ import type { FieldDefinition } from '@svadmin/core';
3
+ import { t } from '@svadmin/core/i18n';
4
+
5
+ let { field, value, onchange } = $props<{
6
+ field: FieldDefinition;
7
+ value: unknown;
8
+ onchange: (val: unknown) => void;
9
+ }>();
10
+
11
+ function handleInput(e: Event) {
12
+ const target = e.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
13
+ if (field.type === 'number') {
14
+ onchange(parseFloat(target.value) || 0);
15
+ } else if (field.type === 'boolean') {
16
+ onchange((target as HTMLInputElement).checked);
17
+ } else {
18
+ onchange(target.value);
19
+ }
20
+ }
21
+ </script>
22
+
23
+ <div class="space-y-1.5">
24
+ <label class="block text-sm font-medium text-gray-700" for={field.key}>
25
+ {field.label}
26
+ {#if field.required}
27
+ <span class="text-red-500">*</span>
28
+ {/if}
29
+ </label>
30
+
31
+ {#if field.type === 'text' || field.type === 'image'}
32
+ <input
33
+ id={field.key}
34
+ type="text"
35
+ value={value as string ?? ''}
36
+ oninput={handleInput}
37
+ required={field.required}
38
+ class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
39
+ placeholder={t('field.enterValue', { label: field.label })}
40
+ />
41
+
42
+ {:else if field.type === 'number'}
43
+ <input
44
+ id={field.key}
45
+ type="number"
46
+ value={value as number ?? 0}
47
+ oninput={handleInput}
48
+ required={field.required}
49
+ class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
50
+ />
51
+
52
+ {:else if field.type === 'textarea' || field.type === 'richtext'}
53
+ <textarea
54
+ id={field.key}
55
+ value={value as string ?? ''}
56
+ oninput={handleInput}
57
+ required={field.required}
58
+ rows={field.type === 'richtext' ? 10 : 4}
59
+ class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary resize-y"
60
+ placeholder={t('field.enterValue', { label: field.label })}
61
+ ></textarea>
62
+
63
+ {:else if field.type === 'select'}
64
+ <select
65
+ id={field.key}
66
+ value={value as string ?? ''}
67
+ onchange={handleInput}
68
+ required={field.required}
69
+ class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary bg-white"
70
+ >
71
+ <option value="">{t('field.selectPlaceholder')}</option>
72
+ {#each field.options ?? [] as opt}
73
+ <option value={opt.value}>{opt.label}</option>
74
+ {/each}
75
+ </select>
76
+
77
+ {:else if field.type === 'boolean'}
78
+ <label class="relative inline-flex cursor-pointer items-center">
79
+ <input
80
+ type="checkbox"
81
+ checked={value as boolean ?? false}
82
+ onchange={handleInput}
83
+ class="peer sr-only"
84
+ />
85
+ <div class="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-primary peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:ring-2 peer-focus:ring-primary/20"></div>
86
+ </label>
87
+
88
+ {:else if field.type === 'tags'}
89
+ {@const tags = (value as string[] ?? [])}
90
+ <div class="space-y-2">
91
+ <div class="flex flex-wrap gap-1.5">
92
+ {#each tags as tag, i}
93
+ <span class="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2.5 py-0.5 text-xs font-medium text-primary-700">
94
+ {tag}
95
+ <button
96
+ type="button"
97
+ onclick={() => onchange(tags.filter((_, idx) => idx !== i))}
98
+ class="text-primary-400 hover:text-primary-700"
99
+ >×</button>
100
+ </span>
101
+ {/each}
102
+ </div>
103
+ <input
104
+ type="text"
105
+ placeholder={t('field.tagsPlaceholder')}
106
+ class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
107
+ onkeydown={(e) => {
108
+ if (e.key === 'Enter') {
109
+ e.preventDefault();
110
+ const input = e.target as HTMLInputElement;
111
+ if (input.value.trim()) {
112
+ onchange([...tags, input.value.trim()]);
113
+ input.value = '';
114
+ }
115
+ }
116
+ }}
117
+ />
118
+ </div>
119
+
120
+ {:else if field.type === 'date'}
121
+ <input
122
+ id={field.key}
123
+ type="date"
124
+ value={value as string ?? ''}
125
+ oninput={handleInput}
126
+ required={field.required}
127
+ class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
128
+ />
129
+
130
+ {:else if field.type === 'json'}
131
+ <textarea
132
+ id={field.key}
133
+ value={typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
134
+ oninput={(e) => {
135
+ try {
136
+ onchange(JSON.parse((e.target as HTMLTextAreaElement).value));
137
+ } catch (err) {
138
+ console.debug('[FieldRenderer] Invalid JSON input:', err);
139
+ }
140
+ }}
141
+ rows={6}
142
+ class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary resize-y"
143
+ ></textarea>
144
+
145
+ {:else}
146
+ <input
147
+ id={field.key}
148
+ type="text"
149
+ value={value as string ?? ''}
150
+ oninput={handleInput}
151
+ class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
152
+ />
153
+ {/if}
154
+ </div>