feat(12.2-04): add the fileupload field with deferred uploads on the form session key
- sessionKey.ts: one 32-byte base64url key per form mount, sent only in headers - api/files.ts: FileRoutes over the record and child file routes, XHR upload with progress, 401 refresh and retry - FileuploadField and FileCaptionModal per UI-SPEC section 3: dropzone, image grid, rows, per-item states, client pre-checks, reorder, protected previews - FormView provides FORM_SESSION, counts pending changes as dirty and sends X-Session-Key on create and update - fileupload lang keys in en and pl, admin-spa docs note, deferred smoke test, rebuilt dist
This commit is contained in:
241
admin/src/components/form/fields/FileCaptionModal.vue
Normal file
241
admin/src/components/form/fields/FileCaptionModal.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
} from 'reka-ui'
|
||||
import { CircleAlert, FileText, X } from '@lucide/vue'
|
||||
import { errorMessage, type FileRoutes } from '../../../api/files'
|
||||
import type { FileItem } from '../../../api/types'
|
||||
import { t } from '../../../app/i18n'
|
||||
import { showToast } from '../../../state/useToasts'
|
||||
import ConfirmDialog from '../../ui/ConfirmDialog.vue'
|
||||
import { useConfirm } from '../../ui/confirm'
|
||||
import { controlClass } from '../control'
|
||||
|
||||
// Attachment details (UI-SPEC section 3, caption modal; D-09). Title and
|
||||
// description of one file are saved at once through the caption route (A7),
|
||||
// not with the form, so saving never marks the form dirty. A 422 stays in
|
||||
// the dialog under its inputs; any other failure keeps the input and shows
|
||||
// a danger toast. Closing with changes asks first.
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
file: FileItem | null
|
||||
routes: FileRoutes | null
|
||||
/** Preview thumbnail source, or null for the file icon. */
|
||||
preview: string | null
|
||||
idPrefix: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:open': [open: boolean]
|
||||
saved: [file: FileItem]
|
||||
closed: []
|
||||
}>()
|
||||
|
||||
const title = ref('')
|
||||
const description = ref('')
|
||||
const errors = ref<Record<string, string[]>>({})
|
||||
const busy = ref(false)
|
||||
const confirm = useConfirm()
|
||||
|
||||
/** 422 details as string lists (formState is not imported: registry cycle). */
|
||||
function detailMessages(details: Record<string, unknown> | undefined): Record<string, string[]> {
|
||||
const out: Record<string, string[]> = {}
|
||||
for (const [name, value] of Object.entries(details ?? {})) {
|
||||
const list = (Array.isArray(value) ? value : [value]).filter(
|
||||
(item): item is string => typeof item === 'string' && item !== '',
|
||||
)
|
||||
if (list.length > 0) {
|
||||
out[name] = list
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const titleId = computed(() => `${props.idPrefix}-caption-title`)
|
||||
const descriptionId = computed(() => `${props.idPrefix}-caption-description`)
|
||||
const dirty = computed(
|
||||
() => !!props.file && (title.value !== props.file.title || description.value !== props.file.description),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open && props.file) {
|
||||
title.value = props.file.title ?? ''
|
||||
description.value = props.file.description ?? ''
|
||||
errors.value = {}
|
||||
busy.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function setOpen(open: boolean): Promise<void> {
|
||||
if (open) {
|
||||
emit('update:open', true)
|
||||
return
|
||||
}
|
||||
if (busy.value) {
|
||||
return
|
||||
}
|
||||
if (dirty.value) {
|
||||
const ok = await confirm.ask({
|
||||
message: t('backend::lang.form.unsaved_confirm'),
|
||||
confirmLabel: t('backend::lang.form.discard'),
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) {
|
||||
return
|
||||
}
|
||||
}
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
if (busy.value || !props.file || !props.routes) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await props.routes.update(props.file.id, { title: title.value, description: description.value })
|
||||
if (result.data) {
|
||||
errors.value = {}
|
||||
emit('saved', result.data)
|
||||
emit('update:open', false)
|
||||
showToast(t('backend::lang.fileupload.details_saved'))
|
||||
return
|
||||
}
|
||||
if (result.status === 422) {
|
||||
errors.value = detailMessages(result.error?.details)
|
||||
if (Object.keys(errors.value).length === 0) {
|
||||
errors.value = { title: [errorMessage(result.error) || t('backend::lang.form.error_generic')] }
|
||||
}
|
||||
return
|
||||
}
|
||||
showToast(result.error?.message || t('backend::lang.form.error_generic'), 'danger')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCloseAutoFocus(event: Event): void {
|
||||
event.preventDefault()
|
||||
emit('closed')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot :open="open" @update:open="setOpen">
|
||||
<DialogPortal>
|
||||
<DialogOverlay data-dialog-overlay class="fixed inset-0 z-40 bg-overlay" />
|
||||
<DialogContent
|
||||
data-caption-modal
|
||||
data-dialog-content
|
||||
aria-modal="true"
|
||||
class="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100vh-32px)] w-[calc(100%-32px)] max-w-[560px] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-y-auto rounded-modal bg-surface p-6 text-text shadow-pop"
|
||||
@close-auto-focus="onCloseAutoFocus"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<DialogTitle class="text-[20px] font-bold tracking-[-0.01em]">{{ t('backend::lang.fileupload.attachment') }}</DialogTitle>
|
||||
<DialogDescription class="text-muted">{{ t('backend::lang.fileupload.help') }}</DialogDescription>
|
||||
</div>
|
||||
<DialogClose
|
||||
data-action="close"
|
||||
:aria-label="t('backend::lang.form.close')"
|
||||
:disabled="busy"
|
||||
class="flex size-[34px] shrink-0 items-center justify-center rounded-tab bg-subtle text-muted hover:bg-hover hover:text-text"
|
||||
>
|
||||
<X :size="16" aria-hidden="true" />
|
||||
</DialogClose>
|
||||
</div>
|
||||
|
||||
<div v-if="file" class="flex items-center gap-4">
|
||||
<img v-if="preview" :src="preview" alt="" class="size-16 shrink-0 rounded-inner object-cover" />
|
||||
<span v-else class="flex size-16 shrink-0 items-center justify-center rounded-inner bg-subtle" aria-hidden="true">
|
||||
<FileText :size="16" class="text-muted" />
|
||||
</span>
|
||||
<span class="min-w-0 truncate text-[13px] text-muted" :title="file.file_name">{{ file.file_name }}</span>
|
||||
</div>
|
||||
|
||||
<form class="flex flex-col gap-4" novalidate @submit.prevent="onSubmit">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label :for="titleId" class="font-semibold">{{ t('backend::lang.fileupload.title_label') }}</label>
|
||||
<input
|
||||
:id="titleId"
|
||||
v-model="title"
|
||||
type="text"
|
||||
data-caption-title
|
||||
:disabled="busy"
|
||||
:aria-invalid="errors.title ? 'true' : undefined"
|
||||
:aria-describedby="errors.title ? `${titleId}-error` : undefined"
|
||||
:class="controlClass(!!errors.title)"
|
||||
class="h-input"
|
||||
/>
|
||||
<p v-if="errors.title" :id="`${titleId}-error`" class="flex items-center gap-1.5 text-[13px] text-danger">
|
||||
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ errors.title.join(' ') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label :for="descriptionId" class="font-semibold">{{ t('backend::lang.fileupload.description_label') }}</label>
|
||||
<textarea
|
||||
:id="descriptionId"
|
||||
v-model="description"
|
||||
rows="3"
|
||||
data-caption-description
|
||||
:disabled="busy"
|
||||
:aria-invalid="errors.description ? 'true' : undefined"
|
||||
:aria-describedby="errors.description ? `${descriptionId}-error` : undefined"
|
||||
:class="controlClass(!!errors.description)"
|
||||
class="min-h-input resize-y py-2.5"
|
||||
/>
|
||||
<p
|
||||
v-if="errors.description"
|
||||
:id="`${descriptionId}-error`"
|
||||
class="flex items-center gap-1.5 text-[13px] text-danger"
|
||||
>
|
||||
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ errors.description.join(' ') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
data-action="cancel"
|
||||
:disabled="busy"
|
||||
class="inline-flex h-button flex-1 items-center justify-center rounded-control border border-border-strong bg-surface font-semibold hover:bg-hover disabled:cursor-not-allowed disabled:opacity-60"
|
||||
@click="setOpen(false)"
|
||||
>
|
||||
{{ t('backend::lang.form.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
data-action="save-caption"
|
||||
:disabled="busy"
|
||||
class="inline-flex h-button flex-1 items-center justify-center rounded-control bg-primary font-semibold text-on-primary hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{{ busy ? t('backend::lang.form.saving') : t('backend::lang.fileupload.details_submit') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="confirm.request.value !== null"
|
||||
:message="confirm.request.value?.message ?? ''"
|
||||
:confirm-label="confirm.request.value?.confirmLabel"
|
||||
:danger="confirm.request.value?.danger"
|
||||
@confirm="confirm.confirm"
|
||||
@cancel="confirm.cancel"
|
||||
/>
|
||||
</template>
|
||||
Reference in New Issue
Block a user