Files
summercms/admin/src/components/form/fields/FileCaptionModal.vue
Jakub Zych ea33296799 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
2026-10-02 19:22:22 +02:00

242 lines
8.7 KiB
Vue

<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>