@bobbykim/manguito-cms-admin 0.1.1 → 0.3.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 (90) hide show
  1. package/dist/assets/{ChangePasswordView-8UW4RWd8.js → ChangePasswordView-B0wg3O_I.js} +1 -1
  2. package/dist/assets/{EnumSelect-BOrBkA76.js → ComputedDisplay-C2FpwSB1.js} +2 -2
  3. package/dist/assets/ContentFormView-D92O0ICh.js +1 -0
  4. package/dist/assets/{ContentListView-BiPHUBHa.js → ContentListView-DM4xggDx.js} +1 -1
  5. package/dist/assets/{ContentSettingsView-DPKF5MRL.js → ContentSettingsView-B9UHNleT.js} +1 -1
  6. package/dist/assets/{LoginView-WELPH4Vh.js → LoginView-DPpdT3Ro.js} +1 -1
  7. package/dist/assets/{MediaDetailView-BT66fV9b.js → MediaDetailView-OUzzhO1B.js} +1 -1
  8. package/dist/assets/{MediaLibraryView-BcdiJ_7a.js → MediaLibraryView-Dx9t8r8T.js} +1 -1
  9. package/dist/assets/TaxonomyFormView-CZlOjxOY.js +1 -0
  10. package/dist/assets/{TaxonomyListView-BB7iTfjb.js → TaxonomyListView-Dkga11a3.js} +1 -1
  11. package/dist/assets/{UserFormView-C2p-FV0A.js → UserFormView-CaOcoKX5.js} +1 -1
  12. package/dist/assets/{UserListView-BnlU2Ovr.js → UserListView-B6_Hdemt.js} +1 -1
  13. package/dist/assets/{index-BkazhAun.js → index-s-2X8XCa.js} +2 -2
  14. package/dist/assets/{useNotification-DU6J5vms.js → useNotification-CEPVkHak.js} +1 -1
  15. package/dist/codegen/index.cjs +7 -2
  16. package/dist/codegen/index.js +7 -2
  17. package/dist/index.html +1 -1
  18. package/index.html +13 -0
  19. package/package.json +11 -7
  20. package/public/favicon.ico +0 -0
  21. package/src/App.vue +147 -0
  22. package/src/adapters/admin.ts +13 -0
  23. package/src/components/.gitkeep +0 -0
  24. package/src/components/fields/BooleanToggle.vue +63 -0
  25. package/src/components/fields/ComputedDisplay.vue +23 -0
  26. package/src/components/fields/DatePicker.vue +61 -0
  27. package/src/components/fields/EnumSelect.vue +69 -0
  28. package/src/components/fields/MediaUpload.vue +445 -0
  29. package/src/components/fields/NumberInput.vue +66 -0
  30. package/src/components/fields/ParagraphEmbed.vue +149 -0
  31. package/src/components/fields/ReferenceSelect.vue +275 -0
  32. package/src/components/fields/RichTextEditor.vue +293 -0
  33. package/src/components/fields/TextInput.vue +93 -0
  34. package/src/components/fields/__tests__/BooleanToggle.test.ts +37 -0
  35. package/src/components/fields/__tests__/ComputedDisplay.test.ts +34 -0
  36. package/src/components/fields/__tests__/DatePicker.test.ts +37 -0
  37. package/src/components/fields/__tests__/EnumSelect.test.ts +53 -0
  38. package/src/components/fields/__tests__/NumberInput.test.ts +64 -0
  39. package/src/components/fields/__tests__/ReferenceSelect.test.ts +196 -0
  40. package/src/components/fields/__tests__/RichTextEditor.test.ts +81 -0
  41. package/src/components/fields/__tests__/TextInput.test.ts +72 -0
  42. package/src/components/layout/AppShell.vue +33 -0
  43. package/src/components/layout/Footer.vue +11 -0
  44. package/src/components/layout/Sidebar.vue +254 -0
  45. package/src/components/layout/Topbar.vue +46 -0
  46. package/src/components/shared/ConfirmDialog.vue +38 -0
  47. package/src/components/shared/MediaSelectModal.vue +273 -0
  48. package/src/components/shared/Pagination.vue +93 -0
  49. package/src/components/shared/StatusBadge.vue +14 -0
  50. package/src/components/shared/ToastContainer.vue +44 -0
  51. package/src/composables/.gitkeep +0 -0
  52. package/src/composables/__tests__/useApiClient.test.ts +90 -0
  53. package/src/composables/__tests__/useFormValidation.test.ts +83 -0
  54. package/src/composables/__tests__/usePermission.test.ts +116 -0
  55. package/src/composables/useApiClient.ts +68 -0
  56. package/src/composables/useDirtyState.ts +28 -0
  57. package/src/composables/useFormValidation.ts +107 -0
  58. package/src/composables/useNotification.ts +26 -0
  59. package/src/composables/usePermission.ts +20 -0
  60. package/src/composables/useTabIndicator.ts +25 -0
  61. package/src/env.d.ts +2 -0
  62. package/src/main.ts +21 -0
  63. package/src/router/index.ts +174 -0
  64. package/src/stores/auth.ts +82 -0
  65. package/src/stores/content.ts +40 -0
  66. package/src/stores/media.ts +31 -0
  67. package/src/stores/schema.ts +130 -0
  68. package/src/stores/taxonomy.ts +40 -0
  69. package/src/stores/ui.ts +94 -0
  70. package/src/stores/users.ts +44 -0
  71. package/src/style.css +46 -0
  72. package/src/test-utils/server.ts +54 -0
  73. package/src/types/index.ts +0 -0
  74. package/src/views/.gitkeep +0 -0
  75. package/src/views/ChangePasswordView.vue +134 -0
  76. package/src/views/LoginView.vue +187 -0
  77. package/src/views/RolesView.vue +84 -0
  78. package/src/views/content/ContentFormView.vue +553 -0
  79. package/src/views/content/ContentListView.vue +236 -0
  80. package/src/views/content/ContentSettingsView.vue +122 -0
  81. package/src/views/media/MediaDetailView.vue +222 -0
  82. package/src/views/media/MediaLibraryView.vue +492 -0
  83. package/src/views/taxonomy/TaxonomyFormView.vue +286 -0
  84. package/src/views/taxonomy/TaxonomyListView.vue +198 -0
  85. package/src/views/users/UserFormView.vue +307 -0
  86. package/src/views/users/UserListView.vue +127 -0
  87. package/src/vite-env.d.ts +1 -0
  88. package/vite.config.ts +17 -0
  89. package/dist/assets/ContentFormView-vaIInaYs.js +0 -1
  90. package/dist/assets/TaxonomyFormView-BdRO9L-7.js +0 -1
@@ -0,0 +1,445 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted } from 'vue'
3
+ import type { ParsedField } from '@bobbykim/manguito-cms-core'
4
+ import type { MediaItem } from '@bobbykim/manguito-cms-core'
5
+ import { useUiStore } from '../../stores/ui'
6
+ import MediaSelectModal from '../shared/MediaSelectModal.vue'
7
+
8
+ const props = defineProps<{
9
+ field: ParsedField
10
+ modelValue: unknown
11
+ error?: string
12
+ disabled?: boolean
13
+ }>()
14
+
15
+ const emit = defineEmits<{
16
+ 'update:modelValue': [value: string | null]
17
+ }>()
18
+
19
+ const uiStore = useUiStore()
20
+
21
+ // Narrowed ui_component for accepted MIME types.
22
+ const acceptMime = computed(() => {
23
+ const c = props.field.ui_component
24
+ return c.component === 'file-upload' ? c.accepted_mime_types.join(',') : ''
25
+ })
26
+
27
+ const mediaType = computed((): 'image' | 'video' | 'file' => {
28
+ const t = props.field.field_type
29
+ if (t === 'image' || t === 'video' || t === 'file') return t
30
+ return 'file'
31
+ })
32
+
33
+ // Alt text is required for video / file; optional for images.
34
+ const altRequired = computed(() => mediaType.value !== 'image')
35
+
36
+ // ── State ─────────────────────────────────────────────────────────────────────
37
+
38
+ type UploadPhase = 'idle' | 'alt-collect' | 'uploading'
39
+
40
+ const uploadPhase = ref<UploadPhase>('idle')
41
+ const uploadProgress = ref(0)
42
+ const uploadError = ref('')
43
+
44
+ const pendingFile = ref<File | null>(null)
45
+ const altText = ref('')
46
+
47
+ // Full MediaItem kept for display; modelValue only stores the ID.
48
+ const displayItem = ref<MediaItem | null>(null)
49
+
50
+ const showModal = ref(false)
51
+ const fileInputRef = ref<HTMLInputElement | null>(null)
52
+
53
+ // ── Helpers ───────────────────────────────────────────────────────────────────
54
+
55
+ function formatSize(bytes: number): string {
56
+ if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`
57
+ if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(0)} KB`
58
+ return `${bytes} B`
59
+ }
60
+
61
+ function filenameFrom(url: string): string {
62
+ return url.split('/').pop() ?? url
63
+ }
64
+
65
+ // ── Upload logic (XMLHttpRequest — not useApiClient) ──────────────────────────
66
+
67
+ function uploadDirect(file: File, alt: string): Promise<MediaItem> {
68
+ return new Promise((resolve, reject) => {
69
+ const form = new FormData()
70
+ form.append('file', file)
71
+ if (alt) form.append('alt', alt)
72
+
73
+ const xhr = new XMLHttpRequest()
74
+ xhr.withCredentials = true
75
+ xhr.open('POST', `${__ADMIN_PREFIX__}/api/media/${mediaType.value}`)
76
+
77
+ xhr.upload.onprogress = (e) => {
78
+ if (e.lengthComputable) {
79
+ uploadProgress.value = Math.round((e.loaded / e.total) * 100)
80
+ }
81
+ }
82
+
83
+ xhr.onload = () => {
84
+ try {
85
+ const json = JSON.parse(xhr.responseText) as {
86
+ ok: boolean
87
+ data?: MediaItem
88
+ error?: { message: string }
89
+ }
90
+ if (json.ok && json.data) {
91
+ resolve(json.data)
92
+ } else {
93
+ reject(new Error(json.error?.message ?? 'Upload failed'))
94
+ }
95
+ } catch {
96
+ reject(new Error('Invalid server response'))
97
+ }
98
+ }
99
+
100
+ xhr.onerror = () => reject(new Error('Network error'))
101
+ xhr.send(form)
102
+ })
103
+ }
104
+
105
+ async function uploadPresigned(file: File, alt: string): Promise<MediaItem> {
106
+ // Step 1 — get presigned URL.
107
+ const params = new URLSearchParams({
108
+ type: mediaType.value,
109
+ filename: file.name,
110
+ mime_type: file.type,
111
+ })
112
+ const presignedRes = await fetch(
113
+ `${__ADMIN_PREFIX__}/api/media/presigned-url?${params.toString()}`,
114
+ { credentials: 'include' }
115
+ )
116
+ const presignedJson = (await presignedRes.json()) as {
117
+ ok: boolean
118
+ data?: { upload_url: string; media_id: string; method?: 'PUT' | 'POST'; fields?: Record<string, string> }
119
+ error?: { message: string }
120
+ }
121
+ if (!presignedJson.ok || !presignedJson.data) {
122
+ throw new Error(presignedJson.error?.message ?? 'Failed to get upload URL')
123
+ }
124
+
125
+ const { upload_url, media_id, method, fields } = presignedJson.data
126
+
127
+ // Step 2 — upload directly to storage (no auth headers; the URL/fields are
128
+ // self-authenticated). S3 takes a raw PUT; Cloudinary a multipart POST with
129
+ // signed fields.
130
+ await new Promise<void>((resolve, reject) => {
131
+ const xhr = new XMLHttpRequest()
132
+
133
+ xhr.upload.onprogress = (e) => {
134
+ if (e.lengthComputable) {
135
+ uploadProgress.value = Math.round((e.loaded / e.total) * 100)
136
+ }
137
+ }
138
+
139
+ xhr.onload = () => {
140
+ xhr.status >= 200 && xhr.status < 300
141
+ ? resolve()
142
+ : reject(new Error(`Storage upload failed (${xhr.status})`))
143
+ }
144
+ xhr.onerror = () => reject(new Error('Network error during upload'))
145
+ if (method === 'POST' && fields) {
146
+ const form = new FormData()
147
+ for (const [k, v] of Object.entries(fields)) form.append(k, v)
148
+ form.append('file', file)
149
+ xhr.open('POST', upload_url)
150
+ xhr.send(form)
151
+ } else {
152
+ xhr.open('PUT', upload_url)
153
+ xhr.send(file)
154
+ }
155
+ })
156
+
157
+ // Step 3 — confirm with alt text.
158
+ const confirmRes = await fetch(
159
+ `${__ADMIN_PREFIX__}/api/media/confirm/${media_id}`,
160
+ {
161
+ method: 'POST',
162
+ credentials: 'include',
163
+ headers: { 'Content-Type': 'application/json' },
164
+ body: JSON.stringify({ alt }),
165
+ }
166
+ )
167
+ const confirmJson = (await confirmRes.json()) as {
168
+ ok: boolean
169
+ data?: MediaItem
170
+ error?: { message: string }
171
+ }
172
+ if (!confirmJson.ok || !confirmJson.data) {
173
+ throw new Error(confirmJson.error?.message ?? 'Upload confirmation failed')
174
+ }
175
+
176
+ return confirmJson.data
177
+ }
178
+
179
+ async function startUpload(file: File) {
180
+ uploadPhase.value = 'uploading'
181
+ uploadProgress.value = 0
182
+ uploadError.value = ''
183
+
184
+ try {
185
+ const alt = altText.value.trim()
186
+ // Cloud storage uploads straight to the bucket via presigned URL; local
187
+ // storage routes through the server's direct endpoint.
188
+ const item = uiStore.presignedUploads
189
+ ? await uploadPresigned(file, alt)
190
+ : await uploadDirect(file, alt)
191
+
192
+ displayItem.value = item
193
+ altText.value = item.alt ?? ''
194
+ emit('update:modelValue', item.id)
195
+ } catch (e) {
196
+ uploadError.value = e instanceof Error ? e.message : 'Upload failed'
197
+ } finally {
198
+ uploadPhase.value = 'idle'
199
+ pendingFile.value = null
200
+ }
201
+ }
202
+
203
+ // Persist alt text for images via PATCH after the upload completes.
204
+ async function saveAlt() {
205
+ const item = displayItem.value
206
+ if (!item) return
207
+
208
+ const xhr = new XMLHttpRequest()
209
+ xhr.withCredentials = true
210
+ xhr.open('PATCH', `${__ADMIN_PREFIX__}/api/media/${item.id}`)
211
+ xhr.setRequestHeader('Content-Type', 'application/json')
212
+ xhr.onload = () => {
213
+ try {
214
+ const json = JSON.parse(xhr.responseText) as { ok: boolean; data?: MediaItem }
215
+ if (json.ok && json.data) displayItem.value = json.data
216
+ } catch {
217
+ // Ignore parse errors — display stays as-is.
218
+ }
219
+ }
220
+ xhr.send(JSON.stringify({ alt: altText.value.trim() }))
221
+ }
222
+
223
+ // ── Event handlers ────────────────────────────────────────────────────────────
224
+
225
+ function onFileSelected(e: Event) {
226
+ const file = (e.target as HTMLInputElement).files?.[0]
227
+ if (!file) return
228
+
229
+ pendingFile.value = file
230
+ altText.value = ''
231
+ ;(e.target as HTMLInputElement).value = '' // allow re-selecting the same file
232
+
233
+ if (altRequired.value) {
234
+ uploadPhase.value = 'alt-collect'
235
+ } else {
236
+ void startUpload(file)
237
+ }
238
+ }
239
+
240
+ function startUploadFromForm() {
241
+ if (!pendingFile.value) return
242
+ if (altRequired.value && !altText.value.trim()) return
243
+ void startUpload(pendingFile.value)
244
+ }
245
+
246
+ function cancelUpload() {
247
+ pendingFile.value = null
248
+ altText.value = ''
249
+ uploadPhase.value = 'idle'
250
+ uploadError.value = ''
251
+ }
252
+
253
+ function clearSelection() {
254
+ displayItem.value = null
255
+ altText.value = ''
256
+ emit('update:modelValue', null)
257
+ }
258
+
259
+ function onModalSelect(item: MediaItem) {
260
+ displayItem.value = item
261
+ altText.value = item.alt ?? ''
262
+ showModal.value = false
263
+ emit('update:modelValue', item.id)
264
+ }
265
+
266
+ // ── Init ──────────────────────────────────────────────────────────────────────
267
+
268
+ onMounted(async () => {
269
+ if (typeof props.modelValue !== 'string' || !props.modelValue) return
270
+ const res = await fetch(`${__ADMIN_PREFIX__}/api/media/${props.modelValue}`, {
271
+ credentials: 'include',
272
+ })
273
+ if (!res.ok) return
274
+ const json = (await res.json()) as { ok: boolean; data?: MediaItem }
275
+ if (json.ok && json.data) {
276
+ displayItem.value = json.data
277
+ altText.value = json.data.alt ?? ''
278
+ }
279
+ })
280
+ </script>
281
+
282
+ <template>
283
+ <div>
284
+ <label class="block text-[13px] font-semibold text-[#3D3D52]">
285
+ {{ field.label }}
286
+ <span v-if="field.required" class="ml-0.5 text-red-500" aria-hidden="true">*</span>
287
+ </label>
288
+
289
+ <!-- ── Selected media item ─────────────────────────────────────────────── -->
290
+ <div
291
+ v-if="displayItem"
292
+ class="mt-1 flex flex-col gap-2 rounded-md border border-gray-200 p-3"
293
+ >
294
+ <div class="flex items-center gap-3">
295
+ <!-- Image thumbnail -->
296
+ <img
297
+ v-if="displayItem.type === 'image'"
298
+ :src="displayItem.url"
299
+ :alt="displayItem.alt ?? ''"
300
+ class="h-16 w-16 rounded object-cover"
301
+ />
302
+ <!-- Video / file icon placeholder -->
303
+ <div
304
+ v-else
305
+ class="flex h-16 w-16 items-center justify-center rounded bg-gray-100 text-2xl text-gray-400"
306
+ aria-hidden="true"
307
+ >
308
+ {{ displayItem.type === 'video' ? '▶' : '📄' }}
309
+ </div>
310
+
311
+ <div class="min-w-0 flex-1">
312
+ <p class="truncate text-sm font-medium text-gray-800">
313
+ {{ filenameFrom(displayItem.url) }}
314
+ </p>
315
+ <p class="text-xs text-gray-500">{{ formatSize(displayItem.file_size) }}</p>
316
+ </div>
317
+
318
+ <button
319
+ v-if="!disabled"
320
+ type="button"
321
+ class="shrink-0 text-lg leading-none text-gray-400 hover:text-red-500"
322
+ aria-label="Remove selected media"
323
+ @click="clearSelection"
324
+ >
325
+ &times;
326
+ </button>
327
+ </div>
328
+
329
+ <!-- Optional alt text for images (inline, saved on blur via PATCH) -->
330
+ <div v-if="displayItem.type === 'image' && !disabled">
331
+ <label :for="`${field.name}-alt`" class="block text-xs text-gray-500">
332
+ Alt text <span class="text-gray-400">(optional)</span>
333
+ </label>
334
+ <input
335
+ :id="`${field.name}-alt`"
336
+ v-model="altText"
337
+ type="text"
338
+ placeholder="Describe this image"
339
+ class="mt-0.5 block w-full rounded-md border border-gray-300 px-2 py-1 text-sm shadow-sm focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200"
340
+ @blur="saveAlt"
341
+ />
342
+ </div>
343
+ </div>
344
+
345
+ <!-- ── Upload in progress ──────────────────────────────────────────────── -->
346
+ <div
347
+ v-else-if="uploadPhase === 'uploading'"
348
+ class="mt-1 rounded-md border border-gray-200 p-3"
349
+ >
350
+ <p class="mb-1.5 text-xs text-gray-500">Uploading…</p>
351
+ <div class="flex items-center gap-2">
352
+ <div class="h-2 flex-1 overflow-hidden rounded-full bg-gray-100">
353
+ <div
354
+ class="h-2 rounded-full bg-indigo-600 transition-all duration-150"
355
+ :style="{ width: `${uploadProgress}%` }"
356
+ />
357
+ </div>
358
+ <span class="w-8 shrink-0 text-right text-xs text-gray-500">
359
+ {{ uploadProgress }}%
360
+ </span>
361
+ </div>
362
+ </div>
363
+
364
+ <!-- ── Alt text collection (video / file — required before upload) ────── -->
365
+ <div
366
+ v-else-if="uploadPhase === 'alt-collect'"
367
+ class="mt-1 rounded-md border border-gray-200 p-3"
368
+ >
369
+ <p class="mb-2 truncate text-[13px] font-semibold text-[#3D3D52]">
370
+ {{ pendingFile?.name }}
371
+ </p>
372
+ <label :for="`${field.name}-alt-pre`" class="block text-xs text-gray-600">
373
+ Alt text
374
+ <span class="text-red-500" aria-hidden="true">*</span>
375
+ </label>
376
+ <input
377
+ :id="`${field.name}-alt-pre`"
378
+ v-model="altText"
379
+ type="text"
380
+ placeholder="Describe this file"
381
+ class="mt-0.5 block w-full rounded-md border border-gray-300 px-2 py-1 text-sm shadow-sm focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200"
382
+ />
383
+ <div class="mt-3 flex justify-end gap-2">
384
+ <button
385
+ type="button"
386
+ class="rounded-md px-3 py-1.5 text-sm text-gray-600 ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
387
+ @click="cancelUpload"
388
+ >
389
+ Cancel
390
+ </button>
391
+ <button
392
+ type="button"
393
+ :disabled="!altText.trim()"
394
+ class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
395
+ @click="startUploadFromForm"
396
+ >
397
+ Upload
398
+ </button>
399
+ </div>
400
+ </div>
401
+
402
+ <!-- ── Idle: trigger buttons ───────────────────────────────────────────── -->
403
+ <div v-else class="mt-1 flex gap-2">
404
+ <input
405
+ ref="fileInputRef"
406
+ type="file"
407
+ class="hidden"
408
+ :accept="acceptMime"
409
+ @change="onFileSelected"
410
+ />
411
+ <button
412
+ type="button"
413
+ :disabled="disabled"
414
+ class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
415
+ @click="fileInputRef?.click()"
416
+ >
417
+ Upload
418
+ </button>
419
+ <button
420
+ type="button"
421
+ :disabled="disabled"
422
+ class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
423
+ @click="showModal = true"
424
+ >
425
+ Select from library
426
+ </button>
427
+ </div>
428
+
429
+ <!-- Error messages -->
430
+ <p v-if="uploadError" class="mt-1 text-sm text-red-600" role="alert">
431
+ {{ uploadError }}
432
+ </p>
433
+ <p v-if="error" class="mt-1 text-sm text-red-600" role="alert">
434
+ {{ error }}
435
+ </p>
436
+
437
+ <!-- Media select modal -->
438
+ <MediaSelectModal
439
+ v-if="showModal"
440
+ :accepted-type="mediaType"
441
+ @select="onModalSelect"
442
+ @close="showModal = false"
443
+ />
444
+ </div>
445
+ </template>
@@ -0,0 +1,66 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { ParsedField } from '@bobbykim/manguito-cms-core'
4
+
5
+ const props = defineProps<{
6
+ field: ParsedField
7
+ modelValue: unknown
8
+ error?: string
9
+ disabled?: boolean
10
+ }>()
11
+
12
+ const emit = defineEmits<{
13
+ 'update:modelValue': [value: number | null]
14
+ }>()
15
+
16
+ const step = computed(() => props.field.field_type === 'integer' ? 1 : 0.01)
17
+ const min = computed(() => props.field.validation.min)
18
+ const max = computed(() => props.field.validation.max)
19
+
20
+ const numValue = computed(() =>
21
+ typeof props.modelValue === 'number' ? props.modelValue : ''
22
+ )
23
+
24
+ function onInput(event: Event) {
25
+ const input = event.target as HTMLInputElement
26
+ const n = input.valueAsNumber
27
+ emit('update:modelValue', isNaN(n) ? null : n)
28
+ }
29
+ </script>
30
+
31
+ <template>
32
+ <div>
33
+ <label
34
+ :for="field.name"
35
+ class="block text-[13px] font-semibold text-[#3D3D52]"
36
+ >
37
+ {{ field.label }}
38
+ <span v-if="field.required" class="ml-0.5 text-red-500" aria-hidden="true">*</span>
39
+ </label>
40
+
41
+ <div class="mt-1">
42
+ <input
43
+ :id="field.name"
44
+ type="number"
45
+ :value="numValue"
46
+ :step="step"
47
+ :min="min"
48
+ :max="max"
49
+ :disabled="disabled"
50
+ :placeholder="field.label"
51
+ :class="[
52
+ 'block w-full rounded-[11px] border px-3.5 py-3 text-sm focus:outline-none focus:ring-[3px]',
53
+ error
54
+ ? 'border-red-300 focus:border-red-400 focus:ring-red-200'
55
+ : 'border-[#E4E3EE] bg-[#FBFBFD] focus:border-indigo-400 focus:bg-white focus:ring-indigo-50',
56
+ disabled && 'cursor-not-allowed bg-gray-50 opacity-60',
57
+ ]"
58
+ @input="onInput"
59
+ />
60
+ </div>
61
+
62
+ <p v-if="error" class="mt-1 text-sm text-red-600" role="alert">
63
+ {{ error }}
64
+ </p>
65
+ </div>
66
+ </template>
@@ -0,0 +1,149 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed } from 'vue'
3
+ import type { Component } from 'vue'
4
+ import type { ParsedField } from '@bobbykim/manguito-cms-core'
5
+
6
+ const props = defineProps<{
7
+ field: ParsedField
8
+ modelValue: unknown
9
+ error?: string
10
+ disabled?: boolean
11
+ formComponent: Component
12
+ }>()
13
+
14
+ const emit = defineEmits<{
15
+ 'update:modelValue': [value: Record<string, unknown>[]]
16
+ }>()
17
+
18
+ const safeValue = computed<Record<string, unknown>[]>(() =>
19
+ Array.isArray(props.modelValue)
20
+ ? (props.modelValue as Record<string, unknown>[])
21
+ : []
22
+ )
23
+
24
+ // Drag state — not tested (too coupled to DOM / jsdom unreliable).
25
+ const dragFromIndex = ref<number | null>(null)
26
+ const dragOverIndex = ref<number | null>(null)
27
+
28
+ function addItem() {
29
+ const items = [...safeValue.value]
30
+ items.push({ order: items.length })
31
+ emit('update:modelValue', items)
32
+ }
33
+
34
+ function removeItem(index: number) {
35
+ const items = [...safeValue.value]
36
+ items.splice(index, 1)
37
+ emit('update:modelValue', items.map((item, i) => ({ ...item, order: i })))
38
+ }
39
+
40
+ function updateItem(index: number, value: Record<string, unknown>) {
41
+ const items = [...safeValue.value]
42
+ items[index] = { ...value, order: index }
43
+ emit('update:modelValue', items)
44
+ }
45
+
46
+ function onDragStart(index: number) {
47
+ dragFromIndex.value = index
48
+ }
49
+
50
+ function onDragOver(index: number) {
51
+ dragOverIndex.value = index
52
+ }
53
+
54
+ function onDrop(toIndex: number) {
55
+ if (dragFromIndex.value === null || dragFromIndex.value === toIndex) {
56
+ dragFromIndex.value = null
57
+ dragOverIndex.value = null
58
+ return
59
+ }
60
+ const items = [...safeValue.value]
61
+ const [moved] = items.splice(dragFromIndex.value, 1)
62
+ if (moved === undefined) return
63
+ items.splice(toIndex, 0, moved)
64
+ emit('update:modelValue', items.map((item, i) => ({ ...item, order: i })))
65
+ dragFromIndex.value = null
66
+ dragOverIndex.value = null
67
+ }
68
+
69
+ function onDragEnd() {
70
+ dragFromIndex.value = null
71
+ dragOverIndex.value = null
72
+ }
73
+ </script>
74
+
75
+ <template>
76
+ <div>
77
+ <label class="block text-[13px] font-semibold text-[#3D3D52]">
78
+ {{ field.label }}
79
+ <span v-if="field.required" class="ml-0.5 text-red-500" aria-hidden="true">*</span>
80
+ </label>
81
+
82
+ <div class="mt-1 space-y-2">
83
+ <div
84
+ v-for="(item, i) in safeValue"
85
+ :key="i"
86
+ draggable="true"
87
+ :class="[
88
+ 'rounded-md border bg-white transition-colors',
89
+ dragOverIndex === i && dragFromIndex !== i
90
+ ? 'border-indigo-400 ring-2 ring-indigo-200'
91
+ : 'border-gray-200',
92
+ disabled && 'opacity-60',
93
+ ]"
94
+ @dragstart="onDragStart(i)"
95
+ @dragover.prevent="onDragOver(i)"
96
+ @drop.prevent="onDrop(i)"
97
+ @dragend="onDragEnd"
98
+ >
99
+ <!-- Item header: drag handle + label + remove -->
100
+ <div class="flex items-center gap-2 border-b border-gray-100 px-3 py-2">
101
+ <span
102
+ class="cursor-grab select-none text-gray-400"
103
+ title="Drag to reorder"
104
+ aria-hidden="true"
105
+ >
106
+ &#8942;&#8942;
107
+ </span>
108
+ <span class="text-xs font-medium text-gray-500">
109
+ Item {{ i + 1 }}
110
+ </span>
111
+ <button
112
+ v-if="!disabled"
113
+ type="button"
114
+ class="ml-auto text-xs text-red-500 hover:text-red-700"
115
+ :aria-label="`Remove item ${i + 1}`"
116
+ @click="removeItem(i)"
117
+ >
118
+ Remove
119
+ </button>
120
+ </div>
121
+
122
+ <!-- Paragraph form rendered via dynamic component -->
123
+ <div class="p-3">
124
+ <component
125
+ :is="formComponent"
126
+ :model-value="item"
127
+ :disabled="disabled"
128
+ @update:model-value="(v: unknown) => updateItem(i, v as Record<string, unknown>)"
129
+ />
130
+ </div>
131
+ </div>
132
+ </div>
133
+
134
+ <!-- Add item button -->
135
+ <button
136
+ v-if="!disabled"
137
+ type="button"
138
+ class="mt-2 inline-flex items-center gap-1.5 rounded-md border border-dashed border-gray-300 px-3 py-2 text-sm text-gray-600 hover:border-indigo-400 hover:text-indigo-600"
139
+ @click="addItem"
140
+ >
141
+ <span aria-hidden="true">+</span>
142
+ Add {{ field.label }}
143
+ </button>
144
+
145
+ <p v-if="error" class="mt-1 text-sm text-red-600" role="alert">
146
+ {{ error }}
147
+ </p>
148
+ </div>
149
+ </template>