@quoin-cms/admin 0.1.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/LICENSE +661 -0
  2. package/biome.json +62 -0
  3. package/dist/assets/index-C9Y5-AKj.js +33 -0
  4. package/dist/assets/index-uVdiUjty.css +1 -0
  5. package/dist/index.html +20 -0
  6. package/index.html +19 -0
  7. package/package.json +43 -0
  8. package/src/AdminRoot.svelte +98 -0
  9. package/src/app.css +211 -0
  10. package/src/lib/Slot.svelte +65 -0
  11. package/src/lib/api/auth.ts +26 -0
  12. package/src/lib/api/client.ts +73 -0
  13. package/src/lib/api/files.ts +56 -0
  14. package/src/lib/api/globals.ts +13 -0
  15. package/src/lib/api/records.ts +102 -0
  16. package/src/lib/api/schema.ts +7 -0
  17. package/src/lib/api/versions.ts +40 -0
  18. package/src/lib/components/AdminHeader.svelte +107 -0
  19. package/src/lib/components/AdminSidebar.svelte +262 -0
  20. package/src/lib/components/DeleteDialog.svelte +58 -0
  21. package/src/lib/components/DocumentEditLayout.svelte +263 -0
  22. package/src/lib/components/DynamicForm.svelte +74 -0
  23. package/src/lib/components/KpiCard.svelte +75 -0
  24. package/src/lib/components/MediaLibrary.svelte +311 -0
  25. package/src/lib/components/Pagination.svelte +78 -0
  26. package/src/lib/components/RangeFilter.svelte +41 -0
  27. package/src/lib/components/RecordGrid.svelte +123 -0
  28. package/src/lib/components/RecordTable.svelte +156 -0
  29. package/src/lib/components/cells/CheckboxCell.svelte +10 -0
  30. package/src/lib/components/cells/ColorCell.svelte +15 -0
  31. package/src/lib/components/cells/DateCell.svelte +8 -0
  32. package/src/lib/components/cells/RelationshipCell.svelte +20 -0
  33. package/src/lib/components/cells/RichTextCell.svelte +21 -0
  34. package/src/lib/components/cells/SelectCell.svelte +26 -0
  35. package/src/lib/components/cells/TextCell.svelte +8 -0
  36. package/src/lib/components/cells/UploadCell.svelte +34 -0
  37. package/src/lib/components/cells/index.ts +28 -0
  38. package/src/lib/components/charts/TimeSeriesChart.svelte +184 -0
  39. package/src/lib/components/doc/ApiView.svelte +181 -0
  40. package/src/lib/components/doc/Autosave.svelte +102 -0
  41. package/src/lib/components/doc/DocHeader.svelte +86 -0
  42. package/src/lib/components/doc/DocMetaStrip.svelte +103 -0
  43. package/src/lib/components/doc/DocTabBar.svelte +26 -0
  44. package/src/lib/components/doc/HeaderModeSwitch.svelte +32 -0
  45. package/src/lib/components/doc/PublishButton.svelte +114 -0
  46. package/src/lib/components/doc/ScheduleModal.svelte +110 -0
  47. package/src/lib/components/doc/VersionHistory.svelte +20 -0
  48. package/src/lib/components/fields/ArrayFieldEditor.svelte +62 -0
  49. package/src/lib/components/fields/BlockCard.svelte +63 -0
  50. package/src/lib/components/fields/BlocksFieldEditor.svelte +83 -0
  51. package/src/lib/components/fields/CheckboxField.svelte +27 -0
  52. package/src/lib/components/fields/ColorField.svelte +46 -0
  53. package/src/lib/components/fields/DateField.svelte +52 -0
  54. package/src/lib/components/fields/EmailField.svelte +30 -0
  55. package/src/lib/components/fields/FileField.svelte +280 -0
  56. package/src/lib/components/fields/JsonField.svelte +145 -0
  57. package/src/lib/components/fields/NumberField.svelte +44 -0
  58. package/src/lib/components/fields/PasswordField.svelte +38 -0
  59. package/src/lib/components/fields/RelationshipField.svelte +271 -0
  60. package/src/lib/components/fields/RichTextField.svelte +139 -0
  61. package/src/lib/components/fields/SelectField.svelte +33 -0
  62. package/src/lib/components/fields/SlugField.svelte +70 -0
  63. package/src/lib/components/fields/TabsField.svelte +56 -0
  64. package/src/lib/components/fields/TagsField.svelte +85 -0
  65. package/src/lib/components/fields/TextField.svelte +36 -0
  66. package/src/lib/components/fields/TextareaField.svelte +32 -0
  67. package/src/lib/components/fields/UploadField.svelte +166 -0
  68. package/src/lib/components/fields/UploadFieldDispatch.svelte +21 -0
  69. package/src/lib/components/fields/UploadGalleryField.svelte +166 -0
  70. package/src/lib/components/fields/index.ts +22 -0
  71. package/src/lib/components/fields/registry.ts +58 -0
  72. package/src/lib/components/lexical/CustomHTMLComponent.svelte +52 -0
  73. package/src/lib/components/lexical/CustomHTMLNode.ts +94 -0
  74. package/src/lib/components/lexical/PullQuoteComponent.svelte +73 -0
  75. package/src/lib/components/lexical/PullQuoteNode.ts +112 -0
  76. package/src/lib/components/lexical/lexical-helpers.ts +24 -0
  77. package/src/lib/components/lexical/nodes.ts +8 -0
  78. package/src/lib/components/lexical/toolbar/EditorToolbar.svelte +159 -0
  79. package/src/lib/components/lexical/toolbar/InsertBlockDropdown.svelte +278 -0
  80. package/src/lib/components/versions/CompareSelector.svelte +31 -0
  81. package/src/lib/components/versions/FieldDiff.svelte +141 -0
  82. package/src/lib/components/versions/RestoreModal.svelte +67 -0
  83. package/src/lib/components/versions/StatusPill.svelte +21 -0
  84. package/src/lib/context.svelte.ts +156 -0
  85. package/src/lib/router/index.svelte.ts +282 -0
  86. package/src/lib/router/matcher.ts +52 -0
  87. package/src/lib/stores/branding.svelte.ts +74 -0
  88. package/src/lib/stores/schema.svelte.ts +17 -0
  89. package/src/lib/types/schema.ts +126 -0
  90. package/src/lib/utils/cn.ts +6 -0
  91. package/src/lib/utils/diff.ts +112 -0
  92. package/src/lib/utils/dirty.svelte.ts +50 -0
  93. package/src/lib/utils/format.ts +28 -0
  94. package/src/lib/utils/json-highlight.ts +34 -0
  95. package/src/lib/utils/slug.ts +8 -0
  96. package/src/main.ts +32 -0
  97. package/src/views/AdminLayout.svelte +73 -0
  98. package/src/views/AdsAnalyticsView.svelte +152 -0
  99. package/src/views/CollectionEditView.svelte +117 -0
  100. package/src/views/CollectionListView.svelte +347 -0
  101. package/src/views/CollectionNewView.svelte +68 -0
  102. package/src/views/CustomPageView.svelte +59 -0
  103. package/src/views/DashboardView.svelte +370 -0
  104. package/src/views/GlobalEditView.svelte +100 -0
  105. package/src/views/LoginView.svelte +231 -0
  106. package/src/views/NotFoundView.svelte +9 -0
  107. package/src/views/VersionDetailView.svelte +307 -0
  108. package/src/views/VersionsListView.svelte +201 -0
  109. package/tsconfig.json +25 -0
  110. package/vite.config.ts +80 -0
@@ -0,0 +1,280 @@
1
+ <script lang="ts">
2
+ import type { FieldSchema } from '$lib/types/schema.js'
3
+ import { uploadFile, type UploadResult } from '$lib/api/files.js'
4
+ import { formatFileSize } from '$lib/utils/format.js'
5
+ import { toast } from 'svelte-sonner'
6
+ import { X, Upload, Eye, FolderOpen } from 'lucide-svelte'
7
+ import MediaLibrary from '../MediaLibrary.svelte'
8
+ import { createRecord } from '$lib/api/records.js'
9
+
10
+ interface FileValue {
11
+ url: string
12
+ alt: string
13
+ filename: string
14
+ mimeType: string
15
+ size: number
16
+ }
17
+
18
+ let {
19
+ field,
20
+ value = $bindable(),
21
+ error,
22
+ }: {
23
+ field: FieldSchema
24
+ value?: any
25
+ error?: string
26
+ } = $props()
27
+
28
+ let isUploading = $state(false)
29
+ let fileInput: HTMLInputElement
30
+ let previewUrl = $state<string | null>(null)
31
+ let mediaLibraryOpen = $state(false)
32
+
33
+ // Parse value into FileValue items for display
34
+ let items = $derived.by((): FileValue[] => {
35
+ if (!value) return []
36
+ // Handle JSON string from DB
37
+ let parsed = value
38
+ if (typeof parsed === 'string') {
39
+ try {
40
+ parsed = JSON.parse(parsed)
41
+ } catch {
42
+ return []
43
+ }
44
+ }
45
+ if (field.multiple) {
46
+ if (Array.isArray(parsed)) return parsed
47
+ return []
48
+ }
49
+ // Single file
50
+ if (typeof parsed === 'object' && parsed?.url) return [parsed]
51
+ return []
52
+ })
53
+
54
+ function isImage(mimeType: string): boolean {
55
+ return mimeType?.startsWith('image/') ?? false
56
+ }
57
+
58
+ async function handleUpload(e: Event) {
59
+ const target = e.target as HTMLInputElement
60
+ const files = target.files
61
+ if (!files?.length) return
62
+
63
+ isUploading = true
64
+ for (const file of files) {
65
+ const result = await uploadFile(file)
66
+ if (result.ok) {
67
+ const fv: FileValue = {
68
+ url: result.data.url,
69
+ alt: '',
70
+ filename: result.data.filename,
71
+ mimeType: result.data.mimeType,
72
+ size: result.data.size,
73
+ }
74
+ if (field.multiple) {
75
+ const current = Array.isArray(value) ? [...value] : []
76
+ value = [...current, fv]
77
+ } else {
78
+ value = fv
79
+ }
80
+ toast.success(`Uploaded ${file.name}`)
81
+ // Create media record for the centralized library (fire-and-forget)
82
+ try {
83
+ await createRecord('media', {
84
+ title: file.name,
85
+ alt: '',
86
+ filename: result.data.filename,
87
+ mimeType: result.data.mimeType,
88
+ size: result.data.size,
89
+ file: {
90
+ url: result.data.url,
91
+ filename: result.data.filename,
92
+ mimeType: result.data.mimeType,
93
+ size: result.data.size,
94
+ },
95
+ })
96
+ } catch {
97
+ // Silently ignore -- upload itself succeeded
98
+ }
99
+ } else {
100
+ toast.error(`Failed to upload ${file.name}: ${result.error}`)
101
+ }
102
+ }
103
+ isUploading = false
104
+ target.value = ''
105
+ }
106
+
107
+ function removeItem(index: number) {
108
+ if (field.multiple && Array.isArray(value)) {
109
+ value = value.filter((_: any, i: number) => i !== index)
110
+ } else {
111
+ value = null
112
+ }
113
+ }
114
+
115
+ function updateAlt(index: number, alt: string) {
116
+ if (field.multiple && Array.isArray(value)) {
117
+ value = value.map((v: FileValue, i: number) => (i === index ? { ...v, alt } : v))
118
+ } else if (value) {
119
+ value = { ...value, alt }
120
+ }
121
+ }
122
+
123
+ function openPreview(url: string) {
124
+ previewUrl = url
125
+ }
126
+
127
+ function closePreview() {
128
+ previewUrl = null
129
+ }
130
+
131
+ function handleMediaSelect(media: { url: string; alt: string; filename: string; mimeType: string; size: number }) {
132
+ const fv: FileValue = {
133
+ url: media.url,
134
+ alt: media.alt || '',
135
+ filename: media.filename,
136
+ mimeType: media.mimeType,
137
+ size: media.size,
138
+ }
139
+ if (field.multiple) {
140
+ const current = Array.isArray(value) ? [...value] : []
141
+ value = [...current, fv]
142
+ } else {
143
+ value = fv
144
+ }
145
+ }
146
+ </script>
147
+
148
+ <div>
149
+ <!-- svelte-ignore a11y_label_has_associated_control -->
150
+ <label class="mb-1.5 block text-sm font-medium">
151
+ {field.label}
152
+ {#if field.required}<span class="text-destructive">*</span>{/if}
153
+ </label>
154
+
155
+ <!-- Display uploaded files -->
156
+ {#if items.length > 0}
157
+ <div class="mb-2 space-y-2">
158
+ {#each items as item, index}
159
+ <div class="flex items-start gap-3 rounded-md border bg-muted/30 p-2">
160
+ {#if isImage(item.mimeType) && item.url}
161
+ <div class="relative">
162
+ <button
163
+ type="button"
164
+ onclick={() => openPreview(item.url)}
165
+ class="group relative h-16 w-16 shrink-0 overflow-hidden rounded"
166
+ >
167
+ <img src={item.url} alt={item.alt || item.filename} class="h-full w-full object-cover" />
168
+ <div class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
169
+ <Eye class="h-5 w-5 text-white" />
170
+ </div>
171
+ </button>
172
+ </div>
173
+ {:else}
174
+ <div class="flex h-16 w-16 shrink-0 items-center justify-center rounded bg-muted text-lg">
175
+ 📄
176
+ </div>
177
+ {/if}
178
+ <div class="flex-1 space-y-1">
179
+ <p class="text-xs font-medium">{item.filename}</p>
180
+ <p class="text-xs text-muted-foreground">{formatFileSize(item.size)}</p>
181
+ <div>
182
+ <label for="alt-{index}" class="mb-0.5 block text-xs font-medium text-muted-foreground">
183
+ Alt text
184
+ </label>
185
+ <input
186
+ id="alt-{index}"
187
+ type="text"
188
+ value={item.alt}
189
+ oninput={(e) => updateAlt(index, (e.target as HTMLInputElement).value)}
190
+ placeholder="Describe the image"
191
+ class="h-7 w-full rounded border bg-background px-2 text-xs"
192
+ />
193
+ </div>
194
+ </div>
195
+ <button
196
+ type="button"
197
+ onclick={() => removeItem(index)}
198
+ class="shrink-0 rounded p-1 text-muted-foreground hover:text-destructive"
199
+ >
200
+ <X class="h-4 w-4" />
201
+ </button>
202
+ </div>
203
+ {/each}
204
+ </div>
205
+ {/if}
206
+
207
+ <button
208
+ type="button"
209
+ onclick={() => fileInput.click()}
210
+ disabled={isUploading}
211
+ class="inline-flex h-10 items-center gap-2 rounded-md border bg-background px-4 text-sm hover:bg-accent disabled:opacity-50 {error ? 'border-destructive' : ''}"
212
+ >
213
+ <Upload class="h-4 w-4" />
214
+ {#if isUploading}
215
+ Uploading...
216
+ {:else}
217
+ {items.length > 0 ? 'Add' : 'Upload'} File{field.multiple ? 's' : ''}
218
+ {/if}
219
+ </button>
220
+
221
+ <button
222
+ type="button"
223
+ onclick={() => (mediaLibraryOpen = true)}
224
+ class="inline-flex h-10 items-center gap-2 rounded-md border bg-background px-4 text-sm hover:bg-accent"
225
+ >
226
+ <FolderOpen class="h-4 w-4" />
227
+ Browse Media
228
+ </button>
229
+
230
+ <input
231
+ bind:this={fileInput}
232
+ type="file"
233
+ class="hidden"
234
+ onchange={handleUpload}
235
+ multiple={field.multiple || false}
236
+ accept={field.accept?.join(',')}
237
+ />
238
+
239
+ {#if error}
240
+ <p class="mt-1 text-xs text-destructive">{error}</p>
241
+ {/if}
242
+ </div>
243
+
244
+ <MediaLibrary
245
+ bind:open={mediaLibraryOpen}
246
+ accept={field.accept || ['image/*']}
247
+ onSelect={handleMediaSelect}
248
+ />
249
+
250
+ <!-- Image Preview Modal -->
251
+ {#if previewUrl}
252
+ <div
253
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
254
+ onclick={closePreview}
255
+ role="button"
256
+ tabindex="0"
257
+ onkeydown={(e) => e.key === 'Escape' && closePreview()}
258
+ >
259
+ <div class="relative max-h-[90vh] max-w-[90vw]">
260
+ <button
261
+ type="button"
262
+ onclick={closePreview}
263
+ class="absolute -right-2 -top-2 rounded-full bg-white p-2 text-black shadow-lg hover:bg-gray-100"
264
+ >
265
+ <X class="h-5 w-5" />
266
+ </button>
267
+ <button
268
+ type="button"
269
+ class="appearance-none border-0 bg-transparent p-0 cursor-pointer"
270
+ onclick={(e) => e.stopPropagation()}
271
+ >
272
+ <img
273
+ src={previewUrl}
274
+ alt="Preview"
275
+ class="max-h-[90vh] max-w-[90vw] rounded object-contain"
276
+ />
277
+ </button>
278
+ </div>
279
+ </div>
280
+ {/if}
@@ -0,0 +1,145 @@
1
+ <script lang="ts">
2
+ import type { FieldSchema } from '$lib/types/schema.js'
3
+ import { Plus, X } from 'lucide-svelte'
4
+
5
+ let {
6
+ field,
7
+ value = $bindable(),
8
+ error,
9
+ }: {
10
+ field: FieldSchema
11
+ value?: string
12
+ error?: string
13
+ } = $props()
14
+
15
+ type KVPair = { key: string; value: string }
16
+
17
+ // Parse value (string or object) into key-value pairs
18
+ function parseToEntries(val: any): KVPair[] {
19
+ let obj: Record<string, any> = {}
20
+ if (typeof val === 'string') {
21
+ try {
22
+ obj = JSON.parse(val)
23
+ } catch {
24
+ return []
25
+ }
26
+ } else if (val && typeof val === 'object' && !Array.isArray(val)) {
27
+ obj = val
28
+ }
29
+ const entries = Object.entries(obj)
30
+ return entries.length > 0 ? entries.map(([k, v]) => ({ key: k, value: String(v) })) : []
31
+ }
32
+
33
+ let entries = $state<KVPair[]>(parseToEntries(value))
34
+
35
+ // Sync entries back to the bound value as a JSON string
36
+ function syncValue() {
37
+ const obj: Record<string, string> = {}
38
+ for (const e of entries) {
39
+ const k = e.key.trim()
40
+ if (k) obj[k] = e.value
41
+ }
42
+ value = JSON.stringify(obj)
43
+ }
44
+
45
+ // Re-parse when value changes externally (e.g. on initial load)
46
+ let lastSyncedValue = $state(value)
47
+ $effect(() => {
48
+ if (value !== lastSyncedValue) {
49
+ const parsed = parseToEntries(value)
50
+ // Only update entries if the parsed result is different
51
+ if (
52
+ JSON.stringify(parsed) !==
53
+ JSON.stringify(entries.map((e) => ({ key: e.key, value: e.value })))
54
+ ) {
55
+ entries = parsed
56
+ }
57
+ lastSyncedValue = value
58
+ }
59
+ })
60
+
61
+ function addEntry() {
62
+ entries.push({ key: '', value: '' })
63
+ }
64
+
65
+ function removeEntry(index: number) {
66
+ entries.splice(index, 1)
67
+ syncValue()
68
+ }
69
+
70
+ function handleKeyInput(index: number, e: Event) {
71
+ entries[index].key = (e.target as HTMLInputElement).value
72
+ syncValue()
73
+ }
74
+
75
+ function handleValueInput(index: number, e: Event) {
76
+ entries[index].value = (e.target as HTMLInputElement).value
77
+ syncValue()
78
+ }
79
+
80
+ function handleKeydown(e: KeyboardEvent) {
81
+ if (e.key === 'Enter') {
82
+ e.preventDefault()
83
+ addEntry()
84
+ }
85
+ }
86
+ </script>
87
+
88
+ <div>
89
+ <label for={field.name} class="mb-1.5 block text-sm font-medium">
90
+ {field.label}
91
+ {#if field.required}<span class="text-destructive">*</span>{/if}
92
+ </label>
93
+
94
+ <div class="space-y-2">
95
+ {#if entries.length > 0}
96
+ <div class="grid grid-cols-[1fr_1fr_auto] gap-2 text-xs text-muted-foreground">
97
+ <span>Key</span>
98
+ <span>Value</span>
99
+ <span class="w-8"></span>
100
+ </div>
101
+ {/if}
102
+
103
+ {#each entries as entry, i}
104
+ <div class="grid grid-cols-[1fr_1fr_auto] gap-2">
105
+ <input
106
+ type="text"
107
+ value={entry.key}
108
+ oninput={(e) => handleKeyInput(i, e)}
109
+ onkeydown={handleKeydown}
110
+ class="h-9 rounded-md border bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring {error ? 'border-destructive' : ''}"
111
+ placeholder="key"
112
+ />
113
+ <input
114
+ type="text"
115
+ value={entry.value}
116
+ oninput={(e) => handleValueInput(i, e)}
117
+ onkeydown={handleKeydown}
118
+ class="h-9 rounded-md border bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring {error ? 'border-destructive' : ''}"
119
+ placeholder="value"
120
+ />
121
+ <button
122
+ type="button"
123
+ onclick={() => removeEntry(i)}
124
+ class="flex h-9 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
125
+ title="Remove"
126
+ >
127
+ <X class="h-4 w-4" />
128
+ </button>
129
+ </div>
130
+ {/each}
131
+
132
+ <button
133
+ type="button"
134
+ onclick={addEntry}
135
+ class="inline-flex h-9 items-center gap-1.5 rounded-md border border-dashed px-3 text-sm text-muted-foreground hover:border-primary hover:text-primary"
136
+ >
137
+ <Plus class="h-4 w-4" />
138
+ Add entry
139
+ </button>
140
+ </div>
141
+
142
+ {#if error}
143
+ <p class="mt-1 text-xs text-destructive">{error}</p>
144
+ {/if}
145
+ </div>
@@ -0,0 +1,44 @@
1
+ <script lang="ts">
2
+ import type { FieldSchema } from '$lib/types/schema.js'
3
+
4
+ let {
5
+ field,
6
+ value = $bindable(),
7
+ error,
8
+ }: {
9
+ field: FieldSchema
10
+ value?: number | string | null
11
+ error?: string
12
+ } = $props()
13
+
14
+ function handleInput(e: Event) {
15
+ const target = e.target as HTMLInputElement
16
+ const v = target.value
17
+ if (v === '') {
18
+ value = null
19
+ } else {
20
+ value = field.integerOnly ? parseInt(v, 10) : parseFloat(v)
21
+ }
22
+ }
23
+ </script>
24
+
25
+ <div>
26
+ <label for={field.name} class="mb-1.5 block text-sm font-medium">
27
+ {field.label}
28
+ {#if field.required}<span class="text-destructive">*</span>{/if}
29
+ </label>
30
+ <input
31
+ id={field.name}
32
+ type="number"
33
+ value={value ?? ''}
34
+ oninput={handleInput}
35
+ min={field.min}
36
+ max={field.max}
37
+ step={field.integerOnly ? 1 : 'any'}
38
+ class="h-10 w-full rounded-md border bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring {error ? 'border-destructive' : ''}"
39
+ placeholder={field.label}
40
+ />
41
+ {#if error}
42
+ <p class="mt-1 text-xs text-destructive">{error}</p>
43
+ {/if}
44
+ </div>
@@ -0,0 +1,38 @@
1
+ <script lang="ts">
2
+ import type { FieldSchema } from '$lib/types/schema.js'
3
+
4
+ // PasswordField — obscured input for field.Text{Type:"password"}.
5
+ // Phase 21 D-06 + Open Question 2 resolution: ship dedicated component to
6
+ // avoid browser autofill leaking password chars into a <input type=text>.
7
+
8
+ let {
9
+ field,
10
+ value = $bindable(),
11
+ error,
12
+ }: {
13
+ field: FieldSchema
14
+ value?: string
15
+ error?: string
16
+ } = $props()
17
+ </script>
18
+
19
+ <div>
20
+ <label for={field.name} class="mb-1.5 block text-sm font-medium">
21
+ {field.label}
22
+ {#if field.required}<span class="text-destructive">*</span>{/if}
23
+ </label>
24
+ <input
25
+ id={field.name}
26
+ name={field.name}
27
+ type="password"
28
+ bind:value
29
+ autocomplete="new-password"
30
+ class="h-10 w-full rounded-md border bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring {error ? 'border-destructive' : ''}"
31
+ placeholder="leave empty to keep unchanged"
32
+ minlength={field.minLength}
33
+ maxlength={field.maxLength}
34
+ />
35
+ {#if error}
36
+ <p class="mt-1 text-xs text-destructive">{error}</p>
37
+ {/if}
38
+ </div>