@svadmin/ui 0.3.0 → 0.5.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 +9 -4
- package/src/actions.ts +110 -0
- package/src/components/AdminApp.svelte +7 -4
- package/src/components/Authenticated.svelte +1 -7
- package/src/components/AutoForm.svelte +158 -69
- package/src/components/AutoSaveIndicator.svelte +19 -14
- package/src/components/AutoTable.svelte +465 -243
- package/src/components/Breadcrumbs.svelte +20 -17
- package/src/components/CanAccess.svelte +6 -4
- package/src/components/ComboboxField.svelte +122 -0
- package/src/components/CommandPalette.svelte +93 -0
- package/src/components/ConfigErrorScreen.svelte +53 -151
- package/src/components/ConfirmDialog.svelte +25 -17
- package/src/components/CreatePage.svelte +3 -2
- package/src/components/DevTools.svelte +99 -230
- package/src/components/DrawerForm.svelte +19 -10
- package/src/components/EditPage.svelte +25 -6
- package/src/components/EmptyState.svelte +5 -1
- package/src/components/ErrorBoundary.svelte +38 -40
- package/src/components/ErrorComponent.svelte +18 -17
- package/src/components/FieldRenderer.svelte +240 -75
- package/src/components/ForgotPasswordPage.svelte +20 -111
- package/src/components/Header.svelte +28 -8
- package/src/components/InferencerPanel.svelte +136 -110
- package/src/components/InfiniteList.svelte +93 -0
- package/src/components/Layout.svelte +85 -22
- package/src/components/ListPage.svelte +25 -1
- package/src/components/LiveIndicator.svelte +30 -87
- package/src/components/LoginPage.svelte +81 -155
- package/src/components/ModalForm.svelte +6 -2
- package/src/components/PageSkeleton.svelte +2 -1
- package/src/components/PasswordInput.svelte +95 -0
- package/src/components/ProfilePage.svelte +166 -0
- package/src/components/RegisterPage.svelte +35 -164
- package/src/components/ShowPage.svelte +32 -33
- package/src/components/Sidebar.svelte +185 -73
- package/src/components/StatsCard.svelte +12 -3
- package/src/components/StepsForm.svelte +139 -0
- package/src/components/Toast.svelte +15 -54
- package/src/components/TooltipButton.svelte +52 -0
- package/src/components/UndoableNotification.svelte +28 -96
- package/src/components/UpdatePasswordPage.svelte +32 -160
- package/src/components/buttons/RefreshButton.svelte +7 -2
- package/src/components/fieldComponentMap.ts +79 -0
- package/src/components/fields/BooleanField.svelte +9 -9
- package/src/components/fields/EmailField.svelte +2 -8
- package/src/components/fields/FileField.svelte +4 -9
- package/src/components/fields/ImageField.svelte +2 -7
- package/src/components/fields/JsonField.svelte +72 -0
- package/src/components/fields/MarkdownField.svelte +2 -12
- package/src/components/fields/MultiSelectField.svelte +20 -0
- package/src/components/fields/RelationField.svelte +24 -0
- package/src/components/fields/RichTextField.svelte +35 -0
- package/src/components/fields/SelectField.svelte +14 -0
- package/src/components/fields/TagField.svelte +6 -11
- package/src/components/fields/TextField.svelte +2 -7
- package/src/components/fields/UrlField.svelte +4 -10
- package/src/components/ui/alert-dialog/alert-dialog-content.svelte +25 -0
- package/src/components/ui/alert-dialog/alert-dialog-description.svelte +16 -0
- package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +8 -0
- package/src/components/ui/alert-dialog/alert-dialog-header.svelte +8 -0
- package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +16 -0
- package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +6 -0
- package/src/components/ui/alert-dialog/alert-dialog-title.svelte +16 -0
- package/src/components/ui/alert-dialog/index.ts +20 -0
- package/src/components/ui/breadcrumb/breadcrumb-ellipsis.svelte +18 -0
- package/src/components/ui/breadcrumb/breadcrumb-item.svelte +15 -0
- package/src/components/ui/breadcrumb/breadcrumb-link.svelte +15 -0
- package/src/components/ui/breadcrumb/breadcrumb-list.svelte +15 -0
- package/src/components/ui/breadcrumb/breadcrumb-page.svelte +18 -0
- package/src/components/ui/breadcrumb/breadcrumb-separator.svelte +22 -0
- package/src/components/ui/breadcrumb/breadcrumb.svelte +11 -0
- package/src/components/ui/breadcrumb/index.ts +7 -0
- package/src/components/ui/collapsible/index.ts +7 -0
- package/src/components/ui/command/command-dialog.svelte +31 -0
- package/src/components/ui/command/index.ts +2 -0
- package/src/components/ui/context-menu/index.ts +11 -0
- package/src/components/ui/drawer/drawer-content.svelte +21 -0
- package/src/components/ui/drawer/drawer-description.svelte +11 -0
- package/src/components/ui/drawer/drawer-footer.svelte +11 -0
- package/src/components/ui/drawer/drawer-header.svelte +11 -0
- package/src/components/ui/drawer/drawer-title.svelte +11 -0
- package/src/components/ui/drawer/drawer.svelte +18 -0
- package/src/components/ui/drawer/index.ts +6 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +43 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +27 -0
- package/src/components/ui/dropdown-menu/index.ts +6 -0
- package/src/components/ui/label/index.ts +1 -0
- package/src/components/ui/label/label.svelte +18 -0
- package/src/components/ui/pagination/index.ts +7 -0
- package/src/components/ui/pagination/pagination-content.svelte +11 -0
- package/src/components/ui/pagination/pagination-ellipsis.svelte +12 -0
- package/src/components/ui/pagination/pagination-item.svelte +11 -0
- package/src/components/ui/pagination/pagination-link.svelte +26 -0
- package/src/components/ui/pagination/pagination-next.svelte +22 -0
- package/src/components/ui/pagination/pagination-previous.svelte +22 -0
- package/src/components/ui/pagination/pagination.svelte +11 -0
- package/src/components/ui/popover/index.ts +12 -0
- package/src/components/ui/popover/popover-content.svelte +25 -0
- package/src/components/ui/progress/index.ts +1 -0
- package/src/components/ui/progress/progress.svelte +22 -0
- package/src/components/ui/scroll-area/index.ts +1 -0
- package/src/components/ui/scroll-area/scroll-area.svelte +20 -0
- package/src/components/ui/sheet/index.ts +15 -1
- package/src/components/ui/sheet/sheet-content.svelte +28 -0
- package/src/components/ui/sheet/sheet-description.svelte +22 -0
- package/src/components/ui/sheet/sheet-footer.svelte +22 -0
- package/src/components/ui/sheet/sheet-header.svelte +22 -0
- package/src/components/ui/sheet/sheet-title.svelte +22 -0
- package/src/index.ts +31 -1
|
@@ -1,154 +1,319 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export type { FieldDefinition } from '@svadmin/core';
|
|
3
|
+
</script>
|
|
4
|
+
|
|
1
5
|
<script lang="ts">
|
|
2
6
|
import type { FieldDefinition } from '@svadmin/core';
|
|
3
7
|
import { t } from '@svadmin/core/i18n';
|
|
8
|
+
import { Input } from './ui/input/index.js';
|
|
9
|
+
import { Textarea } from './ui/textarea/index.js';
|
|
10
|
+
import { Switch } from './ui/switch/index.js';
|
|
11
|
+
import { Badge } from './ui/badge/index.js';
|
|
12
|
+
import { Label } from './ui/label/index.js';
|
|
13
|
+
import { Select } from './ui/select/index.js';
|
|
14
|
+
import { Checkbox } from './ui/checkbox/index.js';
|
|
15
|
+
import { Button } from './ui/button/index.js';
|
|
16
|
+
import ComboboxField from './ComboboxField.svelte';
|
|
17
|
+
import { Plus, X } from 'lucide-svelte';
|
|
18
|
+
import type { Snippet } from 'svelte';
|
|
4
19
|
|
|
5
|
-
let { field, value, onchange } = $props<{
|
|
20
|
+
let { field, value, onchange, children } = $props<{
|
|
6
21
|
field: FieldDefinition;
|
|
7
22
|
value: unknown;
|
|
8
23
|
onchange: (val: unknown) => void;
|
|
24
|
+
/** Optional custom renderer snippet */
|
|
25
|
+
children?: Snippet;
|
|
9
26
|
}>();
|
|
10
27
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
28
|
+
// Typed accessors
|
|
29
|
+
const strVal = $derived((value as string) ?? '');
|
|
30
|
+
const numVal = $derived((value as number) ?? 0);
|
|
31
|
+
const boolVal = $derived((value as boolean) ?? false);
|
|
32
|
+
const tagsVal = $derived((value as string[]) ?? []);
|
|
33
|
+
const multiVal = $derived((value as (string | number)[]) ?? []);
|
|
34
|
+
const imagesVal = $derived((value as string[]) ?? []);
|
|
35
|
+
|
|
36
|
+
function handleTagKeydown(e: KeyboardEvent) {
|
|
37
|
+
if (e.key === 'Enter') {
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
const input = e.target as HTMLInputElement;
|
|
40
|
+
const tag = input.value.trim();
|
|
41
|
+
if (tag) {
|
|
42
|
+
onchange([...tagsVal, tag]);
|
|
43
|
+
input.value = '';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function removeTag(index: number) {
|
|
49
|
+
onchange(tagsVal.filter((_: string, i: number) => i !== index));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Multiselect toggle
|
|
53
|
+
function toggleMulti(optValue: string | number) {
|
|
54
|
+
if (multiVal.includes(optValue)) {
|
|
55
|
+
onchange(multiVal.filter((v: string | number) => v !== optValue));
|
|
17
56
|
} else {
|
|
18
|
-
onchange(
|
|
57
|
+
onchange([...multiVal, optValue]);
|
|
19
58
|
}
|
|
20
59
|
}
|
|
60
|
+
|
|
61
|
+
// Images management
|
|
62
|
+
function addImage() {
|
|
63
|
+
onchange([...imagesVal, '']);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function updateImage(index: number, url: string) {
|
|
67
|
+
const next = [...imagesVal];
|
|
68
|
+
next[index] = url;
|
|
69
|
+
onchange(next);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function removeImage(index: number) {
|
|
73
|
+
onchange(imagesVal.filter((_: string, i: number) => i !== index));
|
|
74
|
+
}
|
|
21
75
|
</script>
|
|
22
76
|
|
|
23
77
|
<div class="space-y-1.5">
|
|
24
|
-
<
|
|
78
|
+
<Label for={field.key}>
|
|
25
79
|
{field.label}
|
|
26
80
|
{#if field.required}
|
|
27
|
-
<span class="text-
|
|
81
|
+
<span class="text-destructive">*</span>
|
|
28
82
|
{/if}
|
|
29
|
-
</
|
|
83
|
+
</Label>
|
|
30
84
|
|
|
31
|
-
{#if
|
|
32
|
-
|
|
85
|
+
{#if children}
|
|
86
|
+
{@render children()}
|
|
87
|
+
|
|
88
|
+
{:else if field.type === 'text' || field.type === 'image'}
|
|
89
|
+
<Input
|
|
33
90
|
id={field.key}
|
|
34
91
|
type="text"
|
|
35
|
-
value={
|
|
36
|
-
oninput={
|
|
92
|
+
value={strVal}
|
|
93
|
+
oninput={(e) => onchange((e.target as HTMLInputElement).value)}
|
|
37
94
|
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
95
|
placeholder={t('field.enterValue', { label: field.label })}
|
|
40
96
|
/>
|
|
41
97
|
|
|
98
|
+
{:else if field.type === 'email'}
|
|
99
|
+
<Input
|
|
100
|
+
id={field.key}
|
|
101
|
+
type="email"
|
|
102
|
+
value={strVal}
|
|
103
|
+
oninput={(e) => onchange((e.target as HTMLInputElement).value)}
|
|
104
|
+
required={field.required}
|
|
105
|
+
placeholder="name@example.com"
|
|
106
|
+
/>
|
|
107
|
+
|
|
108
|
+
{:else if field.type === 'url'}
|
|
109
|
+
<Input
|
|
110
|
+
id={field.key}
|
|
111
|
+
type="url"
|
|
112
|
+
value={strVal}
|
|
113
|
+
oninput={(e) => onchange((e.target as HTMLInputElement).value)}
|
|
114
|
+
required={field.required}
|
|
115
|
+
placeholder="https://"
|
|
116
|
+
/>
|
|
117
|
+
|
|
118
|
+
{:else if field.type === 'phone'}
|
|
119
|
+
<Input
|
|
120
|
+
id={field.key}
|
|
121
|
+
type="tel"
|
|
122
|
+
value={strVal}
|
|
123
|
+
oninput={(e) => onchange((e.target as HTMLInputElement).value)}
|
|
124
|
+
required={field.required}
|
|
125
|
+
placeholder="+1 (555) 000-0000"
|
|
126
|
+
/>
|
|
127
|
+
|
|
128
|
+
{:else if field.type === 'color'}
|
|
129
|
+
<div class="flex items-center gap-3">
|
|
130
|
+
<input
|
|
131
|
+
id={field.key}
|
|
132
|
+
type="color"
|
|
133
|
+
value={strVal || '#000000'}
|
|
134
|
+
oninput={(e) => onchange((e.target as HTMLInputElement).value)}
|
|
135
|
+
class="h-10 w-14 cursor-pointer rounded-md border border-input bg-background p-1"
|
|
136
|
+
/>
|
|
137
|
+
<Input
|
|
138
|
+
type="text"
|
|
139
|
+
value={strVal}
|
|
140
|
+
oninput={(e) => onchange((e.target as HTMLInputElement).value)}
|
|
141
|
+
placeholder="#000000"
|
|
142
|
+
class="max-w-32 font-mono text-sm"
|
|
143
|
+
/>
|
|
144
|
+
{#if strVal}
|
|
145
|
+
<span
|
|
146
|
+
class="h-8 w-8 rounded-full border border-border shadow-sm"
|
|
147
|
+
style="background-color: {strVal}"
|
|
148
|
+
></span>
|
|
149
|
+
{/if}
|
|
150
|
+
</div>
|
|
151
|
+
|
|
42
152
|
{:else if field.type === 'number'}
|
|
43
|
-
<
|
|
153
|
+
<Input
|
|
44
154
|
id={field.key}
|
|
45
155
|
type="number"
|
|
46
|
-
value={
|
|
47
|
-
oninput={
|
|
156
|
+
value={String(numVal)}
|
|
157
|
+
oninput={(e) => onchange(parseFloat((e.target as HTMLInputElement).value) || 0)}
|
|
48
158
|
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
159
|
/>
|
|
51
160
|
|
|
52
161
|
{:else if field.type === 'textarea' || field.type === 'richtext'}
|
|
53
|
-
<
|
|
162
|
+
<Textarea
|
|
54
163
|
id={field.key}
|
|
55
|
-
value={
|
|
56
|
-
oninput={
|
|
164
|
+
value={strVal}
|
|
165
|
+
oninput={(e) => onchange((e.target as HTMLTextAreaElement).value)}
|
|
57
166
|
required={field.required}
|
|
58
167
|
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
168
|
placeholder={t('field.enterValue', { label: field.label })}
|
|
61
|
-
|
|
169
|
+
class="resize-y"
|
|
170
|
+
/>
|
|
171
|
+
|
|
172
|
+
{:else if field.type === 'relation' && field.resource}
|
|
173
|
+
<ComboboxField
|
|
174
|
+
resource={field.resource}
|
|
175
|
+
value={value as string | number | null}
|
|
176
|
+
onchange={(v) => onchange(v)}
|
|
177
|
+
optionLabel={field.optionLabel ?? 'title'}
|
|
178
|
+
optionValue={field.optionValue ?? 'id'}
|
|
179
|
+
placeholder={t('field.selectPlaceholder')}
|
|
180
|
+
/>
|
|
62
181
|
|
|
63
182
|
{:else if field.type === 'select'}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
183
|
+
{#if (field.options?.length ?? 0) > 8}
|
|
184
|
+
<!-- Many options → use ComboboxField-style search -->
|
|
185
|
+
<ComboboxField
|
|
186
|
+
resource=""
|
|
187
|
+
value={value as string | number | null}
|
|
188
|
+
onchange={(v) => onchange(v)}
|
|
189
|
+
searchable={false}
|
|
190
|
+
/>
|
|
191
|
+
{:else}
|
|
192
|
+
<Select
|
|
193
|
+
id={field.key}
|
|
194
|
+
value={strVal}
|
|
195
|
+
onchange={(e) => onchange((e.target as HTMLSelectElement).value)}
|
|
196
|
+
required={field.required}
|
|
197
|
+
placeholder={t('field.selectPlaceholder')}
|
|
198
|
+
>
|
|
199
|
+
{#each field.options ?? [] as opt}
|
|
200
|
+
<option value={opt.value}>{opt.label}</option>
|
|
201
|
+
{/each}
|
|
202
|
+
</Select>
|
|
203
|
+
{/if}
|
|
204
|
+
|
|
205
|
+
{:else if field.type === 'multiselect'}
|
|
206
|
+
<div class="space-y-2 rounded-lg border border-input p-3 max-h-48 overflow-y-auto">
|
|
72
207
|
{#each field.options ?? [] as opt}
|
|
73
|
-
<
|
|
208
|
+
<label class="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5 transition-colors">
|
|
209
|
+
<Checkbox
|
|
210
|
+
checked={multiVal.includes(opt.value)}
|
|
211
|
+
onCheckedChange={() => toggleMulti(opt.value)}
|
|
212
|
+
/>
|
|
213
|
+
{opt.label}
|
|
214
|
+
</label>
|
|
74
215
|
{/each}
|
|
75
|
-
|
|
216
|
+
{#if !(field.options?.length)}
|
|
217
|
+
<p class="text-xs text-muted-foreground">{t('field.noOptions')}</p>
|
|
218
|
+
{/if}
|
|
219
|
+
</div>
|
|
220
|
+
{#if multiVal.length > 0}
|
|
221
|
+
<div class="flex flex-wrap gap-1 mt-1">
|
|
222
|
+
{#each multiVal as v}
|
|
223
|
+
{@const label = field.options?.find(o => o.value === v)?.label ?? String(v)}
|
|
224
|
+
<Badge variant="secondary" class="gap-1">
|
|
225
|
+
{label}
|
|
226
|
+
<button type="button" onclick={() => toggleMulti(v)} class="ml-0.5 rounded-sm hover:text-destructive hover:bg-destructive/10 transition-colors" aria-label={t('common.clear')}>×</button>
|
|
227
|
+
</Badge>
|
|
228
|
+
{/each}
|
|
229
|
+
</div>
|
|
230
|
+
{/if}
|
|
76
231
|
|
|
77
232
|
{:else if field.type === 'boolean'}
|
|
78
|
-
<
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
onchange={handleInput}
|
|
83
|
-
class="peer sr-only"
|
|
233
|
+
<div class="flex items-center gap-2 pt-1">
|
|
234
|
+
<Switch
|
|
235
|
+
checked={boolVal}
|
|
236
|
+
onCheckedChange={(v) => onchange(v)}
|
|
84
237
|
/>
|
|
85
|
-
<
|
|
86
|
-
</
|
|
238
|
+
<span class="text-sm text-muted-foreground">{boolVal ? t('common.yes') : t('common.no')}</span>
|
|
239
|
+
</div>
|
|
87
240
|
|
|
88
241
|
{:else if field.type === 'tags'}
|
|
89
|
-
{@const tags = (value as string[] ?? [])}
|
|
90
242
|
<div class="space-y-2">
|
|
91
243
|
<div class="flex flex-wrap gap-1.5">
|
|
92
|
-
{#each
|
|
93
|
-
<
|
|
244
|
+
{#each tagsVal as tag, i}
|
|
245
|
+
<Badge variant="secondary" class="gap-1">
|
|
94
246
|
{tag}
|
|
95
247
|
<button
|
|
96
248
|
type="button"
|
|
97
|
-
onclick={() =>
|
|
98
|
-
class="
|
|
249
|
+
onclick={() => removeTag(i)}
|
|
250
|
+
class="ml-0.5 rounded-sm hover:text-destructive hover:bg-destructive/10 transition-colors"
|
|
251
|
+
aria-label={t('common.clear')}
|
|
99
252
|
>×</button>
|
|
100
|
-
</
|
|
253
|
+
</Badge>
|
|
101
254
|
{/each}
|
|
102
255
|
</div>
|
|
103
|
-
<
|
|
256
|
+
<Input
|
|
104
257
|
type="text"
|
|
105
258
|
placeholder={t('field.tagsPlaceholder')}
|
|
106
|
-
|
|
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
|
-
}}
|
|
259
|
+
onkeydown={handleTagKeydown}
|
|
117
260
|
/>
|
|
118
261
|
</div>
|
|
119
262
|
|
|
120
263
|
{:else if field.type === 'date'}
|
|
121
|
-
<
|
|
264
|
+
<Input
|
|
122
265
|
id={field.key}
|
|
123
266
|
type="date"
|
|
124
|
-
value={
|
|
125
|
-
oninput={
|
|
267
|
+
value={strVal}
|
|
268
|
+
oninput={(e) => onchange((e.target as HTMLInputElement).value)}
|
|
126
269
|
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
270
|
/>
|
|
129
271
|
|
|
272
|
+
{:else if field.type === 'images'}
|
|
273
|
+
<div class="space-y-2">
|
|
274
|
+
{#each imagesVal as url, i}
|
|
275
|
+
<div class="flex items-center gap-2">
|
|
276
|
+
<Input
|
|
277
|
+
type="text"
|
|
278
|
+
value={url}
|
|
279
|
+
oninput={(e) => updateImage(i, (e.target as HTMLInputElement).value)}
|
|
280
|
+
placeholder="https://example.com/image.jpg"
|
|
281
|
+
class="flex-1"
|
|
282
|
+
/>
|
|
283
|
+
{#if url}
|
|
284
|
+
<img src={url} alt="preview" class="h-9 w-9 rounded object-cover border" />
|
|
285
|
+
{/if}
|
|
286
|
+
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0" onclick={() => removeImage(i)}>
|
|
287
|
+
<X class="h-3.5 w-3.5" />
|
|
288
|
+
</Button>
|
|
289
|
+
</div>
|
|
290
|
+
{/each}
|
|
291
|
+
<Button variant="outline" size="sm" type="button" onclick={addImage}>
|
|
292
|
+
<Plus class="h-3.5 w-3.5 mr-1" /> {t('field.addImage')}
|
|
293
|
+
</Button>
|
|
294
|
+
</div>
|
|
295
|
+
|
|
130
296
|
{:else if field.type === 'json'}
|
|
131
|
-
<
|
|
297
|
+
<Textarea
|
|
132
298
|
id={field.key}
|
|
133
|
-
value={typeof value === 'string' ?
|
|
299
|
+
value={typeof value === 'string' ? strVal : JSON.stringify(value, null, 2)}
|
|
134
300
|
oninput={(e) => {
|
|
135
301
|
try {
|
|
136
302
|
onchange(JSON.parse((e.target as HTMLTextAreaElement).value));
|
|
137
|
-
} catch
|
|
138
|
-
|
|
303
|
+
} catch {
|
|
304
|
+
// keep raw text until valid JSON
|
|
139
305
|
}
|
|
140
306
|
}}
|
|
141
307
|
rows={6}
|
|
142
|
-
class="
|
|
143
|
-
|
|
308
|
+
class="resize-y font-mono text-xs"
|
|
309
|
+
/>
|
|
144
310
|
|
|
145
311
|
{:else}
|
|
146
|
-
<
|
|
312
|
+
<Input
|
|
147
313
|
id={field.key}
|
|
148
314
|
type="text"
|
|
149
|
-
value={
|
|
150
|
-
oninput={
|
|
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"
|
|
315
|
+
value={strVal}
|
|
316
|
+
oninput={(e) => onchange((e.target as HTMLInputElement).value)}
|
|
152
317
|
/>
|
|
153
318
|
{/if}
|
|
154
319
|
</div>
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
import { navigate } from '@svadmin/core/router';
|
|
5
5
|
import { Button } from './ui/button/index.js';
|
|
6
6
|
import { Input } from './ui/input/index.js';
|
|
7
|
+
import { Label } from './ui/label/index.js';
|
|
7
8
|
import * as Card from './ui/card/index.js';
|
|
8
|
-
import
|
|
9
|
+
import * as Alert from './ui/alert/index.js';
|
|
10
|
+
import { KeyRound, Mail, ArrowLeft, CheckCircle, Loader2, AlertCircle } from 'lucide-svelte';
|
|
9
11
|
|
|
10
12
|
let { title = 'Admin' } = $props<{
|
|
11
13
|
title?: string;
|
|
@@ -32,11 +34,11 @@
|
|
|
32
34
|
}
|
|
33
35
|
</script>
|
|
34
36
|
|
|
35
|
-
<div class="
|
|
36
|
-
<div class="
|
|
37
|
-
<Card.Card class="
|
|
38
|
-
<Card.CardHeader class="
|
|
39
|
-
<div class="
|
|
37
|
+
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/[0.08] via-background to-primary/[0.04] p-4">
|
|
38
|
+
<div class="w-full max-w-[420px]">
|
|
39
|
+
<Card.Card class="backdrop-blur-xl border-border/50 shadow-[0_8px_32px_hsl(var(--primary)/0.08),0_2px_8px_hsl(0_0%_0%/0.06)]">
|
|
40
|
+
<Card.CardHeader class="text-center pb-2">
|
|
41
|
+
<div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-primary/10 text-primary mx-auto mb-3">
|
|
40
42
|
{#if sent}
|
|
41
43
|
<CheckCircle class="h-6 w-6" />
|
|
42
44
|
{:else}
|
|
@@ -52,7 +54,7 @@
|
|
|
52
54
|
</Card.CardHeader>
|
|
53
55
|
<Card.CardContent>
|
|
54
56
|
{#if sent}
|
|
55
|
-
<div class="
|
|
57
|
+
<div class="py-2">
|
|
56
58
|
<p class="text-sm text-muted-foreground text-center mb-4">
|
|
57
59
|
{t('auth.resetLinkSent')}
|
|
58
60
|
</p>
|
|
@@ -64,15 +66,16 @@
|
|
|
64
66
|
{:else}
|
|
65
67
|
<form onsubmit={handleSubmit} class="space-y-4">
|
|
66
68
|
{#if error}
|
|
67
|
-
<
|
|
68
|
-
<
|
|
69
|
-
|
|
69
|
+
<Alert.Root variant="destructive">
|
|
70
|
+
<AlertCircle class="h-4 w-4" />
|
|
71
|
+
<Alert.Description>{error}</Alert.Description>
|
|
72
|
+
</Alert.Root>
|
|
70
73
|
{/if}
|
|
71
74
|
|
|
72
75
|
<div class="space-y-2">
|
|
73
|
-
<
|
|
74
|
-
<div class="
|
|
75
|
-
<Mail class="
|
|
76
|
+
<Label for="forgot-email">{t('auth.email')}</Label>
|
|
77
|
+
<div class="relative">
|
|
78
|
+
<Mail class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none z-[1]" />
|
|
76
79
|
<Input
|
|
77
80
|
id="forgot-email"
|
|
78
81
|
type="email"
|
|
@@ -86,21 +89,17 @@
|
|
|
86
89
|
|
|
87
90
|
<Button type="submit" class="w-full" disabled={forgot.isLoading}>
|
|
88
91
|
{#if forgot.isLoading}
|
|
89
|
-
<
|
|
92
|
+
<Loader2 class="h-4 w-4 animate-spin mr-2" />
|
|
90
93
|
{/if}
|
|
91
94
|
{t('auth.sendResetLink')}
|
|
92
95
|
</Button>
|
|
93
96
|
</form>
|
|
94
97
|
|
|
95
|
-
<div class="
|
|
96
|
-
<
|
|
97
|
-
type="button"
|
|
98
|
-
class="text-sm text-primary hover:underline font-medium inline-flex items-center gap-1"
|
|
99
|
-
onclick={() => navigate('/login')}
|
|
100
|
-
>
|
|
98
|
+
<div class="flex items-center justify-center mt-5 pt-5 border-t">
|
|
99
|
+
<Button variant="link" class="text-sm h-auto p-0 font-medium inline-flex items-center gap-1" onclick={() => navigate('/login')}>
|
|
101
100
|
<ArrowLeft class="h-3 w-3" />
|
|
102
101
|
{t('auth.backToLogin')}
|
|
103
|
-
</
|
|
102
|
+
</Button>
|
|
104
103
|
</div>
|
|
105
104
|
{/if}
|
|
106
105
|
</Card.CardContent>
|
|
@@ -111,93 +110,3 @@
|
|
|
111
110
|
</p>
|
|
112
111
|
</div>
|
|
113
112
|
</div>
|
|
114
|
-
|
|
115
|
-
<style>
|
|
116
|
-
.forgot-page {
|
|
117
|
-
min-height: 100vh;
|
|
118
|
-
display: flex;
|
|
119
|
-
align-items: center;
|
|
120
|
-
justify-content: center;
|
|
121
|
-
background: linear-gradient(135deg, hsl(var(--primary) / 0.08) 0%, hsl(var(--background)) 50%, hsl(var(--primary) / 0.04) 100%);
|
|
122
|
-
padding: 1rem;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
.forgot-container {
|
|
126
|
-
width: 100%;
|
|
127
|
-
max-width: 420px;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
.forgot-icon {
|
|
131
|
-
display: inline-flex;
|
|
132
|
-
align-items: center;
|
|
133
|
-
justify-content: center;
|
|
134
|
-
width: 48px;
|
|
135
|
-
height: 48px;
|
|
136
|
-
border-radius: 12px;
|
|
137
|
-
background: hsl(var(--primary) / 0.1);
|
|
138
|
-
color: hsl(var(--primary));
|
|
139
|
-
margin: 0 auto 0.75rem;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
:global(.login-card) {
|
|
143
|
-
backdrop-filter: blur(20px);
|
|
144
|
-
border: 1px solid hsl(var(--border) / 0.5);
|
|
145
|
-
box-shadow: 0 8px 32px hsl(var(--primary) / 0.08), 0 2px 8px hsl(0 0% 0% / 0.06);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
:global(.login-header) {
|
|
149
|
-
text-align: center;
|
|
150
|
-
padding-bottom: 0.5rem;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
.input-with-icon {
|
|
154
|
-
position: relative;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
:global(.input-icon) {
|
|
158
|
-
position: absolute;
|
|
159
|
-
left: 0.75rem;
|
|
160
|
-
top: 50%;
|
|
161
|
-
transform: translateY(-50%);
|
|
162
|
-
color: hsl(var(--muted-foreground));
|
|
163
|
-
pointer-events: none;
|
|
164
|
-
z-index: 1;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
.error-alert {
|
|
168
|
-
padding: 0.75rem;
|
|
169
|
-
border-radius: 0.5rem;
|
|
170
|
-
background: hsl(var(--destructive) / 0.1);
|
|
171
|
-
border: 1px solid hsl(var(--destructive) / 0.3);
|
|
172
|
-
color: hsl(var(--destructive));
|
|
173
|
-
font-size: 0.875rem;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
.success-state {
|
|
177
|
-
padding: 0.5rem 0;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
.loading-spinner {
|
|
181
|
-
display: inline-block;
|
|
182
|
-
width: 16px;
|
|
183
|
-
height: 16px;
|
|
184
|
-
border: 2px solid transparent;
|
|
185
|
-
border-top-color: currentColor;
|
|
186
|
-
border-radius: 50%;
|
|
187
|
-
animation: spin 0.6s linear infinite;
|
|
188
|
-
margin-right: 0.5rem;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
@keyframes spin {
|
|
192
|
-
to { transform: rotate(360deg); }
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
.auth-link {
|
|
196
|
-
display: flex;
|
|
197
|
-
align-items: center;
|
|
198
|
-
justify-content: center;
|
|
199
|
-
margin-top: 1.25rem;
|
|
200
|
-
padding-top: 1.25rem;
|
|
201
|
-
border-top: 1px solid hsl(var(--border));
|
|
202
|
-
}
|
|
203
|
-
</style>
|
|
@@ -1,35 +1,55 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { Moon, Sun } from 'lucide-svelte';
|
|
2
|
+
import { Moon, Sun, Search } from 'lucide-svelte';
|
|
3
3
|
import { Button } from './ui/button';
|
|
4
|
-
import
|
|
4
|
+
import TooltipButton from './TooltipButton.svelte';
|
|
5
|
+
import { getResolvedTheme, toggleTheme } from '@svadmin/core';
|
|
6
|
+
import Breadcrumbs from './Breadcrumbs.svelte';
|
|
7
|
+
import { t } from '@svadmin/core/i18n';
|
|
5
8
|
|
|
6
9
|
let {
|
|
7
10
|
showThemeToggle = true,
|
|
11
|
+
showBreadcrumbs = true,
|
|
12
|
+
showSearch = true,
|
|
13
|
+
onSearchClick,
|
|
8
14
|
children,
|
|
9
15
|
} = $props<{
|
|
10
16
|
showThemeToggle?: boolean;
|
|
17
|
+
showBreadcrumbs?: boolean;
|
|
18
|
+
showSearch?: boolean;
|
|
19
|
+
onSearchClick?: () => void;
|
|
11
20
|
children?: import('svelte').Snippet;
|
|
12
21
|
}>();
|
|
13
22
|
</script>
|
|
14
23
|
|
|
15
24
|
<header class="sticky top-0 z-30 flex h-16 w-full shrink-0 items-center justify-between border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/60 md:px-6">
|
|
16
25
|
<div class="flex items-center gap-4">
|
|
17
|
-
|
|
26
|
+
{#if showBreadcrumbs}
|
|
27
|
+
<Breadcrumbs />
|
|
28
|
+
{/if}
|
|
18
29
|
</div>
|
|
19
|
-
<div class="ml-auto flex items-center gap-
|
|
30
|
+
<div class="ml-auto flex items-center gap-2">
|
|
20
31
|
{#if children}
|
|
21
32
|
{@render children()}
|
|
22
33
|
{/if}
|
|
34
|
+
|
|
35
|
+
{#if showSearch && onSearchClick}
|
|
36
|
+
<Button variant="outline" size="sm" onclick={onSearchClick} class="gap-2 text-muted-foreground">
|
|
37
|
+
<Search class="h-4 w-4" />
|
|
38
|
+
<span class="hidden sm:inline text-xs">Search...</span>
|
|
39
|
+
<kbd class="hidden sm:inline-flex items-center gap-0.5 rounded border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
|
40
|
+
<span class="text-xs">⌘</span>K
|
|
41
|
+
</kbd>
|
|
42
|
+
</Button>
|
|
43
|
+
{/if}
|
|
23
44
|
|
|
24
45
|
{#if showThemeToggle}
|
|
25
|
-
<
|
|
26
|
-
{#if
|
|
46
|
+
<TooltipButton tooltip={t('common.toggleTheme')} onclick={() => toggleTheme()} class="rounded-full">
|
|
47
|
+
{#if getResolvedTheme() === 'dark'}
|
|
27
48
|
<Moon class="h-5 w-5 transition-all" />
|
|
28
49
|
{:else}
|
|
29
50
|
<Sun class="h-5 w-5 transition-all" />
|
|
30
51
|
{/if}
|
|
31
|
-
|
|
32
|
-
</Button>
|
|
52
|
+
</TooltipButton>
|
|
33
53
|
{/if}
|
|
34
54
|
</div>
|
|
35
55
|
</header>
|