feat: media picker and image removal

This commit is contained in:
fsylvain committed 2026-10-01 05:04:58 +02:00
1 parent 008b0dfbb9
commit b3793d5dbb
54 files changed
+3769 -1412

No files matched your search

+509 -228
View File
@@ -1,13 +1,21 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import MediaPicker from '@/components/MediaPicker.vue'
import MediaThumb from '@/components/MediaThumb.vue'
import { ApiError, useApi } from '@/composables/useApi'
import { useLibraryList } from '@/composables/useLibraryList'
import { classifyMediaError, type MediaItem } from '@/composables/useMedia'
import { oldestOf } from '@/composables/useMediaUpload'
import { hashFile } from '@/lib/fileHash'
import {
IMAGE_TYPE_REJECTION_TEXT,
formatUploadProgress,
isSizeRejectionBody,
rejectionTitle,
sizeRejectionText,
validateImageFile,
} from '@/lib/imageUpload'
import { libraryMatchText } from '@/lib/mediaFormat'
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
import {
ASPECT_RATIOS,
WIDTH_PRESETS,
@@ -35,17 +43,38 @@ import {
type Corner = 'tl' | 'tr' | 'bl' | 'br'
import { useImageUpload } from '@/composables/useImageUpload'
const props = defineProps<{
articleId: number
initialFile?: File | null
}>()
const props = withDefaults(
defineProps<{
initialFile?: File | null
inArticleIds?: number[]
}>(),
{ initialFile: null, inArticleIds: () => [] },
)
const emit = defineEmits<{
uploaded: [location: string, imageId: number]
uploaded: [item: MediaItem]
inserted: [item: MediaItem, alt: string]
close: []
}>()
type Phase = 'idle' | 'dragging-over' | 'crop' | 'uploading' | 'rejected' | 'failed'
type Phase = 'idle' | 'dragging-over' | 'duplicate' | 'crop' | 'uploading' | 'rejected' | 'failed'
type Tab = 'upload' | 'library'
const DROP_ZONE_UI = {
base: 'min-h-45 rounded-none border-accented p-4 text-sm',
wrapper: 'gap-2 p-0',
avatar: 'size-6 bg-transparent',
icon: 'size-6 text-toned',
label: 'mt-0 font-medium text-highlighted',
description: 'mt-0 max-w-75 text-xs/5 text-toned',
}
const FOOTER_BUTTON_CLASS = 'h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3'
const TAB_ITEMS = [
{ label: 'Upload', value: 'upload', icon: 'i-lucide-upload' },
{ label: 'Library', value: 'library', icon: 'i-lucide-images' },
]
const open = defineModel<boolean>('open', { default: false })
@@ -55,6 +84,11 @@ const lastUploadedFile = ref<File | null>(null)
const pickedFile = ref<File | null>(null)
const rejectionKind = ref<'type' | 'size' | null>(null)
const succeeded = ref(false)
const tab = ref<Tab>('library')
const existing = ref<MediaItem | null>(null)
const selected = ref<MediaItem | null>(null)
const alt = ref('')
let tabTouched = false
const naturalWidth = ref(0)
const naturalHeight = ref(0)
@@ -67,6 +101,8 @@ const previewCanvasRef = ref<HTMLCanvasElement | null>(null)
const sourceBitmap = ref<ImageBitmap | null>(null)
const { progress, start, abort } = useImageUpload()
const { request } = useApi()
const library = useLibraryList()
let closeTimer: ReturnType<typeof setTimeout> | null = null
const uploadPercent = computed(() => {
@@ -90,15 +126,43 @@ const rejectedText = computed(() => {
return IMAGE_TYPE_REJECTION_TEXT
})
const onLibraryTab = computed(() => tab.value === 'library')
const showTabs = computed(
() =>
onLibraryTab.value ||
phase.value === 'idle' ||
phase.value === 'dragging-over' ||
phase.value === 'duplicate',
)
const tabModel = computed({
get: () => tab.value,
set: (value: string) => selectTab(value === 'library' ? 'library' : 'upload'),
})
const duplicateText = computed(() =>
existing.value && chosenFile.value
? libraryMatchText(chosenFile.value.name, existing.value)
: '',
)
const cancelLabel = computed(() => (phase.value === 'uploading' ? 'Cancel upload' : 'Cancel'))
const showCancel = computed(
() =>
phase.value !== 'failed' &&
phase.value !== 'crop' &&
!(phase.value === 'uploading' && succeeded.value),
onLibraryTab.value ||
(phase.value !== 'failed' &&
phase.value !== 'crop' &&
!(phase.value === 'uploading' && succeeded.value)),
)
const showFailedActions = computed(() => !onLibraryTab.value && phase.value === 'failed')
const showRejectedActions = computed(() => !onLibraryTab.value && phase.value === 'rejected')
const showCropActions = computed(() => !onLibraryTab.value && phase.value === 'crop')
const wideModal = computed(() => showTabs.value || phase.value === 'crop')
const modalTitle = computed(() => (phase.value === 'crop' ? 'Crop and resize' : 'Add an image'))
const modalDescription = computed(() => {
@@ -110,11 +174,13 @@ const modalDescription = computed(() => {
const modalContentClass = computed(
() =>
`divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 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 ${
phase.value === 'crop' ? 'sm:max-w-160' : 'sm:max-w-120'
}`,
`divide-y-0 shadow-(--shadow-modal) ring-0 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 ${
showTabs.value ? 'sm:top-10' : 'sm:top-35'
} ${wideModal.value ? 'sm:max-w-160' : 'sm:max-w-120'}`,
)
const bodyClass = computed(() => (showTabs.value ? 'px-6 pt-4' : 'px-6 pt-5'))
const boundsWidth = computed(
() => rotatedDimensions(naturalWidth.value, naturalHeight.value, rotation.value).width,
)
@@ -164,20 +230,24 @@ function clearCloseTimer() {
async function runUpload(file: File) {
lastUploadedFile.value = file
const outcome = await start(props.articleId, file)
const outcome = await start(file)
if (outcome.kind === 'success') {
succeeded.value = true
emit('uploaded', outcome.location, outcome.id)
emit('uploaded', outcome.item)
closeTimer = setTimeout(() => {
open.value = false
}, 600)
return
}
if (outcome.kind === 'rejected') {
rejectionKind.value = isSizeRejectionBody(outcome.body) ? 'size' : 'type'
rejectionKind.value = outcome.reason
phase.value = 'rejected'
return
}
if (outcome.kind === 'handled') {
open.value = false
return
}
phase.value = 'failed'
}
@@ -244,7 +314,32 @@ async function openCropStep(file: File) {
await loadPreviewBitmap(file)
}
function handleFile(file: File) {
async function findExisting(file: File): Promise<MediaItem | null | 'handled'> {
const hash = await hashFile(file)
if (hash === null) {
return null
}
try {
const response = await request<{ items: MediaItem[] }>(`/v1/media/?hash=${hash}`)
return oldestOf(response.items) ?? null
} catch (error) {
if (error instanceof ApiError && classifyMediaError(error) === 'handled') {
return 'handled'
}
return null
}
}
function continueWithFile(file: File) {
if (file.type === 'image/svg+xml') {
phase.value = 'uploading'
runUpload(file)
return
}
openCropStep(file)
}
async function handleFile(file: File) {
chosenFile.value = file
const result = validateImageFile(file)
if (!result.ok) {
@@ -254,12 +349,75 @@ function handleFile(file: File) {
}
rejectionKind.value = null
succeeded.value = false
if (file.type === 'image/svg+xml') {
phase.value = 'uploading'
runUpload(file)
const match = await findExisting(file)
if (!open.value || chosenFile.value !== file) {
return
}
openCropStep(file)
if (match === 'handled') {
open.value = false
return
}
tab.value = 'upload'
if (match) {
existing.value = match
phase.value = 'duplicate'
return
}
continueWithFile(file)
}
function uploadAnyway() {
if (chosenFile.value) {
existing.value = null
continueWithFile(chosenFile.value)
}
}
function selectTab(next: Tab) {
tabTouched = true
tab.value = next
if (next === 'library') {
library.ensureLoaded()
}
}
function useExisting() {
if (!existing.value) {
return
}
selectItem(existing.value)
selectTab('library')
phase.value = 'idle'
existing.value = null
}
function selectItem(item: MediaItem) {
if (selected.value?.id === item.id) {
return
}
selected.value = item
alt.value = ''
}
async function searchLibrary(query: string) {
selected.value = null
alt.value = ''
await library.search(query)
}
function insertSelected() {
if (!selected.value) {
return
}
emit('inserted', selected.value, alt.value)
open.value = false
}
async function openLibraryFirst() {
await library.load()
if (!tabTouched && library.status.value === 'ready' && library.items.value.length === 0) {
tab.value = 'upload'
}
}
function onFileChange() {
@@ -291,6 +449,7 @@ function chooseAnotherFile() {
chosenFile.value = null
pickedFile.value = null
rejectionKind.value = null
existing.value = null
closeSourceBitmap()
}
@@ -517,9 +676,18 @@ watch(open, (isOpen) => {
pickedFile.value = null
rejectionKind.value = null
succeeded.value = false
existing.value = null
selected.value = null
alt.value = ''
tabTouched = false
library.reset()
if (props.initialFile) {
tab.value = 'upload'
handleFile(props.initialFile)
return
}
tab.value = 'library'
openLibraryFirst()
return
}
if (phase.value === 'uploading' && !succeeded.value) {
@@ -542,262 +710,375 @@ onUnmounted(() => {
:description="modalDescription"
:ui="{
content: modalContentClass,
header: 'min-h-12 items-start px-6 pt-5 pb-0 sm:px-6',
close: 'top-5 end-6',
title: 'text-lg/6 font-semibold',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
body: 'p-0 sm:p-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 v-if="showTabs" class="mt-4 border-b border-default px-6">
<UTabs
v-model="tabModel"
:items="TAB_ITEMS"
variant="link"
color="neutral"
:content="false"
class="-mb-px"
:ui="{
list: 'gap-5 border-b-0 p-0',
trigger: `${UNDERLINE_TAB_TRIGGER} max-md:text-sm`,
leadingIcon: 'size-3.5',
}"
/>
</div>
<div :class="bodyClass">
<MediaPicker
v-if="onLibraryTab"
v-model:alt="alt"
:items="library.items.value"
:status="library.status.value"
:show-skeleton="library.showSkeleton.value"
:loading-more="library.loadingMore.value"
:has-more="library.nextCursor.value !== null"
:total="library.total.value"
:query="library.query.value"
:first-new-id="library.firstNewId.value"
:in-article-ids="inArticleIds"
:selected="selected"
@search="searchLibrary"
@select="selectItem"
@load-more="library.loadMore"
@retry="library.load()"
@upload="selectTab('upload')"
/>
<div v-else-if="phase === 'crop'" class="flex flex-col gap-4">
<div
class="flex h-60 items-center justify-center bg-elevated p-4 sm:h-85"
data-testid="crop-stage"
v-else-if="phase === 'idle' || phase === 'dragging-over'"
data-testid="image-dropzone"
@dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDropOnZone"
>
<div
ref="imageBoxRef"
class="relative h-full max-w-full"
:style="{ aspectRatio: `${boundsWidth} / ${boundsHeight}` }"
:aria-label="`${chosenFile?.name}, ${boundsWidth} × ${boundsHeight} px`"
role="img"
<UFileUpload
v-model="pickedFile"
data-testid="image-file-input"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
:multiple="false"
layout="list"
icon="i-lucide-image-plus"
:reset="true"
:ui="DROP_ZONE_UI"
@change="onFileChange"
>
<div class="absolute inset-0 overflow-hidden bg-(--color-sand-200)">
<canvas ref="previewCanvasRef" class="absolute inset-0 h-full w-full" />
<template #label>
{{
phase === 'dragging-over'
? 'Drop to upload'
: 'Drop an image here, or choose a file'
}}
</template>
<template #description>PNG, JPEG, WEBP, SVG · max 10 MB</template>
</UFileUpload>
</div>
<div
v-else-if="phase === 'duplicate' && existing"
data-testid="duplicate-card"
class="flex flex-col gap-3 border border-info bg-default p-4"
>
<div class="flex items-center gap-3">
<MediaThumb
:item="existing"
size="sm"
class="h-13.5 w-18 border border-default"
/>
<div class="flex min-w-0 flex-col gap-0.5">
<p class="font-semibold text-highlighted">Already in your library</p>
<p class="text-xs text-toned">{{ duplicateText }}</p>
</div>
</div>
<div class="flex flex-col gap-2 md:flex-row">
<UButton class="h-11 justify-center md:h-8" @click="useExisting">
Use existing
</UButton>
<UButton
color="neutral"
variant="outline"
class="h-11 justify-center border border-accented bg-default text-highlighted ring-0 md:h-8"
@click="uploadAnyway"
>
Upload anyway
</UButton>
</div>
</div>
<div v-else-if="phase === 'crop'" class="flex flex-col gap-4">
<div
class="flex h-60 items-center justify-center bg-elevated p-4 sm:h-85"
data-testid="crop-stage"
>
<div
ref="imageBoxRef"
class="relative h-full max-w-full"
:style="{ aspectRatio: `${boundsWidth} / ${boundsHeight}` }"
:aria-label="`${chosenFile?.name}, ${boundsWidth} × ${boundsHeight} px`"
role="img"
>
<div class="absolute inset-0 overflow-hidden bg-(--color-sand-200)">
<canvas
ref="previewCanvasRef"
class="absolute inset-0 h-full w-full"
/>
<div
class="absolute"
:style="{
left: `${(rect.x / boundsWidth) * 100}%`,
top: `${(rect.y / boundsHeight) * 100}%`,
width: `${(rect.width / boundsWidth) * 100}%`,
height: `${(rect.height / boundsHeight) * 100}%`,
boxShadow: '0 0 0 2000px rgb(10 10 10 / 0.55)',
}"
/>
</div>
<div
class="absolute"
data-testid="crop-frame"
tabindex="0"
role="group"
aria-label="Crop area. Arrow keys move it, Shift and arrow keys resize it."
class="absolute cursor-move touch-none outline outline-white"
:style="{
left: `${(rect.x / boundsWidth) * 100}%`,
top: `${(rect.y / boundsHeight) * 100}%`,
width: `${(rect.width / boundsWidth) * 100}%`,
height: `${(rect.height / boundsHeight) * 100}%`,
boxShadow: '0 0 0 2000px rgb(10 10 10 / 0.55)',
}"
/>
@keydown="onFrameKeydown"
@mousedown="onFrameDragStart"
@touchstart="onFrameDragStart"
>
<div
class="pointer-events-none absolute top-0 bottom-0 w-px bg-white/45"
style="left: 33.333%"
/>
<div
class="pointer-events-none absolute top-0 bottom-0 w-px bg-white/45"
style="left: 66.666%"
/>
<div
class="pointer-events-none absolute left-0 right-0 h-px bg-white/45"
style="top: 33.333%"
/>
<div
class="pointer-events-none absolute left-0 right-0 h-px bg-white/45"
style="top: 66.666%"
/>
<div
v-for="corner in ['tl', 'tr', 'bl', 'br'] as Corner[]"
:key="corner"
data-testid="crop-handle"
class="absolute size-3 touch-none border border-ink-900 bg-white"
:class="{
'-top-1.5 -left-1.5 cursor-nwse-resize': corner === 'tl',
'-top-1.5 -right-1.5 cursor-nesw-resize': corner === 'tr',
'-bottom-1.5 -left-1.5 cursor-nesw-resize': corner === 'bl',
'-bottom-1.5 -right-1.5 cursor-nwse-resize':
corner === 'br',
}"
@mousedown="onHandleDragStart($event, corner)"
@touchstart="onHandleDragStart($event, corner)"
/>
<span
class="absolute bottom-2 left-2 bg-ink-900/80 px-1.5 py-px font-mono text-xs whitespace-nowrap text-white"
>
{{ dimsLabel }}
</span>
</div>
</div>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-1">
<span class="flex-1 font-medium text-highlighted">Aspect ratio</span>
<UButton
color="neutral"
variant="ghost"
size="sm"
icon="i-lucide-rotate-ccw-square"
@click="rotateImage"
>
Rotate
</UButton>
<UButton
color="neutral"
variant="ghost"
size="sm"
icon="i-lucide-undo-2"
@click="resetCrop"
>
Reset
</UButton>
</div>
<div
data-testid="crop-frame"
tabindex="0"
role="group"
aria-label="Crop area. Arrow keys move it, Shift and arrow keys resize it."
class="absolute cursor-move touch-none outline outline-white"
:style="{
left: `${(rect.x / boundsWidth) * 100}%`,
top: `${(rect.y / boundsHeight) * 100}%`,
width: `${(rect.width / boundsWidth) * 100}%`,
height: `${(rect.height / boundsHeight) * 100}%`,
}"
@keydown="onFrameKeydown"
@mousedown="onFrameDragStart"
@touchstart="onFrameDragStart"
role="radiogroup"
aria-label="Aspect ratio"
class="flex w-full border border-accented sm:w-max"
>
<div
class="pointer-events-none absolute top-0 bottom-0 w-px bg-white/45"
style="left: 33.333%"
/>
<div
class="pointer-events-none absolute top-0 bottom-0 w-px bg-white/45"
style="left: 66.666%"
/>
<div
class="pointer-events-none absolute left-0 right-0 h-px bg-white/45"
style="top: 33.333%"
/>
<div
class="pointer-events-none absolute left-0 right-0 h-px bg-white/45"
style="top: 66.666%"
/>
<div
v-for="corner in ['tl', 'tr', 'bl', 'br'] as Corner[]"
:key="corner"
data-testid="crop-handle"
class="absolute size-3 touch-none border border-ink-900 bg-white"
:class="{
'-top-1.5 -left-1.5 cursor-nwse-resize': corner === 'tl',
'-top-1.5 -right-1.5 cursor-nesw-resize': corner === 'tr',
'-bottom-1.5 -left-1.5 cursor-nesw-resize': corner === 'bl',
'-bottom-1.5 -right-1.5 cursor-nwse-resize': corner === 'br',
}"
@mousedown="onHandleDragStart($event, corner)"
@touchstart="onHandleDragStart($event, corner)"
/>
<span
class="absolute bottom-2 left-2 bg-ink-900/80 px-1.5 py-px font-mono text-xs whitespace-nowrap text-white"
<button
v-for="ratio in ASPECT_RATIOS"
:key="ratio"
type="button"
role="radio"
:aria-checked="ratioLabel === ratio"
:tabindex="ratioLabel === ratio ? 0 : -1"
class="flex-1 px-3 py-1.5 font-medium whitespace-nowrap sm:flex-none"
:class="
ratioLabel === ratio
? 'bg-primary text-inverted'
: 'bg-transparent text-toned'
"
@click="selectRatio(ratio)"
>
{{ dimsLabel }}
</span>
{{ ratio }}
</button>
</div>
</div>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-1">
<span class="flex-1 font-medium text-highlighted">Aspect ratio</span>
<UButton
color="neutral"
variant="ghost"
size="sm"
icon="i-lucide-rotate-ccw-square"
@click="rotateImage"
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">Width</span>
<div
role="radiogroup"
aria-label="Width"
class="flex w-full border border-accented sm:w-max"
>
Rotate
</UButton>
<UButton
color="neutral"
variant="ghost"
size="sm"
icon="i-lucide-undo-2"
@click="resetCrop"
>
Reset
</UButton>
<button
v-for="widthOption in WIDTH_PRESETS"
:key="widthOption"
type="button"
role="radio"
:aria-checked="widthLabel === widthOption"
:tabindex="widthLabel === widthOption ? 0 : -1"
class="flex-1 px-3 py-1.5 font-medium whitespace-nowrap sm:flex-none"
:class="
widthLabel === widthOption
? 'bg-primary text-inverted'
: 'bg-transparent text-toned'
"
@click="selectWidth(widthOption)"
>
{{ widthOption }}
</button>
</div>
<span class="font-mono text-xs text-muted">{{ monoLine }}</span>
</div>
<div
role="radiogroup"
aria-label="Aspect ratio"
class="flex w-full border border-accented sm:w-max"
v-if="showNarrowNote"
role="status"
class="flex gap-2 bg-(--rc-info-soft) p-2.5"
>
<button
v-for="ratio in ASPECT_RATIOS"
:key="ratio"
type="button"
role="radio"
:aria-checked="ratioLabel === ratio"
:tabindex="ratioLabel === ratio ? 0 : -1"
class="flex-1 px-3 py-1.5 font-medium whitespace-nowrap sm:flex-none"
:class="
ratioLabel === ratio
? 'bg-primary text-inverted'
: 'bg-transparent text-toned'
"
@click="selectRatio(ratio)"
>
{{ ratio }}
</button>
<UIcon name="i-lucide-info" class="mt-0.5 size-4 shrink-0 text-info" />
<span class="text-toned">{{ narrowNoteText }}</span>
</div>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">Width</span>
<div v-else-if="phase === 'uploading'" class="flex flex-col gap-3">
<div
role="radiogroup"
aria-label="Width"
class="flex w-full border border-accented sm:w-max"
class="flex flex-col items-center gap-3 rounded-md border border-default px-6 py-8 text-center"
>
<button
v-for="widthOption in WIDTH_PRESETS"
:key="widthOption"
type="button"
role="radio"
:aria-checked="widthLabel === widthOption"
:tabindex="widthLabel === widthOption ? 0 : -1"
class="flex-1 px-3 py-1.5 font-medium whitespace-nowrap sm:flex-none"
:class="
widthLabel === widthOption
? 'bg-primary text-inverted'
: 'bg-transparent text-toned'
"
@click="selectWidth(widthOption)"
>
{{ widthOption }}
</button>
<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>
<span class="font-mono text-xs text-muted">{{ monoLine }}</span>
</div>
<div
v-if="showNarrowNote"
role="status"
class="flex gap-2 bg-(--rc-info-soft) p-2.5"
>
<UIcon name="i-lucide-info" class="mt-0.5 size-4 shrink-0 text-info" />
<span class="text-toned">{{ narrowNoteText }}</span>
</div>
</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 v-if="!succeeded" class="text-sm text-toned">
{{ formatUploadProgress(progress.loaded, progress.total) }}
</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">
{{ formatUploadProgress(progress.loaded, progress.total) }}
</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 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>
<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 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>
</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">
<UButton
v-if="showCancel"
color="neutral"
variant="outline"
block
:class="FOOTER_BUTTON_CLASS"
class="text-highlighted"
@click="cancel"
>
{{ cancelLabel }}
</UButton>
<UButton v-if="phase === 'rejected'" @click="chooseAnotherFile">
<UButton
v-if="onLibraryTab"
block
:class="FOOTER_BUTTON_CLASS"
:disabled="!selected"
@click="insertSelected"
>
Insert image
</UButton>
<UButton
v-if="showRejectedActions"
block
:class="FOOTER_BUTTON_CLASS"
@click="chooseAnotherFile"
>
Choose another file
</UButton>
<UButton v-if="phase === 'failed'" @click="tryAgain"> Try again </UButton>
<template v-if="phase === 'crop'">
<UButton color="neutral" variant="outline" @click="chooseAnotherFile">
<UButton
v-if="showFailedActions"
block
:class="FOOTER_BUTTON_CLASS"
@click="tryAgain"
>
Try again
</UButton>
<template v-if="showCropActions">
<UButton
color="neutral"
variant="outline"
block
:class="FOOTER_BUTTON_CLASS"
class="text-highlighted"
@click="chooseAnotherFile"
>
Choose another file
</UButton>
<UButton @click="confirmCropUpload">Upload image</UButton>
<UButton block :class="FOOTER_BUTTON_CLASS" @click="confirmCropUpload">
Upload image
</UButton>
</template>
</div>
</template>