@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.
- package/package.json +44 -0
- package/src/app.css +113 -0
- package/src/components/AdminApp.svelte +127 -0
- package/src/components/AutoForm.svelte +192 -0
- package/src/components/AutoTable.svelte +343 -0
- package/src/components/Breadcrumbs.svelte +51 -0
- package/src/components/ConfirmDialog.svelte +45 -0
- package/src/components/EmptyState.svelte +41 -0
- package/src/components/ErrorBoundary.svelte +51 -0
- package/src/components/FieldRenderer.svelte +154 -0
- package/src/components/Header.svelte +66 -0
- package/src/components/Layout.svelte +66 -0
- package/src/components/PageHeader.svelte +39 -0
- package/src/components/ShowPage.svelte +71 -0
- package/src/components/Sidebar.svelte +169 -0
- package/src/components/StatsCard.svelte +49 -0
- package/src/components/Toast.svelte +48 -0
- package/src/components/ui/alert/alert-description.svelte +17 -0
- package/src/components/ui/alert/alert-title.svelte +17 -0
- package/src/components/ui/alert/alert.svelte +44 -0
- package/src/components/ui/alert/index.ts +14 -0
- package/src/components/ui/avatar/avatar.svelte +62 -0
- package/src/components/ui/avatar/index.ts +2 -0
- package/src/components/ui/badge/badge.svelte +49 -0
- package/src/components/ui/badge/index.ts +2 -0
- package/src/components/ui/button/button.svelte +82 -0
- package/src/components/ui/button/index.ts +17 -0
- package/src/components/ui/card/card-action.svelte +23 -0
- package/src/components/ui/card/card-content.svelte +20 -0
- package/src/components/ui/card/card-description.svelte +20 -0
- package/src/components/ui/card/card-footer.svelte +20 -0
- package/src/components/ui/card/card-header.svelte +23 -0
- package/src/components/ui/card/card-title.svelte +15 -0
- package/src/components/ui/card/card.svelte +22 -0
- package/src/components/ui/card/index.ts +25 -0
- package/src/components/ui/checkbox/checkbox.svelte +39 -0
- package/src/components/ui/checkbox/index.ts +6 -0
- package/src/components/ui/dialog/dialog-close.svelte +11 -0
- package/src/components/ui/dialog/dialog-content.svelte +48 -0
- package/src/components/ui/dialog/dialog-description.svelte +17 -0
- package/src/components/ui/dialog/dialog-footer.svelte +32 -0
- package/src/components/ui/dialog/dialog-header.svelte +20 -0
- package/src/components/ui/dialog/dialog-overlay.svelte +17 -0
- package/src/components/ui/dialog/dialog-portal.svelte +7 -0
- package/src/components/ui/dialog/dialog-title.svelte +17 -0
- package/src/components/ui/dialog/dialog-trigger.svelte +11 -0
- package/src/components/ui/dialog/dialog.svelte +7 -0
- package/src/components/ui/dialog/index.ts +34 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-content.svelte +30 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-item.svelte +36 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte +10 -0
- package/src/components/ui/dropdown-menu/dropdown-menu.svelte +41 -0
- package/src/components/ui/dropdown-menu/index.ts +15 -0
- package/src/components/ui/input/index.ts +7 -0
- package/src/components/ui/input/input.svelte +48 -0
- package/src/components/ui/select/index.ts +2 -0
- package/src/components/ui/select/select.svelte +34 -0
- package/src/components/ui/separator/index.ts +7 -0
- package/src/components/ui/separator/separator.svelte +23 -0
- package/src/components/ui/sheet/index.ts +2 -0
- package/src/components/ui/sheet/sheet.svelte +77 -0
- package/src/components/ui/skeleton/index.ts +2 -0
- package/src/components/ui/skeleton/skeleton.svelte +21 -0
- package/src/components/ui/switch/index.ts +2 -0
- package/src/components/ui/switch/switch.svelte +49 -0
- package/src/components/ui/table/index.ts +28 -0
- package/src/components/ui/table/table-body.svelte +15 -0
- package/src/components/ui/table/table-caption.svelte +20 -0
- package/src/components/ui/table/table-cell.svelte +15 -0
- package/src/components/ui/table/table-footer.svelte +20 -0
- package/src/components/ui/table/table-head.svelte +15 -0
- package/src/components/ui/table/table-header.svelte +20 -0
- package/src/components/ui/table/table-row.svelte +15 -0
- package/src/components/ui/table/table.svelte +17 -0
- package/src/components/ui/tabs/index.ts +15 -0
- package/src/components/ui/tabs/tabs-content.svelte +30 -0
- package/src/components/ui/tabs/tabs-list.svelte +26 -0
- package/src/components/ui/tabs/tabs-trigger.svelte +37 -0
- package/src/components/ui/tabs/tabs.svelte +27 -0
- package/src/components/ui/textarea/index.ts +2 -0
- package/src/components/ui/textarea/textarea.svelte +24 -0
- package/src/components/ui/tooltip/index.ts +19 -0
- package/src/components/ui/tooltip/tooltip-content.svelte +52 -0
- package/src/components/ui/tooltip/tooltip-portal.svelte +7 -0
- package/src/components/ui/tooltip/tooltip-provider.svelte +7 -0
- package/src/components/ui/tooltip/tooltip-trigger.svelte +7 -0
- package/src/components/ui/tooltip/tooltip.svelte +10 -0
- package/src/index.ts +43 -0
- 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>
|