- 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
242 lines
8.7 KiB
Vue
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>
|