@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.
Files changed (110) hide show
  1. package/package.json +9 -4
  2. package/src/actions.ts +110 -0
  3. package/src/components/AdminApp.svelte +7 -4
  4. package/src/components/Authenticated.svelte +1 -7
  5. package/src/components/AutoForm.svelte +158 -69
  6. package/src/components/AutoSaveIndicator.svelte +19 -14
  7. package/src/components/AutoTable.svelte +465 -243
  8. package/src/components/Breadcrumbs.svelte +20 -17
  9. package/src/components/CanAccess.svelte +6 -4
  10. package/src/components/ComboboxField.svelte +122 -0
  11. package/src/components/CommandPalette.svelte +93 -0
  12. package/src/components/ConfigErrorScreen.svelte +53 -151
  13. package/src/components/ConfirmDialog.svelte +25 -17
  14. package/src/components/CreatePage.svelte +3 -2
  15. package/src/components/DevTools.svelte +99 -230
  16. package/src/components/DrawerForm.svelte +19 -10
  17. package/src/components/EditPage.svelte +25 -6
  18. package/src/components/EmptyState.svelte +5 -1
  19. package/src/components/ErrorBoundary.svelte +38 -40
  20. package/src/components/ErrorComponent.svelte +18 -17
  21. package/src/components/FieldRenderer.svelte +240 -75
  22. package/src/components/ForgotPasswordPage.svelte +20 -111
  23. package/src/components/Header.svelte +28 -8
  24. package/src/components/InferencerPanel.svelte +136 -110
  25. package/src/components/InfiniteList.svelte +93 -0
  26. package/src/components/Layout.svelte +85 -22
  27. package/src/components/ListPage.svelte +25 -1
  28. package/src/components/LiveIndicator.svelte +30 -87
  29. package/src/components/LoginPage.svelte +81 -155
  30. package/src/components/ModalForm.svelte +6 -2
  31. package/src/components/PageSkeleton.svelte +2 -1
  32. package/src/components/PasswordInput.svelte +95 -0
  33. package/src/components/ProfilePage.svelte +166 -0
  34. package/src/components/RegisterPage.svelte +35 -164
  35. package/src/components/ShowPage.svelte +32 -33
  36. package/src/components/Sidebar.svelte +185 -73
  37. package/src/components/StatsCard.svelte +12 -3
  38. package/src/components/StepsForm.svelte +139 -0
  39. package/src/components/Toast.svelte +15 -54
  40. package/src/components/TooltipButton.svelte +52 -0
  41. package/src/components/UndoableNotification.svelte +28 -96
  42. package/src/components/UpdatePasswordPage.svelte +32 -160
  43. package/src/components/buttons/RefreshButton.svelte +7 -2
  44. package/src/components/fieldComponentMap.ts +79 -0
  45. package/src/components/fields/BooleanField.svelte +9 -9
  46. package/src/components/fields/EmailField.svelte +2 -8
  47. package/src/components/fields/FileField.svelte +4 -9
  48. package/src/components/fields/ImageField.svelte +2 -7
  49. package/src/components/fields/JsonField.svelte +72 -0
  50. package/src/components/fields/MarkdownField.svelte +2 -12
  51. package/src/components/fields/MultiSelectField.svelte +20 -0
  52. package/src/components/fields/RelationField.svelte +24 -0
  53. package/src/components/fields/RichTextField.svelte +35 -0
  54. package/src/components/fields/SelectField.svelte +14 -0
  55. package/src/components/fields/TagField.svelte +6 -11
  56. package/src/components/fields/TextField.svelte +2 -7
  57. package/src/components/fields/UrlField.svelte +4 -10
  58. package/src/components/ui/alert-dialog/alert-dialog-content.svelte +25 -0
  59. package/src/components/ui/alert-dialog/alert-dialog-description.svelte +16 -0
  60. package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +8 -0
  61. package/src/components/ui/alert-dialog/alert-dialog-header.svelte +8 -0
  62. package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +16 -0
  63. package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +6 -0
  64. package/src/components/ui/alert-dialog/alert-dialog-title.svelte +16 -0
  65. package/src/components/ui/alert-dialog/index.ts +20 -0
  66. package/src/components/ui/breadcrumb/breadcrumb-ellipsis.svelte +18 -0
  67. package/src/components/ui/breadcrumb/breadcrumb-item.svelte +15 -0
  68. package/src/components/ui/breadcrumb/breadcrumb-link.svelte +15 -0
  69. package/src/components/ui/breadcrumb/breadcrumb-list.svelte +15 -0
  70. package/src/components/ui/breadcrumb/breadcrumb-page.svelte +18 -0
  71. package/src/components/ui/breadcrumb/breadcrumb-separator.svelte +22 -0
  72. package/src/components/ui/breadcrumb/breadcrumb.svelte +11 -0
  73. package/src/components/ui/breadcrumb/index.ts +7 -0
  74. package/src/components/ui/collapsible/index.ts +7 -0
  75. package/src/components/ui/command/command-dialog.svelte +31 -0
  76. package/src/components/ui/command/index.ts +2 -0
  77. package/src/components/ui/context-menu/index.ts +11 -0
  78. package/src/components/ui/drawer/drawer-content.svelte +21 -0
  79. package/src/components/ui/drawer/drawer-description.svelte +11 -0
  80. package/src/components/ui/drawer/drawer-footer.svelte +11 -0
  81. package/src/components/ui/drawer/drawer-header.svelte +11 -0
  82. package/src/components/ui/drawer/drawer-title.svelte +11 -0
  83. package/src/components/ui/drawer/drawer.svelte +18 -0
  84. package/src/components/ui/drawer/index.ts +6 -0
  85. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +43 -0
  86. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +27 -0
  87. package/src/components/ui/dropdown-menu/index.ts +6 -0
  88. package/src/components/ui/label/index.ts +1 -0
  89. package/src/components/ui/label/label.svelte +18 -0
  90. package/src/components/ui/pagination/index.ts +7 -0
  91. package/src/components/ui/pagination/pagination-content.svelte +11 -0
  92. package/src/components/ui/pagination/pagination-ellipsis.svelte +12 -0
  93. package/src/components/ui/pagination/pagination-item.svelte +11 -0
  94. package/src/components/ui/pagination/pagination-link.svelte +26 -0
  95. package/src/components/ui/pagination/pagination-next.svelte +22 -0
  96. package/src/components/ui/pagination/pagination-previous.svelte +22 -0
  97. package/src/components/ui/pagination/pagination.svelte +11 -0
  98. package/src/components/ui/popover/index.ts +12 -0
  99. package/src/components/ui/popover/popover-content.svelte +25 -0
  100. package/src/components/ui/progress/index.ts +1 -0
  101. package/src/components/ui/progress/progress.svelte +22 -0
  102. package/src/components/ui/scroll-area/index.ts +1 -0
  103. package/src/components/ui/scroll-area/scroll-area.svelte +20 -0
  104. package/src/components/ui/sheet/index.ts +15 -1
  105. package/src/components/ui/sheet/sheet-content.svelte +28 -0
  106. package/src/components/ui/sheet/sheet-description.svelte +22 -0
  107. package/src/components/ui/sheet/sheet-footer.svelte +22 -0
  108. package/src/components/ui/sheet/sheet-header.svelte +22 -0
  109. package/src/components/ui/sheet/sheet-title.svelte +22 -0
  110. 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
- 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);
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(target.value);
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
- <label class="block text-sm font-medium text-gray-700" for={field.key}>
78
+ <Label for={field.key}>
25
79
  {field.label}
26
80
  {#if field.required}
27
- <span class="text-red-500">*</span>
81
+ <span class="text-destructive">*</span>
28
82
  {/if}
29
- </label>
83
+ </Label>
30
84
 
31
- {#if field.type === 'text' || field.type === 'image'}
32
- <input
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={value as string ?? ''}
36
- oninput={handleInput}
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
- <input
153
+ <Input
44
154
  id={field.key}
45
155
  type="number"
46
- value={value as number ?? 0}
47
- oninput={handleInput}
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
- <textarea
162
+ <Textarea
54
163
  id={field.key}
55
- value={value as string ?? ''}
56
- oninput={handleInput}
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
- ></textarea>
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
- <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>
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
- <option value={opt.value}>{opt.label}</option>
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
- </select>
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
- <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"
233
+ <div class="flex items-center gap-2 pt-1">
234
+ <Switch
235
+ checked={boolVal}
236
+ onCheckedChange={(v) => onchange(v)}
84
237
  />
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>
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 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">
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={() => onchange(tags.filter((_, idx) => idx !== i))}
98
- class="text-primary-400 hover:text-primary-700"
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
- </span>
253
+ </Badge>
101
254
  {/each}
102
255
  </div>
103
- <input
256
+ <Input
104
257
  type="text"
105
258
  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
- }}
259
+ onkeydown={handleTagKeydown}
117
260
  />
118
261
  </div>
119
262
 
120
263
  {:else if field.type === 'date'}
121
- <input
264
+ <Input
122
265
  id={field.key}
123
266
  type="date"
124
- value={value as string ?? ''}
125
- oninput={handleInput}
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
- <textarea
297
+ <Textarea
132
298
  id={field.key}
133
- value={typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
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 (err) {
138
- console.debug('[FieldRenderer] Invalid JSON input:', err);
303
+ } catch {
304
+ // keep raw text until valid JSON
139
305
  }
140
306
  }}
141
307
  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>
308
+ class="resize-y font-mono text-xs"
309
+ />
144
310
 
145
311
  {:else}
146
- <input
312
+ <Input
147
313
  id={field.key}
148
314
  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"
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 { KeyRound, Mail, ArrowLeft, CheckCircle } from 'lucide-svelte';
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="forgot-page">
36
- <div class="forgot-container">
37
- <Card.Card class="login-card">
38
- <Card.CardHeader class="login-header">
39
- <div class="forgot-icon">
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="success-state">
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
- <div class="error-alert">
68
- <p>{error}</p>
69
- </div>
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
- <label for="forgot-email" class="text-sm font-medium text-foreground">{t('auth.email')}</label>
74
- <div class="input-with-icon">
75
- <Mail class="input-icon h-4 w-4" />
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
- <span class="loading-spinner"></span>
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="auth-link">
96
- <button
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
- </button>
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 { getTheme, toggleTheme } from '@svadmin/core';
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
- <!-- Mobile sidebar toggle can go here -->
26
+ {#if showBreadcrumbs}
27
+ <Breadcrumbs />
28
+ {/if}
18
29
  </div>
19
- <div class="ml-auto flex items-center gap-4">
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
- <Button variant="ghost" size="icon" onclick={() => toggleTheme()} class="rounded-full">
26
- {#if getTheme() === 'dark'}
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
- <span class="sr-only">Toggle theme</span>
32
- </Button>
52
+ </TooltipButton>
33
53
  {/if}
34
54
  </div>
35
55
  </header>