@bobbykim/manguito-cms-admin 0.1.1 → 0.2.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/index.html +13 -0
- package/package.json +10 -6
- package/public/favicon.ico +0 -0
- package/src/App.vue +147 -0
- package/src/adapters/admin.ts +13 -0
- package/src/components/.gitkeep +0 -0
- package/src/components/fields/BooleanToggle.vue +63 -0
- package/src/components/fields/DatePicker.vue +61 -0
- package/src/components/fields/EnumSelect.vue +69 -0
- package/src/components/fields/MediaUpload.vue +445 -0
- package/src/components/fields/NumberInput.vue +66 -0
- package/src/components/fields/ParagraphEmbed.vue +149 -0
- package/src/components/fields/ReferenceSelect.vue +275 -0
- package/src/components/fields/RichTextEditor.vue +293 -0
- package/src/components/fields/TextInput.vue +93 -0
- package/src/components/fields/__tests__/BooleanToggle.test.ts +37 -0
- package/src/components/fields/__tests__/DatePicker.test.ts +37 -0
- package/src/components/fields/__tests__/EnumSelect.test.ts +53 -0
- package/src/components/fields/__tests__/NumberInput.test.ts +64 -0
- package/src/components/fields/__tests__/ReferenceSelect.test.ts +196 -0
- package/src/components/fields/__tests__/RichTextEditor.test.ts +81 -0
- package/src/components/fields/__tests__/TextInput.test.ts +72 -0
- package/src/components/layout/AppShell.vue +33 -0
- package/src/components/layout/Footer.vue +11 -0
- package/src/components/layout/Sidebar.vue +254 -0
- package/src/components/layout/Topbar.vue +46 -0
- package/src/components/shared/ConfirmDialog.vue +38 -0
- package/src/components/shared/MediaSelectModal.vue +273 -0
- package/src/components/shared/Pagination.vue +93 -0
- package/src/components/shared/StatusBadge.vue +14 -0
- package/src/components/shared/ToastContainer.vue +44 -0
- package/src/composables/.gitkeep +0 -0
- package/src/composables/__tests__/useApiClient.test.ts +90 -0
- package/src/composables/__tests__/useFormValidation.test.ts +83 -0
- package/src/composables/__tests__/usePermission.test.ts +116 -0
- package/src/composables/useApiClient.ts +68 -0
- package/src/composables/useDirtyState.ts +28 -0
- package/src/composables/useFormValidation.ts +107 -0
- package/src/composables/useNotification.ts +26 -0
- package/src/composables/usePermission.ts +20 -0
- package/src/composables/useTabIndicator.ts +25 -0
- package/src/env.d.ts +2 -0
- package/src/main.ts +21 -0
- package/src/router/index.ts +174 -0
- package/src/stores/auth.ts +82 -0
- package/src/stores/content.ts +40 -0
- package/src/stores/media.ts +31 -0
- package/src/stores/schema.ts +130 -0
- package/src/stores/taxonomy.ts +40 -0
- package/src/stores/ui.ts +94 -0
- package/src/stores/users.ts +44 -0
- package/src/style.css +46 -0
- package/src/test-utils/server.ts +54 -0
- package/src/types/index.ts +0 -0
- package/src/views/.gitkeep +0 -0
- package/src/views/ChangePasswordView.vue +134 -0
- package/src/views/LoginView.vue +187 -0
- package/src/views/RolesView.vue +84 -0
- package/src/views/content/ContentFormView.vue +548 -0
- package/src/views/content/ContentListView.vue +236 -0
- package/src/views/content/ContentSettingsView.vue +122 -0
- package/src/views/media/MediaDetailView.vue +222 -0
- package/src/views/media/MediaLibraryView.vue +492 -0
- package/src/views/taxonomy/TaxonomyFormView.vue +281 -0
- package/src/views/taxonomy/TaxonomyListView.vue +198 -0
- package/src/views/users/UserFormView.vue +307 -0
- package/src/views/users/UserListView.vue +127 -0
- package/src/vite-env.d.ts +1 -0
- package/vite.config.ts +17 -0
|
@@ -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
|
+
×
|
|
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
|
+
⋮⋮
|
|
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>
|