@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.
- package/LICENSE +661 -0
- package/biome.json +62 -0
- package/dist/assets/index-C9Y5-AKj.js +33 -0
- package/dist/assets/index-uVdiUjty.css +1 -0
- package/dist/index.html +20 -0
- package/index.html +19 -0
- package/package.json +43 -0
- package/src/AdminRoot.svelte +98 -0
- package/src/app.css +211 -0
- package/src/lib/Slot.svelte +65 -0
- package/src/lib/api/auth.ts +26 -0
- package/src/lib/api/client.ts +73 -0
- package/src/lib/api/files.ts +56 -0
- package/src/lib/api/globals.ts +13 -0
- package/src/lib/api/records.ts +102 -0
- package/src/lib/api/schema.ts +7 -0
- package/src/lib/api/versions.ts +40 -0
- package/src/lib/components/AdminHeader.svelte +107 -0
- package/src/lib/components/AdminSidebar.svelte +262 -0
- package/src/lib/components/DeleteDialog.svelte +58 -0
- package/src/lib/components/DocumentEditLayout.svelte +263 -0
- package/src/lib/components/DynamicForm.svelte +74 -0
- package/src/lib/components/KpiCard.svelte +75 -0
- package/src/lib/components/MediaLibrary.svelte +311 -0
- package/src/lib/components/Pagination.svelte +78 -0
- package/src/lib/components/RangeFilter.svelte +41 -0
- package/src/lib/components/RecordGrid.svelte +123 -0
- package/src/lib/components/RecordTable.svelte +156 -0
- package/src/lib/components/cells/CheckboxCell.svelte +10 -0
- package/src/lib/components/cells/ColorCell.svelte +15 -0
- package/src/lib/components/cells/DateCell.svelte +8 -0
- package/src/lib/components/cells/RelationshipCell.svelte +20 -0
- package/src/lib/components/cells/RichTextCell.svelte +21 -0
- package/src/lib/components/cells/SelectCell.svelte +26 -0
- package/src/lib/components/cells/TextCell.svelte +8 -0
- package/src/lib/components/cells/UploadCell.svelte +34 -0
- package/src/lib/components/cells/index.ts +28 -0
- package/src/lib/components/charts/TimeSeriesChart.svelte +184 -0
- package/src/lib/components/doc/ApiView.svelte +181 -0
- package/src/lib/components/doc/Autosave.svelte +102 -0
- package/src/lib/components/doc/DocHeader.svelte +86 -0
- package/src/lib/components/doc/DocMetaStrip.svelte +103 -0
- package/src/lib/components/doc/DocTabBar.svelte +26 -0
- package/src/lib/components/doc/HeaderModeSwitch.svelte +32 -0
- package/src/lib/components/doc/PublishButton.svelte +114 -0
- package/src/lib/components/doc/ScheduleModal.svelte +110 -0
- package/src/lib/components/doc/VersionHistory.svelte +20 -0
- package/src/lib/components/fields/ArrayFieldEditor.svelte +62 -0
- package/src/lib/components/fields/BlockCard.svelte +63 -0
- package/src/lib/components/fields/BlocksFieldEditor.svelte +83 -0
- package/src/lib/components/fields/CheckboxField.svelte +27 -0
- package/src/lib/components/fields/ColorField.svelte +46 -0
- package/src/lib/components/fields/DateField.svelte +52 -0
- package/src/lib/components/fields/EmailField.svelte +30 -0
- package/src/lib/components/fields/FileField.svelte +280 -0
- package/src/lib/components/fields/JsonField.svelte +145 -0
- package/src/lib/components/fields/NumberField.svelte +44 -0
- package/src/lib/components/fields/PasswordField.svelte +38 -0
- package/src/lib/components/fields/RelationshipField.svelte +271 -0
- package/src/lib/components/fields/RichTextField.svelte +139 -0
- package/src/lib/components/fields/SelectField.svelte +33 -0
- package/src/lib/components/fields/SlugField.svelte +70 -0
- package/src/lib/components/fields/TabsField.svelte +56 -0
- package/src/lib/components/fields/TagsField.svelte +85 -0
- package/src/lib/components/fields/TextField.svelte +36 -0
- package/src/lib/components/fields/TextareaField.svelte +32 -0
- package/src/lib/components/fields/UploadField.svelte +166 -0
- package/src/lib/components/fields/UploadFieldDispatch.svelte +21 -0
- package/src/lib/components/fields/UploadGalleryField.svelte +166 -0
- package/src/lib/components/fields/index.ts +22 -0
- package/src/lib/components/fields/registry.ts +58 -0
- package/src/lib/components/lexical/CustomHTMLComponent.svelte +52 -0
- package/src/lib/components/lexical/CustomHTMLNode.ts +94 -0
- package/src/lib/components/lexical/PullQuoteComponent.svelte +73 -0
- package/src/lib/components/lexical/PullQuoteNode.ts +112 -0
- package/src/lib/components/lexical/lexical-helpers.ts +24 -0
- package/src/lib/components/lexical/nodes.ts +8 -0
- package/src/lib/components/lexical/toolbar/EditorToolbar.svelte +159 -0
- package/src/lib/components/lexical/toolbar/InsertBlockDropdown.svelte +278 -0
- package/src/lib/components/versions/CompareSelector.svelte +31 -0
- package/src/lib/components/versions/FieldDiff.svelte +141 -0
- package/src/lib/components/versions/RestoreModal.svelte +67 -0
- package/src/lib/components/versions/StatusPill.svelte +21 -0
- package/src/lib/context.svelte.ts +156 -0
- package/src/lib/router/index.svelte.ts +282 -0
- package/src/lib/router/matcher.ts +52 -0
- package/src/lib/stores/branding.svelte.ts +74 -0
- package/src/lib/stores/schema.svelte.ts +17 -0
- package/src/lib/types/schema.ts +126 -0
- package/src/lib/utils/cn.ts +6 -0
- package/src/lib/utils/diff.ts +112 -0
- package/src/lib/utils/dirty.svelte.ts +50 -0
- package/src/lib/utils/format.ts +28 -0
- package/src/lib/utils/json-highlight.ts +34 -0
- package/src/lib/utils/slug.ts +8 -0
- package/src/main.ts +32 -0
- package/src/views/AdminLayout.svelte +73 -0
- package/src/views/AdsAnalyticsView.svelte +152 -0
- package/src/views/CollectionEditView.svelte +117 -0
- package/src/views/CollectionListView.svelte +347 -0
- package/src/views/CollectionNewView.svelte +68 -0
- package/src/views/CustomPageView.svelte +59 -0
- package/src/views/DashboardView.svelte +370 -0
- package/src/views/GlobalEditView.svelte +100 -0
- package/src/views/LoginView.svelte +231 -0
- package/src/views/NotFoundView.svelte +9 -0
- package/src/views/VersionDetailView.svelte +307 -0
- package/src/views/VersionsListView.svelte +201 -0
- package/tsconfig.json +25 -0
- 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>
|