@svadmin/ui 0.14.0 → 0.17.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.14.0",
3
+ "version": "0.17.0",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
package/src/app.css CHANGED
@@ -154,7 +154,16 @@ button:active:not(:disabled) {
154
154
  border-color: var(--color-primary);
155
155
  }
156
156
 
157
- /* Scrollbar styling */
157
+ /* Base scrollbar styling (Firefox + Standard) */
158
+ * {
159
+ scrollbar-width: thin;
160
+ scrollbar-color: oklch(0.8 0 0) transparent;
161
+ }
162
+ .dark * {
163
+ scrollbar-color: oklch(0.35 0 0) transparent;
164
+ }
165
+
166
+ /* Scrollbar styling (Webkit) */
158
167
  ::-webkit-scrollbar { width: 6px; height: 6px; }
159
168
  ::-webkit-scrollbar-track { background: transparent; }
160
169
  ::-webkit-scrollbar-thumb { background: oklch(0.8 0 0); border-radius: 3px; }
@@ -166,9 +175,11 @@ button:active:not(:disabled) {
166
175
  [data-slot="card"] {
167
176
  transition: box-shadow 0.2s ease, transform 0.2s ease;
168
177
  }
169
- [data-slot="card"]:hover {
170
- box-shadow: 0 4px 12px -2px rgb(0 0 0 / 0.08), 0 8px 24px -6px rgb(0 0 0 / 0.04);
171
- transform: translateY(-1px);
178
+ @media (hover: hover) {
179
+ [data-slot="card"]:hover {
180
+ box-shadow: 0 4px 12px -2px rgb(0 0 0 / 0.08), 0 8px 24px -6px rgb(0 0 0 / 0.04);
181
+ transform: translateY(-1px);
182
+ }
172
183
  }
173
184
 
174
185
  /* ② Table Row Hover — primary color tint instead of plain gray */
@@ -302,6 +313,13 @@ button:active:not(:disabled) {
302
313
  }
303
314
  }
304
315
 
316
+ /* ⑫b Safe Area Inset — iPhone notch / Home Bar support */
317
+ @supports (padding-bottom: env(safe-area-inset-bottom)) {
318
+ main {
319
+ padding-bottom: env(safe-area-inset-bottom);
320
+ }
321
+ }
322
+
305
323
  /* ⑫ Optimistic Delete — row fade-out visual feedback */
306
324
  @keyframes svadmin-row-delete {
307
325
  from {
@@ -372,6 +390,25 @@ main {
372
390
  margin-right: 4rem;
373
391
  }
374
392
 
393
+ /* ⑰ Accessibility Minimum Touch Target — Apple HIG 44px minimum */
394
+ @media (max-width: 640px) and (pointer: coarse) {
395
+ [data-slot="button"] {
396
+ position: relative;
397
+ }
398
+ [data-slot="button"]::after {
399
+ content: '';
400
+ position: absolute;
401
+ top: 50%;
402
+ left: 50%;
403
+ transform: translate(-50%, -50%);
404
+ min-width: 44px;
405
+ min-height: 44px;
406
+ width: 100%;
407
+ height: 100%;
408
+ z-index: 1;
409
+ }
410
+ }
411
+
375
412
  /* Tailwind CSS v4 — Map design tokens to utility classes */
376
413
  @theme {
377
414
  --color-background: var(--background);
@@ -1,9 +1,8 @@
1
1
  <script lang="ts">
2
- import { useOne, useCreate, useUpdate, getResource } from '@svadmin/core';
2
+ import { useForm, getResource, deriveValidator } from '@svadmin/core';
3
3
  import type { FieldDefinition } from '@svadmin/core';
4
- import { navigate } from '@svadmin/core/router';
5
- import { canAccess } from '@svadmin/core/permissions';
6
4
  import { t } from '@svadmin/core/i18n';
5
+ import { navigate } from '@svadmin/core/router';
7
6
  import { Button } from './ui/button/index.js';
8
7
  import TooltipButton from './TooltipButton.svelte';
9
8
  import * as Card from './ui/card/index.js';
@@ -13,30 +12,20 @@
13
12
  import * as Alert from './ui/alert/index.js';
14
13
  import { Skeleton } from './ui/skeleton/index.js';
15
14
  import ConfirmDialog from './ConfirmDialog.svelte';
16
-
17
15
  import type { Snippet } from 'svelte';
18
16
 
19
17
  interface Props {
20
18
  resourceName: string;
21
19
  id?: string | number;
22
20
  mode?: 'create' | 'edit';
23
- /** Custom field renderer — overrides default FieldRenderer */
24
21
  fieldRenderer?: Snippet<[{ field: FieldDefinition; value: unknown; onchange: (v: unknown) => void }]>;
25
- /** Custom form action buttons */
26
22
  formActions?: Snippet<[{ isLoading: boolean; onSubmit: () => void }]>;
27
- /** Custom header content (after title) */
28
23
  headerContent?: Snippet;
29
24
  }
30
25
 
31
- let {
32
- resourceName,
33
- id = undefined,
34
- mode = 'create',
35
- fieldRenderer,
36
- formActions,
37
- headerContent,
38
- }: Props = $props();
26
+ let { resourceName, id, mode = 'create', fieldRenderer, formActions, headerContent }: Props = $props();
39
27
 
28
+ // ─── Resource metadata ────────────────────────────────────────────
40
29
  const resource = $derived(getResource(resourceName));
41
30
  const primaryKey = $derived(resource.primaryKey ?? 'id');
42
31
 
@@ -61,19 +50,7 @@
61
50
  return order.map(g => ({ name: g, fields: map.get(g)! }));
62
51
  })());
63
52
 
64
- // Load existing data for edit mode
65
- const existingQuery = $derived.by(() => mode === 'edit' && id != null
66
- ? useOne({ get resource() { return resourceName; }, get id() { return id; } })
67
- : null);
68
-
69
- // Form state
70
- let formData = $state<Record<string, unknown>>({});
71
- let fieldErrors = $state<Record<string, string>>({});
72
- let submitting = $state(false);
73
- let error = $state<string | null>(null);
74
- let initialized = $state(false);
75
- let isDirty = $state(false);
76
-
53
+ // ─── Default values from field metadata ───────────────────────────
77
54
  function getDefaultForType(field: FieldDefinition): unknown {
78
55
  switch (field.type) {
79
56
  case 'text': case 'textarea': case 'richtext': case 'image': return '';
@@ -86,115 +63,47 @@
86
63
  }
87
64
  }
88
65
 
89
- // Initialize form data (pre-DOM to avoid flash of empty values)
90
- $effect.pre(() => {
91
- if (initialized) return;
92
- if (mode === 'create') {
93
- const defaults: Record<string, unknown> = {};
94
- for (const field of formFields) {
95
- defaults[field.key] = field.defaultValue ?? getDefaultForType(field);
96
- }
97
- formData = defaults;
98
- initialized = true;
99
- } else if (existingQuery && existingQuery.query.data?.data) {
100
- formData = { ...existingQuery.query.data.data as Record<string, unknown> };
101
- initialized = true;
102
- }
103
- });
104
-
105
- // Unsaved changes warning
106
- $effect(() => {
107
- if (!isDirty) return;
108
-
109
- function handleBeforeUnload(e: BeforeUnloadEvent) {
110
- e.preventDefault();
111
- }
66
+ const defaults = $derived((() => {
67
+ const d: Record<string, unknown> = {};
68
+ for (const f of formFields) d[f.key] = f.defaultValue ?? getDefaultForType(f);
69
+ return d;
70
+ })());
112
71
 
113
- window.addEventListener('beforeunload', handleBeforeUnload);
114
- return () => {
115
- window.removeEventListener('beforeunload', handleBeforeUnload);
116
- };
72
+ // ─── useForm: single source of truth for values, errors, tainted ──
73
+ const form = useForm({
74
+ get resource() { return resourceName; },
75
+ get action() { return mode; },
76
+ get id() { return id; },
77
+ defaultValues: defaults,
78
+ redirect: 'list',
79
+ warnWhenUnsavedChanges: true,
80
+ validate: deriveValidator(formFields),
117
81
  });
118
82
 
119
- const createMut = useCreate({ get resource() { return resourceName; } });
120
- const updateMut = useUpdate({ get resource() { return resourceName; } });
121
-
122
- function validateFields(): boolean {
123
- const errors: Record<string, string> = {};
124
- for (const field of formFields) {
125
- const value = formData[field.key];
126
- // Required check
127
- if (field.required) {
128
- if (value === undefined || value === null || value === '') {
129
- errors[field.key] = t('validation.required');
130
- continue;
131
- }
132
- }
133
- // Custom per-field validator
134
- if (field.validate) {
135
- const msg = field.validate(value);
136
- if (msg) { errors[field.key] = msg; }
137
- }
138
- }
139
- fieldErrors = errors;
140
- return Object.keys(errors).length === 0;
141
- }
83
+ // ─── Submission error (non-field, e.g. network error) ─────────────
84
+ let submitError = $state<string | null>(null);
142
85
 
143
86
  async function handleSubmit() {
144
- submitting = true;
145
- error = null;
146
-
147
- if (!validateFields()) {
148
- submitting = false;
149
- return;
150
- }
151
-
87
+ submitError = null;
152
88
  try {
153
- const cleanData: Record<string, unknown> = {};
154
- for (const field of formFields) {
155
- const value = formData[field.key];
156
- if (value !== undefined) {
157
- cleanData[field.key] = value;
158
- }
159
- }
160
-
161
- if (mode === 'create') {
162
- await createMut.mutation.mutateAsync({ variables: cleanData });
163
- } else if (id != null) {
164
- await updateMut.mutation.mutateAsync({ id, variables: cleanData });
165
- }
166
-
167
- isDirty = false;
168
- navigate(`/${resourceName}`);
89
+ await form.submit();
169
90
  } catch (e) {
170
- error = e instanceof Error ? e.message : t('common.operationFailed');
171
- } finally {
172
- submitting = false;
173
- }
174
- }
175
-
176
- function handleFieldChange(key: string, val: unknown) {
177
- formData[key] = val;
178
- isDirty = true;
179
- // Clear field error when user starts typing
180
- if (fieldErrors[key]) {
181
- delete fieldErrors[key];
91
+ submitError = e instanceof Error ? e.message : t('common.operationFailed');
182
92
  }
183
93
  }
184
94
 
185
- const isLoading = $derived(mode === 'edit' && existingQuery ? existingQuery.query.isLoading : false);
186
-
187
95
  const pageTitle = $derived(
188
96
  mode === 'create'
189
97
  ? `${t('common.create')}${resource.label}`
190
98
  : `${t('common.edit')}${resource.label}`
191
99
  );
192
- // Unsaved changes dialog state
100
+
101
+ // ─── Unsaved changes guard ────────────────────────────────────────
193
102
  let confirmOpen = $state(false);
194
103
  let pendingNavigation: (() => void) | null = null;
195
104
 
196
105
  function guardNavigate(fn: () => void) {
197
- if (isDirty) {
106
+ if (form.isTainted()) {
198
107
  pendingNavigation = fn;
199
108
  confirmOpen = true;
200
109
  } else {
@@ -223,12 +132,12 @@
223
132
  <ArrowLeft class="h-5 w-5" />
224
133
  </TooltipButton>
225
134
  <h1 class="text-xl font-semibold text-foreground">{pageTitle}</h1>
226
- {#if isDirty}
135
+ {#if form.isTainted()}
227
136
  <Badge variant="outline" class="border-warning/30 bg-warning/10 text-warning-foreground">{t('common.unsaved')}</Badge>
228
137
  {/if}
229
138
  </div>
230
139
 
231
- {#if isLoading}
140
+ {#if form.loading}
232
141
  <div class="max-w-3xl space-y-6">
233
142
  <div class="rounded-lg border p-6 space-y-5">
234
143
  {#each Array(4) as _}
@@ -241,10 +150,10 @@
241
150
  </div>
242
151
  {:else}
243
152
  <form onsubmit={(e: Event) => { e.preventDefault(); handleSubmit(); }} class="max-w-3xl space-y-6">
244
- {#if error}
153
+ {#if submitError}
245
154
  <Alert.Root variant="destructive">
246
155
  <AlertCircle class="h-4 w-4" />
247
- <Alert.Description>{error}</Alert.Description>
156
+ <Alert.Description>{submitError}</Alert.Description>
248
157
  </Alert.Root>
249
158
  {/if}
250
159
 
@@ -256,20 +165,20 @@
256
165
  <Card.Title class="text-lg">{group.name}</Card.Title>
257
166
  </Card.Header>
258
167
  {/if}
259
- <Card.Content class="space-y-5">
168
+ <Card.Content class="space-y-5 px-4 sm:px-6 pb-4 sm:pb-6 pt-0">
260
169
  {#each group.fields as field (field.key)}
261
- <div class:border-destructive={!!fieldErrors[field.key]}>
170
+ <div class:border-destructive={!!form.errors[field.key]}>
262
171
  {#if fieldRenderer}
263
- {@render fieldRenderer({ field, value: formData[field.key], onchange: (val: unknown) => handleFieldChange(field.key, val) })}
172
+ {@render fieldRenderer({ field, value: form.values[field.key], onchange: (val: unknown) => form.setFieldValue(field.key, val) })}
264
173
  {:else}
265
174
  <FieldRenderer
266
175
  {field}
267
- value={formData[field.key]}
268
- onchange={(val: unknown) => handleFieldChange(field.key, val)}
176
+ value={form.values[field.key]}
177
+ onchange={(val: unknown) => form.setFieldValue(field.key, val)}
269
178
  />
270
179
  {/if}
271
- {#if fieldErrors[field.key]}
272
- <p class="text-destructive text-[0.8125rem] mt-1">{fieldErrors[field.key]}</p>
180
+ {#if form.errors[field.key]}
181
+ <p class="text-destructive text-[0.8125rem] mt-1">{form.errors[field.key]}</p>
273
182
  {/if}
274
183
  </div>
275
184
  {/each}
@@ -278,20 +187,20 @@
278
187
  {/each}
279
188
  {:else}
280
189
  <Card.Root>
281
- <Card.Content class="space-y-5">
190
+ <Card.Content class="space-y-5 px-4 sm:px-6 pb-4 sm:pb-6 pt-0">
282
191
  {#each formFields as field (field.key)}
283
- <div class:border-destructive={!!fieldErrors[field.key]}>
192
+ <div class:border-destructive={!!form.errors[field.key]}>
284
193
  {#if fieldRenderer}
285
- {@render fieldRenderer({ field, value: formData[field.key], onchange: (val: unknown) => handleFieldChange(field.key, val) })}
194
+ {@render fieldRenderer({ field, value: form.values[field.key], onchange: (val: unknown) => form.setFieldValue(field.key, val) })}
286
195
  {:else}
287
196
  <FieldRenderer
288
197
  {field}
289
- value={formData[field.key]}
290
- onchange={(val: unknown) => handleFieldChange(field.key, val)}
198
+ value={form.values[field.key]}
199
+ onchange={(val: unknown) => form.setFieldValue(field.key, val)}
291
200
  />
292
201
  {/if}
293
- {#if fieldErrors[field.key]}
294
- <p class="text-destructive text-[0.8125rem] mt-1">{fieldErrors[field.key]}</p>
202
+ {#if form.errors[field.key]}
203
+ <p class="text-destructive text-[0.8125rem] mt-1">{form.errors[field.key]}</p>
295
204
  {/if}
296
205
  </div>
297
206
  {/each}
@@ -301,10 +210,10 @@
301
210
 
302
211
  <div class="flex items-center gap-3">
303
212
  {#if formActions}
304
- {@render formActions({ isLoading: submitting, onSubmit: handleSubmit })}
213
+ {@render formActions({ isLoading: form.submitting, onSubmit: handleSubmit })}
305
214
  {:else}
306
- <Button type="submit" disabled={submitting}>
307
- {#if submitting}
215
+ <Button type="submit" disabled={form.submitting}>
216
+ {#if form.submitting}
308
217
  <Loader2 class="h-4 w-4 animate-spin" data-icon="inline-start" />
309
218
  {:else}
310
219
  <Save class="h-4 w-4" data-icon="inline-start" />
@@ -335,9 +335,9 @@
335
335
 
336
336
  <div class="space-y-4">
337
337
  <!-- Header -->
338
- <div class="flex items-center justify-between">
339
- <h1 class="text-xl font-semibold text-foreground">{resource.label}</h1>
340
- <div class="flex items-center gap-2">
338
+ <div class="flex flex-wrap items-center justify-between gap-2">
339
+ <h1 class="text-lg sm:text-xl font-semibold text-foreground">{resource.label}</h1>
340
+ <div class="flex flex-wrap items-center gap-2">
341
341
  {#if canExport}
342
342
  <Button variant="outline" size="sm" onclick={exportCSV}>
343
343
  <Download class="h-4 w-4" data-icon="inline-start" /> {t('common.export')}
@@ -575,6 +575,8 @@
575
575
  value={cell.getValue()}
576
576
  onSave={() => listResult.query.refetch()}
577
577
  />
578
+ {:else if field?.key === primaryKey}
579
+ <span class="font-mono text-xs">{cell.getValue() ?? '—'}</span>
578
580
  {:else}
579
581
  {cell.getValue() ?? '—'}
580
582
  {/if}
@@ -723,11 +725,11 @@
723
725
 
724
726
  <!-- Pagination (shadcn) -->
725
727
  {#if totalPages > 0}
726
- <div class="flex items-center justify-between text-sm text-muted-foreground">
728
+ <div class="flex flex-col sm:flex-row items-center justify-between gap-2 text-sm text-muted-foreground">
727
729
  <div class="flex items-center gap-2">
728
730
  <span>{t('common.total', { total: query.data?.total ?? 0 })}</span>
729
731
  <Select
730
- class="h-8 w-auto text-xs"
732
+ class="hidden sm:inline-block h-8 w-auto text-xs"
731
733
  value={String(pagination.pageSize ?? 10)}
732
734
  onchange={(e: Event) => {
733
735
  const size = Number((e.target as HTMLSelectElement).value);
@@ -260,7 +260,7 @@
260
260
  tooltip={t('chat.title') || 'AI Assistant'}
261
261
  variant="default"
262
262
  size="icon"
263
- class="fixed bottom-6 right-6 z-[9998] h-12 w-12 rounded-full shadow-xl hover:shadow-2xl hover:scale-110 transition-all bg-primary text-primary-foreground"
263
+ class="fixed bottom-4 right-4 sm:bottom-6 sm:right-6 z-[9998] h-12 w-12 rounded-full shadow-xl hover:shadow-2xl hover:scale-110 transition-all bg-primary text-primary-foreground"
264
264
  onclick={() => { open = true; minimized = false; }}
265
265
  >
266
266
  <MessageCircle class="h-5 w-5" />
@@ -276,9 +276,11 @@
276
276
  <!-- Chat Panel -->
277
277
  {#if open}
278
278
  <div
279
- class="fixed bottom-6 right-6 z-[9998] flex flex-col rounded-2xl border bg-card shadow-2xl overflow-hidden"
280
- class:w-[400px]={!minimized}
281
- class:h-[560px]={!minimized}
279
+ class="fixed z-[9998] flex flex-col rounded-2xl border bg-card shadow-2xl overflow-hidden bottom-3 right-3 sm:bottom-6 sm:right-6"
280
+ class:w-[calc(100vw-1.5rem)]={!minimized}
281
+ class:sm:w-[400px]={!minimized}
282
+ class:h-[calc(100dvh-6rem)]={!minimized}
283
+ class:sm:h-[560px]={!minimized}
282
284
  class:w-[280px]={minimized}
283
285
  class:h-auto={minimized}
284
286
  transition:fly={{ y: 300, duration: 250 }}
@@ -35,7 +35,7 @@
35
35
  placeholder={t('common.search')}
36
36
  class="flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
37
37
  />
38
- <kbd class="ml-2 text-[10px] font-medium text-muted-foreground bg-muted px-1.5 py-0.5 rounded border border-border">ESC</kbd>
38
+ <kbd class="ml-2 text-[10px] font-mono font-medium text-muted-foreground bg-muted px-1.5 py-0.5 rounded border border-border">ESC</kbd>
39
39
  </div>
40
40
  <Command.List class="max-h-[320px] overflow-y-auto overflow-x-hidden p-1">
41
41
  <Command.Empty class="py-6 text-center text-sm text-muted-foreground">
@@ -58,7 +58,7 @@
58
58
  <Button variant="outline" size="sm" onclick={onSearchClick} class="gap-2 text-muted-foreground h-8 px-3">
59
59
  <Search class="h-3.5 w-3.5" />
60
60
  <span class="hidden sm:inline text-xs">Search...</span>
61
- <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">
61
+ <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 font-mono text-muted-foreground">
62
62
  <span class="text-xs">⌘</span>K
63
63
  </kbd>
64
64
  </Button>
@@ -48,7 +48,7 @@
48
48
  <span class="text-sm text-foreground">{shortcut.label}</span>
49
49
  <div class="flex items-center gap-1">
50
50
  {#each shortcut.keys as key}
51
- <kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border bg-muted px-1.5 text-[10px] font-medium text-muted-foreground">{key}</kbd>
51
+ <kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border bg-muted px-1.5 text-[10px] font-medium font-mono text-muted-foreground">{key}</kbd>
52
52
  {/each}
53
53
  </div>
54
54
  </div>
@@ -62,12 +62,42 @@
62
62
  }
63
63
 
64
64
  let collapsed = $state(false);
65
+
66
+ // Swipe gesture for mobile menu
67
+ let touchStartX = $state(0);
68
+ let touchEndX = $state(0);
69
+
70
+ function handleTouchStart(e: TouchEvent) {
71
+ if (e.touches.length === 1) {
72
+ touchStartX = e.touches[0].clientX;
73
+ touchEndX = e.touches[0].clientX;
74
+ }
75
+ }
76
+
77
+ function handleTouchMove(e: TouchEvent) {
78
+ if (e.touches.length === 1) {
79
+ touchEndX = e.touches[0].clientX;
80
+ }
81
+ }
82
+
83
+ function handleTouchEnd() {
84
+ // Only trigger swipe-to-open if starting near the left edge (e.g., within 30px)
85
+ // and swiping right by at least 50px
86
+ if (touchStartX < 30 && touchEndX - touchStartX > 50) {
87
+ mobileMenuOpen = true;
88
+ }
89
+ }
65
90
  </script>
66
91
 
67
- <svelte:window onkeydown={(e) => {
68
- if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); commandOpen = true; }
69
- if (e.key === '?' && !['INPUT', 'TEXTAREA', 'SELECT'].includes((e.target as HTMLElement)?.tagName)) { e.preventDefault(); shortcutsOpen = true; }
70
- }} />
92
+ <svelte:window
93
+ onkeydown={(e) => {
94
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); commandOpen = true; }
95
+ if (e.key === '?' && !['INPUT', 'TEXTAREA', 'SELECT'].includes((e.target as HTMLElement)?.tagName)) { e.preventDefault(); shortcutsOpen = true; }
96
+ }}
97
+ ontouchstart={handleTouchStart}
98
+ ontouchmove={handleTouchMove}
99
+ ontouchend={handleTouchEnd}
100
+ />
71
101
  {#if loading}
72
102
  <div class="flex h-screen" in:fade={{ duration: 150 }}>
73
103
  <div class="hidden md:block w-64 bg-sidebar/80 p-4 space-y-4">
@@ -1,15 +1,16 @@
1
1
  <script lang="ts">
2
2
  // @ts-expect-error - missing types
3
- import { Marked } from 'marked';
3
+ import { Marked } from "marked";
4
4
  // @ts-expect-error - missing types
5
- import { markedHighlight } from 'marked-highlight';
5
+ import { markedHighlight } from "marked-highlight";
6
6
  // @ts-expect-error - missing types
7
- import hljs from 'highlight.js';
8
- import 'highlight.js/styles/github-dark.css'; // or your preferred theme
7
+ import * as hljsModule from "highlight.js";
8
+ const hljs = "default" in hljsModule ? hljsModule.default : hljsModule;
9
+ import "highlight.js/styles/github-dark.css"; // or your preferred theme
9
10
  // @ts-expect-error - missing types
10
- import DOMPurify from 'isomorphic-dompurify';
11
- import { Check, Copy } from 'lucide-svelte';
12
- import { Button } from './ui/button/index.js';
11
+ import DOMPurify from "isomorphic-dompurify";
12
+ import { Check, Copy } from "lucide-svelte";
13
+ import { Button } from "./ui/button/index.js";
13
14
 
14
15
  interface Props {
15
16
  /** The raw markdown string to render */
@@ -20,39 +21,41 @@
20
21
  class?: string;
21
22
  }
22
23
 
23
- let { content, streaming = false, class: className = '' }: Props = $props();
24
+ let { content, streaming = false, class: className = "" }: Props = $props();
24
25
 
25
26
  // Configure marked with syntax highlighting
26
27
  const marked = new Marked(
27
28
  markedHighlight({
28
- langPrefix: 'hljs language-',
29
+ langPrefix: "hljs language-",
29
30
  highlight(code: string, lang: string) {
30
- const language = hljs.getLanguage(lang) ? lang : 'plaintext';
31
+ const language = hljs.getLanguage(lang) ? lang : "plaintext";
31
32
  return hljs.highlight(code, { language }).value;
32
- }
33
- })
33
+ },
34
+ }),
34
35
  );
35
36
 
36
37
  // Render HTML safely
37
- const html = $derived(DOMPurify.sanitize(marked.parse(content || '') as string));
38
+ const html = $derived(
39
+ DOMPurify.sanitize(marked.parse(content || "") as string),
40
+ );
38
41
 
39
42
  // Handle copy code blocks
40
43
  let copiedBlock = $state<string | null>(null);
41
44
 
42
45
  function handleCopy(event: MouseEvent) {
43
46
  const target = event.target as HTMLElement;
44
- const button = target.closest('.copy-btn');
47
+ const button = target.closest(".copy-btn");
45
48
  if (!button) return;
46
49
 
47
- const pre = button.closest('.code-block-wrapper')?.querySelector('pre');
50
+ const pre = button.closest(".code-block-wrapper")?.querySelector("pre");
48
51
  if (!pre) return;
49
52
 
50
- const code = pre.textContent || '';
53
+ const code = pre.textContent || "";
51
54
  navigator.clipboard.writeText(code);
52
55
 
53
56
  const id = Math.random().toString(36);
54
57
  copiedBlock = id;
55
- button.setAttribute('data-copied-id', id);
58
+ button.setAttribute("data-copied-id", id);
56
59
 
57
60
  setTimeout(() => {
58
61
  if (copiedBlock === id) copiedBlock = null;
@@ -65,48 +68,51 @@
65
68
  return {
66
69
  update(newHtml: string) {
67
70
  // Find all pre > code blocks that don't have wrappers yet
68
- const pres = node.querySelectorAll('pre:not(.enhanced)');
69
- pres.forEach(pre => {
70
- pre.classList.add('enhanced');
71
- const wrapper = document.createElement('div');
72
- wrapper.className = 'code-block-wrapper group relative my-4 rounded-md bg-foreground/95';
73
-
74
- const header = document.createElement('div');
75
- header.className = 'flex items-center justify-between px-4 py-2 text-xs text-zinc-400 border-b border-zinc-800';
76
-
77
- const lang = Array.from(pre.querySelector('code')?.classList || []).find(c => c.startsWith('language-'))?.replace('language-', '') || 'Code';
71
+ const pres = node.querySelectorAll("pre:not(.enhanced)");
72
+ pres.forEach((pre) => {
73
+ pre.classList.add("enhanced");
74
+ const wrapper = document.createElement("div");
75
+ wrapper.className =
76
+ "code-block-wrapper group relative my-4 rounded-md bg-foreground/95";
77
+
78
+ const header = document.createElement("div");
79
+ header.className =
80
+ "flex items-center justify-between px-4 py-2 text-xs text-zinc-400 border-b border-zinc-800";
81
+
82
+ const lang =
83
+ Array.from(pre.querySelector("code")?.classList || [])
84
+ .find((c) => c.startsWith("language-"))
85
+ ?.replace("language-", "") || "Code";
78
86
  header.innerHTML = `
79
87
  <span>${lang}</span>
80
88
  <button class="copy-btn hover:text-white transition-colors flex items-center gap-1">
81
89
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
82
90
  </button>
83
91
  `;
84
-
92
+
85
93
  pre.replaceWith(wrapper);
86
94
  wrapper.appendChild(header);
87
-
88
- const scrollContainer = document.createElement('div');
89
- scrollContainer.className = 'overflow-x-auto p-4';
95
+
96
+ const scrollContainer = document.createElement("div");
97
+ scrollContainer.className = "overflow-x-auto p-4";
90
98
  scrollContainer.appendChild(pre);
91
99
  wrapper.appendChild(scrollContainer);
92
100
  });
93
101
 
94
102
  // Add event listener for copy buttons
95
- node.addEventListener('click', handleCopy);
103
+ node.addEventListener("click", handleCopy);
96
104
  },
97
105
  destroy() {
98
- node.removeEventListener('click', handleCopy);
99
- }
106
+ node.removeEventListener("click", handleCopy);
107
+ },
100
108
  };
101
109
  }
102
110
  </script>
103
111
 
104
112
  <div
105
- class="prose prose-sm dark:prose-invert max-w-none break-words {className}"
113
+ class="prose prose-sm dark:prose-invert max-w-none wrap-break-word {className}"
106
114
  class:streaming={streaming}
107
115
  use:enhanceCodeBlocks={html}
108
116
  >
109
117
  {@html html}
110
118
  </div>
111
-
112
-
@@ -39,9 +39,9 @@
39
39
  <Card.Root>
40
40
  <Card.Content class="divide-y divide-border p-0">
41
41
  {#each showFields.slice(0, 6) as _}
42
- <div class="flex px-6 py-4">
43
- <Skeleton class="h-4 w-1/4" />
44
- <Skeleton class="h-4 w-2/5 ml-auto" />
42
+ <div class="flex flex-col sm:flex-row px-4 sm:px-6 py-3 sm:py-4">
43
+ <Skeleton class="h-4 w-1/2 sm:w-1/4" />
44
+ <Skeleton class="h-4 w-3/4 sm:w-2/5 mt-1 sm:mt-0 sm:ml-auto" />
45
45
  </div>
46
46
  {/each}
47
47
  </Card.Content>
@@ -52,9 +52,9 @@
52
52
  {#each showFields as field}
53
53
  {@const value = (query.query.data!.data as Record<string, unknown>)[field.key]}
54
54
  {@const DisplayComponent = getDisplayComponent(field.type)}
55
- <div class="flex px-6 py-4">
56
- <div class="w-1/3 text-sm font-medium text-muted-foreground">{field.label}</div>
57
- <div class="w-2/3 text-sm text-foreground">
55
+ <div class="flex flex-col sm:flex-row px-4 sm:px-6 py-3 sm:py-4">
56
+ <div class="sm:w-1/3 text-xs sm:text-sm font-medium text-muted-foreground mb-1 sm:mb-0">{field.label}</div>
57
+ <div class="sm:w-2/3 text-sm text-foreground">
58
58
  {#if DisplayComponent && value != null}
59
59
  <DisplayComponent
60
60
  {value}
@@ -57,7 +57,7 @@
57
57
  <Skeleton class="mt-1 h-7 w-20" />
58
58
  {:else}
59
59
  <div class="flex items-baseline gap-2">
60
- <p class="text-xl font-semibold text-foreground">{value}</p>
60
+ <p class="text-xl font-mono font-semibold text-foreground tracking-tight">{value}</p>
61
61
  {#if trend}
62
62
  <span
63
63
  class="text-xs font-medium {trend.value >= 0 ? 'text-success' : 'text-destructive'}"
@@ -1,15 +1,14 @@
1
1
  <script lang="ts">
2
2
  import { fly } from 'svelte/transition';
3
- import { useStepsForm } from '@svadmin/core';
3
+ import { useStepsForm, deriveValidator } from '@svadmin/core';
4
4
  import type { FieldDefinition } from '@svadmin/core';
5
- import { getResource, useOne } from '@svadmin/core';
5
+ import { getResource } from '@svadmin/core';
6
6
  import { navigate } from '@svadmin/core/router';
7
7
  import { t } from '@svadmin/core/i18n';
8
8
  import { Button } from './ui/button/index.js';
9
9
  import * as Card from './ui/card/index.js';
10
10
  import { Badge } from './ui/badge/index.js';
11
11
  import { Progress } from './ui/progress/index.js';
12
- import { Skeleton } from './ui/skeleton/index.js';
13
12
  import { Save, ArrowLeft, ArrowRight, Check, Loader2 } from 'lucide-svelte';
14
13
  import FieldRenderer from './FieldRenderer.svelte';
15
14
 
@@ -30,37 +29,12 @@
30
29
  const resource = $derived(getResource(resourceName));
31
30
  const primaryKey = $derived(resource.primaryKey ?? 'id');
32
31
 
33
- const stepsForm = useStepsForm({
34
- get resource() { return resourceName; },
35
- get action() { return mode; },
36
- get id() { return id; },
37
- });
38
-
39
- const { currentStep, next, prev, gotoStep } = stepsForm;
40
-
41
- const totalSteps = $derived(steps.length);
42
- const progressValue = $derived(((currentStep + 1) / totalSteps) * 100);
43
- const isLastStep = $derived(currentStep >= totalSteps - 1);
44
-
45
- // Get fields for current step
46
- const currentFields = $derived(
47
- steps[currentStep]?.fields
48
- .map(key => resource.fields.find(f => f.key === key))
49
- .filter((f): f is FieldDefinition => f != null && f.key !== primaryKey) ?? []
50
- );
51
-
32
+ // All fields used across all steps
52
33
  const flatStepsFields = $derived(steps.flatMap(s => s.fields)
53
34
  .map(key => resource.fields.find(f => f.key === key))
54
35
  .filter((f): f is FieldDefinition => f != null));
55
36
 
56
- const existingQuery = $derived(mode === 'edit' && id != null
57
- ? stepsForm.query as { data?: { data: Record<string, unknown> }, isLoading: boolean }
58
- : null);
59
-
60
- let formData = $state<Record<string, unknown>>({});
61
- let fieldErrors = $state<Record<string, string>>({});
62
- let initialized = $state(false);
63
-
37
+ // Default values from field metadata
64
38
  function getDefaultForType(field: FieldDefinition): unknown {
65
39
  switch (field.type) {
66
40
  case 'text': case 'textarea': case 'richtext': case 'image': return '';
@@ -73,64 +47,65 @@
73
47
  }
74
48
  }
75
49
 
76
- $effect.pre(() => {
77
- if (initialized) return;
78
- if (mode === 'create') {
79
- const defaults: Record<string, unknown> = {};
80
- for (const field of flatStepsFields) {
81
- defaults[field.key] = field.defaultValue ?? getDefaultForType(field);
82
- }
83
- formData = defaults;
84
- initialized = true;
85
- } else if (existingQuery && existingQuery.data?.data) {
86
- formData = { ...existingQuery.data.data };
87
- initialized = true;
88
- }
50
+ const defaults = $derived((() => {
51
+ const d: Record<string, unknown> = {};
52
+ for (const f of flatStepsFields) d[f.key] = f.defaultValue ?? getDefaultForType(f);
53
+ return d;
54
+ })());
55
+
56
+ // ─── useStepsForm: form state + step navigation ──────────────────
57
+ const form = useStepsForm({
58
+ get resource() { return resourceName; },
59
+ get action() { return mode; },
60
+ get id() { return id; },
61
+ defaultValues: defaults,
62
+ redirect: 'list',
63
+ warnWhenUnsavedChanges: true,
64
+ validate: deriveValidator(flatStepsFields),
89
65
  });
90
66
 
91
- function handleFieldChange(key: string, val: unknown) {
92
- formData[key] = val;
93
- if (fieldErrors[key]) delete fieldErrors[key];
94
- }
67
+ const totalSteps = $derived(steps.length);
68
+ const progressValue = $derived(((form.currentStep + 1) / totalSteps) * 100);
69
+ const isLastStep = $derived(form.currentStep >= totalSteps - 1);
70
+
71
+ // Fields for the current step
72
+ const currentFields = $derived(
73
+ steps[form.currentStep]?.fields
74
+ .map(key => resource.fields.find(f => f.key === key))
75
+ .filter((f): f is FieldDefinition => f != null && f.key !== primaryKey) ?? []
76
+ );
95
77
 
78
+ // Validate only fields in the current step
96
79
  function validateCurrentStep(): boolean {
97
- const errors: Record<string, string> = {};
98
80
  let valid = true;
81
+ form.clearErrors();
99
82
  for (const field of currentFields) {
100
- const value = formData[field.key];
83
+ const value = form.values[field.key];
101
84
  if (field.required && (value === undefined || value === null || value === '')) {
102
- errors[field.key] = t('validation.required');
85
+ form.setFieldError(field.key, t('validation.required'));
103
86
  valid = false;
104
87
  } else if (field.validate) {
105
88
  const msg = field.validate(value);
106
89
  if (msg) {
107
- errors[field.key] = msg;
90
+ form.setFieldError(field.key, msg);
108
91
  valid = false;
109
92
  }
110
93
  }
111
94
  }
112
- fieldErrors = errors;
113
95
  return valid;
114
96
  }
115
97
 
116
- function handleSubmit() {
98
+ async function handleSubmit() {
117
99
  if (!validateCurrentStep()) return;
118
-
100
+
119
101
  if (!isLastStep) {
120
- next();
102
+ form.next();
121
103
  return;
122
104
  }
123
-
124
- const cleanData: Record<string, unknown> = {};
125
- for (const field of flatStepsFields) {
126
- const value = formData[field.key];
127
- if (value !== undefined) cleanData[field.key] = value;
128
- }
129
-
130
- stepsForm.onFinish(cleanData, { redirect: 'list' });
131
- }
132
105
 
133
- const isLoadingData = $derived(mode === 'edit' && !initialized);
106
+ // Last step — submit the full form
107
+ await form.submit({ redirect: 'list' });
108
+ }
134
109
  </script>
135
110
 
136
111
  <div class="space-y-6">
@@ -141,17 +116,17 @@
141
116
  <Button
142
117
  variant="ghost"
143
118
  type="button"
144
- class="flex items-center gap-2 transition-colors h-auto py-1 px-2 {i === currentStep ? 'text-primary font-semibold' : 'text-muted-foreground'}"
145
- onclick={() => gotoStep(i)}
119
+ class="flex items-center gap-2 transition-colors h-auto py-1 px-2 {i === form.currentStep ? 'text-primary font-semibold' : 'text-muted-foreground'}"
120
+ onclick={() => form.gotoStep(i)}
146
121
  >
147
122
  <span
148
123
  class="flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium transition-colors"
149
- class:bg-primary={i <= currentStep}
150
- class:text-primary-foreground={i <= currentStep}
151
- class:bg-muted={i > currentStep}
152
- class:text-muted-foreground={i > currentStep}
124
+ class:bg-primary={i <= form.currentStep}
125
+ class:text-primary-foreground={i <= form.currentStep}
126
+ class:bg-muted={i > form.currentStep}
127
+ class:text-muted-foreground={i > form.currentStep}
153
128
  >
154
- {#if i < currentStep}
129
+ {#if i < form.currentStep}
155
130
  <Check class="h-3.5 w-3.5" />
156
131
  {:else}
157
132
  {i + 1}
@@ -160,7 +135,7 @@
160
135
  <span class="hidden sm:inline">{step.title}</span>
161
136
  </Button>
162
137
  {#if i < steps.length - 1}
163
- <div class="flex-1 mx-2 h-px bg-border" class:bg-primary={i < currentStep}></div>
138
+ <div class="flex-1 mx-2 h-px bg-border" class:bg-primary={i < form.currentStep}></div>
164
139
  {/if}
165
140
  {/each}
166
141
  </div>
@@ -170,22 +145,22 @@
170
145
  <!-- Step content -->
171
146
  <Card.Root>
172
147
  <Card.CardHeader>
173
- <Card.CardTitle>{steps[currentStep]?.title ?? ''}</Card.CardTitle>
148
+ <Card.CardTitle>{steps[form.currentStep]?.title ?? ''}</Card.CardTitle>
174
149
  <Badge variant="outline" class="w-fit">
175
- {t('common.step')} {currentStep + 1} / {totalSteps}
150
+ {t('common.step')} {form.currentStep + 1} / {totalSteps}
176
151
  </Badge>
177
152
  </Card.CardHeader>
178
153
  <Card.CardContent>
179
- {#key currentStep}
154
+ {#key form.currentStep}
180
155
  <form class="space-y-5" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} in:fly={{ x: 30, duration: 250 }}>
181
156
  {#each currentFields as field (field.key)}
182
157
  <FieldRenderer
183
158
  {field}
184
- value={formData[field.key]}
185
- onchange={(v) => handleFieldChange(field.key, v)}
159
+ value={form.values[field.key]}
160
+ onchange={(v) => form.setFieldValue(field.key, v)}
186
161
  />
187
- {#if fieldErrors[field.key]}
188
- <p class="text-destructive text-[0.8125rem] mt-1">{fieldErrors[field.key]}</p>
162
+ {#if form.errors[field.key]}
163
+ <p class="text-destructive text-[0.8125rem] mt-1">{form.errors[field.key]}</p>
189
164
  {/if}
190
165
  {/each}
191
166
 
@@ -193,14 +168,14 @@
193
168
  <Button
194
169
  type="button"
195
170
  variant="outline"
196
- onclick={() => currentStep === 0 ? navigate(`/${resourceName}`) : prev()}
171
+ onclick={() => form.currentStep === 0 ? navigate(`/${resourceName}`) : form.prev()}
197
172
  >
198
173
  <ArrowLeft class="h-4 w-4" data-icon="inline-start" />
199
- {currentStep === 0 ? t('common.cancel') : t('common.back')}
174
+ {form.currentStep === 0 ? t('common.cancel') : t('common.back')}
200
175
  </Button>
201
176
 
202
- <Button type="submit" disabled={stepsForm.formLoading}>
203
- {#if stepsForm.formLoading}
177
+ <Button type="submit" disabled={form.submitting}>
178
+ {#if form.submitting}
204
179
  <Loader2 class="h-4 w-4 animate-spin" data-icon="inline-start" />
205
180
  {:else if isLastStep}
206
181
  <Save class="h-4 w-4" data-icon="inline-start" />
@@ -4,16 +4,13 @@
4
4
  import type { Snippet } from 'svelte';
5
5
  import type { HTMLButtonAttributes } from 'svelte/elements';
6
6
 
7
- interface Props {
7
+ interface Props extends Omit<HTMLButtonAttributes, 'class' | 'children'> {
8
8
  tooltip: string;
9
9
  variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
10
10
  size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm';
11
11
  side?: 'top' | 'right' | 'bottom' | 'left';
12
12
  class?: string;
13
13
  onclick?: (e: MouseEvent) => void;
14
- disabled?: boolean;
15
- type?: 'button' | 'submit' | 'reset';
16
- tabindex?: number;
17
14
  children: Snippet;
18
15
  }
19
16
 
@@ -28,6 +25,7 @@
28
25
  type = 'button',
29
26
  tabindex,
30
27
  children,
28
+ ...restProps
31
29
  }: Props = $props();
32
30
  </script>
33
31
 
@@ -36,10 +34,12 @@
36
34
  {#snippet child({ props })}
37
35
  <Button
38
36
  {...props}
37
+ {...restProps}
39
38
  {variant}
40
39
  {size}
41
40
  {type}
42
41
  class={className}
42
+ aria-label={restProps['aria-label'] ?? tooltip}
43
43
  {onclick}
44
44
  {disabled}
45
45
  {tabindex}
@@ -8,7 +8,7 @@
8
8
 
9
9
  <ol
10
10
  data-slot="breadcrumb-list"
11
- class={cn("text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5", className)}
11
+ class={cn("text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm wrap-break-word sm:gap-2.5", className)}
12
12
  {...restProps}
13
13
  >
14
14
  {@render children?.()}