feat: image upload on articles

This commit is contained in:
fsylvain committed 2026-09-28 02:28:20 +02:00
1 parent d4fcfb2bba
commit 938263cd6a
12 files changed
+1436 -65

No files matched your search

+263
View File
@@ -0,0 +1,263 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
IMAGE_TYPE_REJECTION_TEXT,
formatMegabytes,
isSizeRejectionBody,
rejectionTitle,
sizeRejectionText,
validateImageFile,
} from '@/lib/imageUpload'
import { useImageUpload } from '@/composables/useImageUpload'
const props = defineProps<{
articleId: number
initialFile?: File | null
}>()
const emit = defineEmits<{
uploaded: [location: string]
close: []
}>()
type Phase = 'idle' | 'dragging-over' | 'uploading' | 'rejected' | 'failed'
const open = defineModel<boolean>('open', { default: false })
const phase = ref<Phase>('idle')
const chosenFile = ref<File | null>(null)
const pickedFile = ref<File | null>(null)
const rejectionKind = ref<'type' | 'size' | null>(null)
const succeeded = ref(false)
const { progress, start, abort } = useImageUpload()
let closeTimer: ReturnType<typeof setTimeout> | null = null
const uploadPercent = computed(() => {
if (progress.value.total === 0) {
return 0
}
return Math.round((progress.value.loaded / progress.value.total) * 100)
})
const rejectedTitle = computed(() =>
chosenFile.value ? rejectionTitle(chosenFile.value.name) : '',
)
const rejectedText = computed(() => {
if (!chosenFile.value) {
return ''
}
if (rejectionKind.value === 'size') {
return sizeRejectionText(chosenFile.value.name, chosenFile.value.size)
}
return IMAGE_TYPE_REJECTION_TEXT
})
const cancelLabel = computed(() => (phase.value === 'uploading' ? 'Cancel upload' : 'Cancel'))
const showCancel = computed(
() => phase.value !== 'failed' && !(phase.value === 'uploading' && succeeded.value),
)
function clearCloseTimer() {
if (closeTimer !== null) {
clearTimeout(closeTimer)
closeTimer = null
}
}
async function runUpload(file: File) {
const outcome = await start(props.articleId, file)
if (outcome.kind === 'success') {
succeeded.value = true
emit('uploaded', outcome.location)
closeTimer = setTimeout(() => {
open.value = false
}, 600)
return
}
if (outcome.kind === 'rejected') {
rejectionKind.value = isSizeRejectionBody(outcome.body) ? 'size' : 'type'
phase.value = 'rejected'
return
}
phase.value = 'failed'
}
function handleFile(file: File) {
chosenFile.value = file
const result = validateImageFile(file)
if (!result.ok) {
rejectionKind.value = result.kind
phase.value = 'rejected'
return
}
rejectionKind.value = null
succeeded.value = false
phase.value = 'uploading'
runUpload(file)
}
function onFileChange() {
if (pickedFile.value) {
handleFile(pickedFile.value)
}
}
function onDragOver() {
if (phase.value === 'idle') {
phase.value = 'dragging-over'
}
}
function onDragLeave() {
if (phase.value === 'dragging-over') {
phase.value = 'idle'
}
}
function onDropOnZone() {
if (phase.value === 'dragging-over') {
phase.value = 'idle'
}
}
function chooseAnotherFile() {
phase.value = 'idle'
chosenFile.value = null
pickedFile.value = null
rejectionKind.value = null
}
function tryAgain() {
if (!chosenFile.value) {
return
}
succeeded.value = false
phase.value = 'uploading'
runUpload(chosenFile.value)
}
function cancel() {
open.value = false
}
watch(open, (isOpen) => {
if (isOpen) {
phase.value = 'idle'
chosenFile.value = null
pickedFile.value = null
rejectionKind.value = null
succeeded.value = false
if (props.initialFile) {
handleFile(props.initialFile)
}
return
}
if (phase.value === 'uploading' && !succeeded.value) {
abort()
}
clearCloseTimer()
emit('close')
})
</script>
<template>
<UModal
v-model:open="open"
title="Add an image"
:ui="{
content:
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-120 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
title: 'text-lg/6 font-semibold',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
}"
>
<template #body>
<div
v-if="phase === 'idle' || phase === 'dragging-over'"
data-testid="image-dropzone"
@dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDropOnZone"
>
<UFileUpload
v-model="pickedFile"
data-testid="image-file-input"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
:multiple="false"
layout="list"
:reset="true"
@change="onFileChange"
>
<template #label>
{{ phase === 'dragging-over' ? 'Drop to upload' : 'Drop an image here' }}
</template>
<template #description>PNG, JPEG, WEBP, SVG · max 10 MB</template>
<template #actions="{ open: openDialog }">
<UButton color="neutral" variant="outline" @click="openDialog()">
Choose file
</UButton>
</template>
</UFileUpload>
</div>
<div v-else-if="phase === 'uploading'" class="flex flex-col gap-3">
<div
class="flex flex-col items-center gap-3 rounded-md border border-default px-6 py-8 text-center"
>
<UIcon
:name="succeeded ? 'i-lucide-circle-check' : 'i-lucide-image-plus'"
:class="succeeded ? 'size-6 text-success' : 'size-6 text-dimmed'"
/>
<p class="text-sm text-highlighted">
<template v-if="succeeded">Image added</template>
<template v-else>{{ chosenFile?.name }} {{ uploadPercent }}%</template>
</p>
<UProgress
v-if="!succeeded"
:model-value="uploadPercent"
size="xs"
class="w-full"
role="progressbar"
:aria-valuenow="uploadPercent"
/>
</div>
<p v-if="!succeeded" class="text-sm text-toned">
Uploading {{ formatMegabytes(progress.loaded) }} of
{{ formatMegabytes(progress.total) }} MB
</p>
</div>
<div v-else-if="phase === 'rejected'" class="flex flex-col gap-1">
<div
class="flex flex-col items-center gap-3 rounded-md border border-error px-6 py-8 text-center"
>
<UIcon name="i-lucide-file-x" class="size-6 text-error" />
<p class="text-sm font-semibold text-highlighted">{{ rejectedTitle }}</p>
<p class="text-sm text-toned">{{ rejectedText }}</p>
</div>
</div>
<div v-else-if="phase === 'failed'" class="flex flex-col gap-1">
<p class="text-sm font-semibold text-highlighted">Upload failed</p>
<p class="text-sm text-toned">
The server didn't accept the file. Your article hasn't changed.
</p>
</div>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton v-if="showCancel" color="neutral" variant="outline" @click="cancel">
{{ cancelLabel }}
</UButton>
<UButton v-if="phase === 'rejected'" @click="chooseAnotherFile">
Choose another file
</UButton>
<UButton v-if="phase === 'failed'" @click="tryAgain"> Try again </UButton>
</div>
</template>
</UModal>
</template>