@svadmin/ui 0.64.0 → 0.65.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 (73) hide show
  1. package/dist/components/ActivityFeed.svelte +135 -0
  2. package/dist/components/ActivityFeed.svelte.d.ts +21 -0
  3. package/dist/components/ApprovalActionCard.svelte +248 -0
  4. package/dist/components/ApprovalActionCard.svelte.d.ts +22 -0
  5. package/dist/components/CanvasAnnotation.svelte +389 -0
  6. package/dist/components/CanvasAnnotation.svelte.d.ts +17 -0
  7. package/dist/components/ColumnHeaderFilter.svelte +178 -0
  8. package/dist/components/ColumnHeaderFilter.svelte.d.ts +12 -0
  9. package/dist/components/ColumnSettings.svelte +198 -0
  10. package/dist/components/ColumnSettings.svelte.d.ts +19 -0
  11. package/dist/components/DecisionTable.svelte +240 -0
  12. package/dist/components/DecisionTable.svelte.d.ts +22 -0
  13. package/dist/components/DraggableRowTable.svelte +117 -0
  14. package/dist/components/DraggableRowTable.svelte.d.ts +15 -0
  15. package/dist/components/DrawerForm.svelte +110 -26
  16. package/dist/components/DrawerForm.svelte.d.ts +20 -8
  17. package/dist/components/EditableTable.svelte +195 -0
  18. package/dist/components/EditableTable.svelte.d.ts +25 -0
  19. package/dist/components/GanttChart.svelte +144 -0
  20. package/dist/components/GanttChart.svelte.d.ts +20 -0
  21. package/dist/components/ImageCropper.svelte +122 -0
  22. package/dist/components/ImageCropper.svelte.d.ts +12 -0
  23. package/dist/components/ImportWizard.svelte +336 -0
  24. package/dist/components/ImportWizard.svelte.d.ts +12 -0
  25. package/dist/components/JsonSchemaForm.svelte +149 -0
  26. package/dist/components/JsonSchemaForm.svelte.d.ts +10 -0
  27. package/dist/components/KanbanBoard.svelte +212 -0
  28. package/dist/components/KanbanBoard.svelte.d.ts +30 -0
  29. package/dist/components/MasterDetailView.svelte +134 -0
  30. package/dist/components/MasterDetailView.svelte.d.ts +16 -0
  31. package/dist/components/MediaLibraryModal.svelte +195 -0
  32. package/dist/components/MediaLibraryModal.svelte.d.ts +22 -0
  33. package/dist/components/MentionsInput.svelte +174 -0
  34. package/dist/components/MentionsInput.svelte.d.ts +21 -0
  35. package/dist/components/ModalForm.svelte +113 -28
  36. package/dist/components/ModalForm.svelte.d.ts +20 -8
  37. package/dist/components/MultiTabKeepAlive.svelte +143 -0
  38. package/dist/components/MultiTabKeepAlive.svelte.d.ts +21 -0
  39. package/dist/components/OfflineSyncBanner.svelte +172 -0
  40. package/dist/components/OfflineSyncBanner.svelte.d.ts +19 -0
  41. package/dist/components/PdfDocumentViewer.svelte +255 -0
  42. package/dist/components/PdfDocumentViewer.svelte.d.ts +22 -0
  43. package/dist/components/PivotTable.svelte +140 -0
  44. package/dist/components/PivotTable.svelte.d.ts +16 -0
  45. package/dist/components/PresenceAvatarGroup.svelte +89 -0
  46. package/dist/components/PresenceAvatarGroup.svelte.d.ts +15 -0
  47. package/dist/components/PrintableBill.svelte +149 -0
  48. package/dist/components/PrintableBill.svelte.d.ts +24 -0
  49. package/dist/components/SensitiveDataMask.svelte +125 -0
  50. package/dist/components/SensitiveDataMask.svelte.d.ts +13 -0
  51. package/dist/components/SignaturePad.svelte +264 -0
  52. package/dist/components/SignaturePad.svelte.d.ts +17 -0
  53. package/dist/components/SplitPaneLayout.svelte +113 -0
  54. package/dist/components/SplitPaneLayout.svelte.d.ts +13 -0
  55. package/dist/components/SpreadsheetView.svelte +363 -0
  56. package/dist/components/SpreadsheetView.svelte.d.ts +18 -0
  57. package/dist/components/StepForm.svelte +180 -0
  58. package/dist/components/StepForm.svelte.d.ts +23 -0
  59. package/dist/components/TableSummary.svelte +110 -0
  60. package/dist/components/TableSummary.svelte.d.ts +20 -0
  61. package/dist/components/TreeTable.svelte +237 -0
  62. package/dist/components/TreeTable.svelte.d.ts +28 -0
  63. package/dist/components/UnsavedChangesPrompt.svelte +44 -0
  64. package/dist/components/UnsavedChangesPrompt.svelte.d.ts +7 -0
  65. package/dist/components/VersionDiffViewer.svelte +196 -0
  66. package/dist/components/VersionDiffViewer.svelte.d.ts +12 -0
  67. package/dist/components/VirtualTable.svelte +86 -54
  68. package/dist/components/VirtualTable.svelte.d.ts +14 -21
  69. package/dist/components/Watermark.svelte +166 -0
  70. package/dist/components/Watermark.svelte.d.ts +26 -0
  71. package/dist/index.d.ts +58 -3
  72. package/dist/index.js +35 -3
  73. package/package.json +2 -2
@@ -0,0 +1,12 @@
1
+ interface Props {
2
+ imageUrl: string;
3
+ aspectRatio?: number;
4
+ zoom?: number;
5
+ rotation?: number;
6
+ oncrop?: (croppedDataUrl: string) => void;
7
+ oncancel?: () => void;
8
+ class?: string;
9
+ }
10
+ declare const ImageCropper: import("svelte").Component<Props, {}, "zoom" | "rotation">;
11
+ type ImageCropper = ReturnType<typeof ImageCropper>;
12
+ export default ImageCropper;
@@ -0,0 +1,336 @@
1
+ <script lang="ts">
2
+ import { getResource, captureAdminContext } from '@svadmin/core';
3
+ import { useTranslation } from '@svadmin/core/i18n';
4
+ import { parseCSV, toCsv } from '@svadmin/core';
5
+ import * as Dialog from './ui/dialog/index.js';
6
+ import { Button } from './ui/button/index.js';
7
+ import { Progress } from './ui/progress/index.js';
8
+ import { Badge } from './ui/badge/index.js';
9
+ import { Upload, ArrowRight, ArrowLeft, AlertCircle, Download, FileSpreadsheet, Loader2 } from '@lucide/svelte';
10
+
11
+ interface Props {
12
+ resourceName: string;
13
+ open?: boolean;
14
+ batchSize?: number;
15
+ onSuccess?: (result: { succeeded: number; failed: number }) => void;
16
+ }
17
+
18
+ let {
19
+ resourceName,
20
+ open = $bindable(false),
21
+ batchSize = 20,
22
+ onSuccess,
23
+ }: Props = $props();
24
+
25
+ const i18n = useTranslation();
26
+ const adminContext = captureAdminContext();
27
+ const resource = $derived(getResource(resourceName));
28
+ const availableFields = $derived(
29
+ resource.fields.filter((f) => f.key !== (resource.primaryKey ?? 'id') && f.showInForm !== false)
30
+ );
31
+
32
+ let currentStep = $state<1 | 2 | 3>(1);
33
+ let fileName = $state('');
34
+ let rawHeaders = $state<string[]>([]);
35
+ let rawRows = $state<string[][]>([]);
36
+ let columnMapping = $state<Record<string, string>>({}); // header -> fieldKey or ''
37
+
38
+ let isImporting = $state(false);
39
+ let processedCount = $state(0);
40
+ let succeededCount = $state(0);
41
+ let failedRecords = $state<Array<{ row: Record<string, unknown>; error: string }>>([]);
42
+
43
+ const progressPercent = $derived(
44
+ rawRows.length > 0 ? Math.min(100, Math.round((processedCount / rawRows.length) * 100)) : 0
45
+ );
46
+
47
+ async function handleFileSelect(selectedFile: File) {
48
+ fileName = selectedFile.name;
49
+ const text = await selectedFile.text();
50
+ const cleanText = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
51
+
52
+ if (fileName.endsWith('.json')) {
53
+ try {
54
+ const json = JSON.parse(cleanText);
55
+ if (Array.isArray(json) && json.length > 0) {
56
+ const keys = Object.keys(json[0]);
57
+ rawHeaders = keys;
58
+ rawRows = json.map((item) => keys.map((k) => String(item[k] ?? '')));
59
+ }
60
+ } catch {
61
+ /* invalid json */
62
+ }
63
+ } else {
64
+ const rows = parseCSV(cleanText);
65
+ if (rows.length > 0) {
66
+ rawHeaders = rows[0];
67
+ rawRows = rows.slice(1).filter((r: string[]) => r.some((cell: string) => cell.trim().length > 0));
68
+ }
69
+ }
70
+
71
+ // Auto-match headers to fields by key or label
72
+ const initialMapping: Record<string, string> = {};
73
+ for (const h of rawHeaders) {
74
+ const normalized = h.toLowerCase().trim().replace(/[-_]/g, '');
75
+ const match = availableFields.find(
76
+ (f) =>
77
+ f.key.toLowerCase().replace(/[-_]/g, '') === normalized ||
78
+ f.label.toLowerCase().replace(/[-_]/g, '') === normalized
79
+ );
80
+ initialMapping[h] = match ? match.key : '';
81
+ }
82
+ columnMapping = initialMapping;
83
+ currentStep = 2;
84
+ }
85
+
86
+ function handleDrop(e: DragEvent) {
87
+ e.preventDefault();
88
+ if (e.dataTransfer?.files?.[0]) {
89
+ handleFileSelect(e.dataTransfer.files[0]);
90
+ }
91
+ }
92
+
93
+ async function startImport() {
94
+ currentStep = 3;
95
+ isImporting = true;
96
+ processedCount = 0;
97
+ succeededCount = 0;
98
+ failedRecords = [];
99
+
100
+ const provider = adminContext.getDataProviderForResource(resourceName);
101
+
102
+ // Build mapped records
103
+ const recordsToImport: Record<string, unknown>[] = [];
104
+ for (const row of rawRows) {
105
+ const record: Record<string, unknown> = {};
106
+ rawHeaders.forEach((header, idx) => {
107
+ const targetFieldKey = columnMapping[header];
108
+ if (targetFieldKey) {
109
+ const fieldDef = availableFields.find((f) => f.key === targetFieldKey);
110
+ let val: unknown = row[idx] ?? '';
111
+ if (fieldDef?.type === 'number') {
112
+ const num = Number(val);
113
+ val = isNaN(num) ? val : num;
114
+ } else if (fieldDef?.type === 'boolean') {
115
+ val = val === 'true' || val === '1' || val === 'yes' || val === '是';
116
+ }
117
+ record[targetFieldKey] = val;
118
+ }
119
+ });
120
+ recordsToImport.push(record);
121
+ }
122
+
123
+ // Execute in batches
124
+ for (let i = 0; i < recordsToImport.length; i += batchSize) {
125
+ const batch = recordsToImport.slice(i, i + batchSize);
126
+ if (provider.createMany) {
127
+ try {
128
+ await provider.createMany({ resource: resourceName, variables: batch });
129
+ succeededCount += batch.length;
130
+ } catch (err) {
131
+ batch.forEach((item) => {
132
+ failedRecords.push({ row: item, error: err instanceof Error ? err.message : String(err) });
133
+ });
134
+ }
135
+ } else {
136
+ for (const item of batch) {
137
+ try {
138
+ await provider.create({ resource: resourceName, variables: item });
139
+ succeededCount++;
140
+ } catch (err) {
141
+ failedRecords.push({ row: item, error: err instanceof Error ? err.message : String(err) });
142
+ }
143
+ }
144
+ }
145
+ processedCount = Math.min(recordsToImport.length, i + batch.length);
146
+ }
147
+
148
+ isImporting = false;
149
+ onSuccess?.({ succeeded: succeededCount, failed: failedRecords.length });
150
+ }
151
+
152
+ function downloadErrors() {
153
+ if (failedRecords.length === 0 || typeof document === 'undefined') return;
154
+ const errorRows = failedRecords.map((f) => ({
155
+ ...f.row,
156
+ _error_reason: f.error,
157
+ }));
158
+ const csvContent = toCsv(errorRows);
159
+ const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
160
+ const url = URL.createObjectURL(blob);
161
+ const link = document.createElement('a');
162
+ link.href = url;
163
+ link.download = `${resourceName}-import-errors.csv`;
164
+ link.click();
165
+ URL.revokeObjectURL(url);
166
+ }
167
+
168
+ function reset() {
169
+ currentStep = 1;
170
+ fileName = '';
171
+ rawHeaders = [];
172
+ rawRows = [];
173
+ columnMapping = {};
174
+ processedCount = 0;
175
+ succeededCount = 0;
176
+ failedRecords = [];
177
+ isImporting = false;
178
+ }
179
+ </script>
180
+
181
+ {#if open}
182
+ <Dialog.Dialog bind:open>
183
+ <Dialog.DialogContent class="sm:max-w-2xl max-h-[85vh] overflow-y-auto">
184
+ <Dialog.DialogHeader>
185
+ <Dialog.DialogTitle class="flex items-center gap-2">
186
+ <FileSpreadsheet class="h-5 w-5 text-primary" />
187
+ <span>{i18n.t('common.import', { defaultValue: 'Import' })} {resource.label}</span>
188
+ </Dialog.DialogTitle>
189
+ </Dialog.DialogHeader>
190
+
191
+ <!-- Step Indicator -->
192
+ <div class="flex items-center justify-between text-xs font-medium text-muted-foreground pb-2 border-b border-border/50">
193
+ <span class={currentStep === 1 ? 'text-primary font-semibold' : ''}>1. {i18n.t('common.uploadFile', { defaultValue: 'Upload File' })}</span>
194
+ <span>→</span>
195
+ <span class={currentStep === 2 ? 'text-primary font-semibold' : ''}>2. {i18n.t('common.mapColumns', { defaultValue: 'Map Columns' })}</span>
196
+ <span>→</span>
197
+ <span class={currentStep === 3 ? 'text-primary font-semibold' : ''}>3. {i18n.t('common.importProgress', { defaultValue: 'Import & Verify' })}</span>
198
+ </div>
199
+
200
+ <!-- Step 1: Upload -->
201
+ {#if currentStep === 1}
202
+ <div
203
+ class="border-2 border-dashed border-border/80 hover:border-primary/60 rounded-xl p-8 text-center transition-colors cursor-pointer bg-muted/20"
204
+ ondragover={(e) => e.preventDefault()}
205
+ ondrop={handleDrop}
206
+ role="region"
207
+ aria-label="Upload Dropzone"
208
+ >
209
+ <Upload class="h-10 w-10 mx-auto text-muted-foreground/60 mb-3" />
210
+ <p class="text-sm font-medium text-foreground mb-1">
211
+ {i18n.t('common.dropFileHere', { defaultValue: 'Drag and drop CSV or JSON file here' })}
212
+ </p>
213
+ <p class="text-xs text-muted-foreground mb-4">
214
+ {i18n.t('common.fileFormatHint', { defaultValue: 'Supports .csv, .json with UTF-8 encoding' })}
215
+ </p>
216
+ <label class="inline-flex">
217
+ <input
218
+ type="file"
219
+ accept=".csv,.json"
220
+ class="hidden"
221
+ onchange={(e) => {
222
+ const target = e.currentTarget;
223
+ if (target.files?.[0]) handleFileSelect(target.files[0]);
224
+ }}
225
+ />
226
+ <span class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-xs font-medium text-primary-foreground shadow hover:bg-primary/90 cursor-pointer">
227
+ {i18n.t('common.selectFile', { defaultValue: 'Browse File' })}
228
+ </span>
229
+ </label>
230
+ </div>
231
+ {/if}
232
+
233
+ <!-- Step 2: Column Mapping -->
234
+ {#if currentStep === 2}
235
+ <div class="space-y-4">
236
+ <div class="flex items-center justify-between text-xs text-muted-foreground bg-muted/30 p-2.5 rounded-lg border border-border/60">
237
+ <span>{i18n.t('common.detectedRows', { defaultValue: 'Detected' })}: <strong class="text-foreground">{rawRows.length}</strong> {i18n.t('common.records', { defaultValue: 'records' })}</span>
238
+ <Badge variant="secondary" class="text-[11px] font-mono">{fileName}</Badge>
239
+ </div>
240
+
241
+ <div class="max-h-72 overflow-y-auto space-y-2 pr-1 border border-border/40 rounded-lg p-2">
242
+ {#each rawHeaders as header (header)}
243
+ <div class="flex items-center justify-between gap-3 p-2 rounded-md bg-card border border-border/40 text-xs">
244
+ <div class="flex-1 truncate">
245
+ <span class="font-medium text-foreground">{header}</span>
246
+ {#if rawRows[0]}
247
+ <span class="block text-[11px] text-muted-foreground truncate opacity-70">
248
+ Sample: {rawRows[0][rawHeaders.indexOf(header)] ?? '—'}
249
+ </span>
250
+ {/if}
251
+ </div>
252
+ <div class="w-48 shrink-0">
253
+ <select
254
+ class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
255
+ bind:value={columnMapping[header]}
256
+ >
257
+ <option value="">— {i18n.t('common.ignoreColumn', { defaultValue: 'Ignore' })} —</option>
258
+ {#each availableFields as field (field.key)}
259
+ <option value={field.key}>
260
+ {field.label} ({field.key}) {field.required ? '*' : ''}
261
+ </option>
262
+ {/each}
263
+ </select>
264
+ </div>
265
+ </div>
266
+ {/each}
267
+ </div>
268
+
269
+ <div class="flex items-center justify-between pt-2">
270
+ <Button variant="outline" size="sm" onclick={() => { currentStep = 1; }}>
271
+ <ArrowLeft class="h-3.5 w-3.5 mr-1" />
272
+ {i18n.t('common.back', { defaultValue: 'Back' })}
273
+ </Button>
274
+ <Button size="sm" onclick={startImport}>
275
+ {i18n.t('common.startImport', { defaultValue: 'Start Import' })}
276
+ <ArrowRight class="h-3.5 w-3.5 ml-1" />
277
+ </Button>
278
+ </div>
279
+ </div>
280
+ {/if}
281
+
282
+ <!-- Step 3: Progress & Summary -->
283
+ {#if currentStep === 3}
284
+ <div class="space-y-5 py-2">
285
+ <div class="space-y-2">
286
+ <div class="flex items-center justify-between text-xs">
287
+ <span class="text-muted-foreground">{isImporting ? i18n.t('common.importing', { defaultValue: 'Importing...' }) : i18n.t('common.completed', { defaultValue: 'Completed' })}</span>
288
+ <span class="font-semibold text-foreground tabular-nums">{progressPercent}%</span>
289
+ </div>
290
+ <Progress value={progressPercent} class="h-2" />
291
+ </div>
292
+
293
+ <div class="grid grid-cols-2 gap-3 text-center">
294
+ <div class="p-3 rounded-lg bg-success/10 border border-success/20">
295
+ <span class="text-xs text-muted-foreground block">{i18n.t('common.succeeded', { defaultValue: 'Succeeded' })}</span>
296
+ <span class="text-xl font-bold text-success tabular-nums">{succeededCount}</span>
297
+ </div>
298
+ <div class="p-3 rounded-lg bg-destructive/10 border border-destructive/20">
299
+ <span class="text-xs text-muted-foreground block">{i18n.t('common.failed', { defaultValue: 'Failed' })}</span>
300
+ <span class="text-xl font-bold text-destructive tabular-nums">{failedRecords.length}</span>
301
+ </div>
302
+ </div>
303
+
304
+ {#if failedRecords.length > 0}
305
+ <div class="flex items-center justify-between p-3 rounded-lg bg-destructive/5 border border-destructive/15 text-xs">
306
+ <div class="flex items-center gap-2 text-destructive">
307
+ <AlertCircle class="h-4 w-4" />
308
+ <span>{failedRecords.length} {i18n.t('common.failedRowsHint', { defaultValue: 'records failed to import.' })}</span>
309
+ </div>
310
+ <Button variant="outline" size="sm" class="h-7 text-xs gap-1" onclick={downloadErrors}>
311
+ <Download class="h-3.5 w-3.5" />
312
+ {i18n.t('common.downloadErrors', { defaultValue: 'Download Failed CSV' })}
313
+ </Button>
314
+ </div>
315
+ {/if}
316
+
317
+ <div class="flex items-center justify-end gap-2 pt-3 border-t border-border/50">
318
+ {#if !isImporting}
319
+ <Button variant="outline" size="sm" onclick={reset}>
320
+ {i18n.t('common.importAnother', { defaultValue: 'Import Another' })}
321
+ </Button>
322
+ <Button size="sm" onclick={() => { open = false; }}>
323
+ {i18n.t('common.close', { defaultValue: 'Close' })}
324
+ </Button>
325
+ {:else}
326
+ <Button size="sm" disabled class="gap-2">
327
+ <Loader2 class="h-3.5 w-3.5 animate-spin" />
328
+ {i18n.t('common.processing', { defaultValue: 'Processing...' })}
329
+ </Button>
330
+ {/if}
331
+ </div>
332
+ </div>
333
+ {/if}
334
+ </Dialog.DialogContent>
335
+ </Dialog.Dialog>
336
+ {/if}
@@ -0,0 +1,12 @@
1
+ interface Props {
2
+ resourceName: string;
3
+ open?: boolean;
4
+ batchSize?: number;
5
+ onSuccess?: (result: {
6
+ succeeded: number;
7
+ failed: number;
8
+ }) => void;
9
+ }
10
+ declare const ImportWizard: import("svelte").Component<Props, {}, "open">;
11
+ type ImportWizard = ReturnType<typeof ImportWizard>;
12
+ export default ImportWizard;
@@ -0,0 +1,149 @@
1
+ <script lang="ts">
2
+ import { Button } from './ui/button/index.js';
3
+ import { Loader2 } from '@lucide/svelte';
4
+ import { cn } from '../utils.js';
5
+
6
+ interface Props {
7
+ schema: Record<string, unknown>;
8
+ value?: Record<string, unknown>;
9
+ onsubmit?: (data: Record<string, unknown>) => void | Promise<void>;
10
+ submitText?: string;
11
+ class?: string;
12
+ }
13
+
14
+ let {
15
+ schema,
16
+ value = $bindable({}),
17
+ onsubmit,
18
+ submitText = 'Submit Form',
19
+ class: className = '',
20
+ }: Props = $props();
21
+
22
+ let isSubmitting = $state(false);
23
+
24
+ interface FieldMeta {
25
+ key: string;
26
+ title: string;
27
+ description?: string;
28
+ type: string;
29
+ enum?: Array<string | number>;
30
+ required?: boolean;
31
+ default?: unknown;
32
+ }
33
+
34
+ const properties = $derived.by<FieldMeta[]>(() => {
35
+ const propsObj = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
36
+ const requiredKeys = Array.isArray(schema.required) ? (schema.required as string[]) : [];
37
+
38
+ return Object.entries(propsObj).map(([key, propDef]) => ({
39
+ key,
40
+ title: String(propDef.title ?? key),
41
+ description: propDef.description ? String(propDef.description) : undefined,
42
+ type: String(propDef.type ?? 'string'),
43
+ enum: Array.isArray(propDef.enum) ? (propDef.enum as Array<string | number>) : undefined,
44
+ required: requiredKeys.includes(key),
45
+ default: propDef.default,
46
+ }));
47
+ });
48
+
49
+ async function handleSubmit(e: SubmitEvent) {
50
+ e.preventDefault();
51
+ isSubmitting = true;
52
+ try {
53
+ await onsubmit?.(value);
54
+ } finally {
55
+ isSubmitting = false;
56
+ }
57
+ }
58
+
59
+ function updateValue(key: string, val: unknown) {
60
+ value = {
61
+ ...value,
62
+ [key]: val,
63
+ };
64
+ }
65
+ </script>
66
+
67
+ <form
68
+ onsubmit={handleSubmit}
69
+ class={cn('space-y-4 rounded-xl border border-border bg-card p-6 shadow-xs text-xs', className)}
70
+ >
71
+ {#if schema.title}
72
+ <div class="pb-2 border-b border-border/60">
73
+ <h3 class="text-sm font-semibold text-foreground">{schema.title}</h3>
74
+ {#if schema.description}
75
+ <p class="text-xs text-muted-foreground mt-0.5">{schema.description}</p>
76
+ {/if}
77
+ </div>
78
+ {/if}
79
+
80
+ <div class="space-y-3.5">
81
+ {#each properties as field (field.key)}
82
+ <div class="space-y-1">
83
+ <label for="json_field_{field.key}" class="block font-medium text-foreground">
84
+ {field.title}
85
+ {#if field.required}
86
+ <span class="text-destructive">*</span>
87
+ {/if}
88
+ </label>
89
+
90
+ {#if field.description}
91
+ <p class="text-[11px] text-muted-foreground">{field.description}</p>
92
+ {/if}
93
+
94
+ {#if field.enum}
95
+ <select
96
+ id="json_field_{field.key}"
97
+ required={field.required}
98
+ value={String(value[field.key] ?? field.default ?? '')}
99
+ onchange={(e) => updateValue(field.key, e.currentTarget.value)}
100
+ class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
101
+ >
102
+ <option value="">Select an option...</option>
103
+ {#each field.enum as option (option)}
104
+ <option value={String(option)}>{option}</option>
105
+ {/each}
106
+ </select>
107
+ {:else if field.type === 'boolean'}
108
+ <div class="flex items-center gap-2 pt-1">
109
+ <input
110
+ id="json_field_{field.key}"
111
+ type="checkbox"
112
+ checked={Boolean(value[field.key] ?? field.default)}
113
+ onchange={(e) => updateValue(field.key, e.currentTarget.checked)}
114
+ class="h-4 w-4 rounded border-border text-primary focus:ring-primary/20 cursor-pointer"
115
+ />
116
+ <span class="text-xs text-muted-foreground">{field.title}</span>
117
+ </div>
118
+ {:else if field.type === 'number' || field.type === 'integer'}
119
+ <input
120
+ id="json_field_{field.key}"
121
+ type="number"
122
+ required={field.required}
123
+ value={Number(value[field.key] ?? field.default ?? 0)}
124
+ oninput={(e) => updateValue(field.key, Number(e.currentTarget.value))}
125
+ class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
126
+ />
127
+ {:else}
128
+ <input
129
+ id="json_field_{field.key}"
130
+ type="text"
131
+ required={field.required}
132
+ value={String(value[field.key] ?? field.default ?? '')}
133
+ oninput={(e) => updateValue(field.key, e.currentTarget.value)}
134
+ class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
135
+ />
136
+ {/if}
137
+ </div>
138
+ {/each}
139
+ </div>
140
+
141
+ <div class="pt-4 border-t border-border/60 flex justify-end">
142
+ <Button type="submit" size="sm" disabled={isSubmitting} class="gap-1 min-w-24">
143
+ {#if isSubmitting}
144
+ <Loader2 class="h-3.5 w-3.5 animate-spin" />
145
+ {/if}
146
+ {submitText}
147
+ </Button>
148
+ </div>
149
+ </form>
@@ -0,0 +1,10 @@
1
+ interface Props {
2
+ schema: Record<string, unknown>;
3
+ value?: Record<string, unknown>;
4
+ onsubmit?: (data: Record<string, unknown>) => void | Promise<void>;
5
+ submitText?: string;
6
+ class?: string;
7
+ }
8
+ declare const JsonSchemaForm: import("svelte").Component<Props, {}, "value">;
9
+ type JsonSchemaForm = ReturnType<typeof JsonSchemaForm>;
10
+ export default JsonSchemaForm;