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

+2 -1
View File
@@ -2,6 +2,7 @@
import { computed } from 'vue'
import { useToast } from '@nuxt/ui/composables'
import type { Article } from '@/composables/useArticles'
import { bodyImageCount } from '@/lib/bodyImageIds'
const props = defineProps<{ article: Article }>()
@@ -50,7 +51,7 @@ function formatDetailDate(iso: string, relative = false) {
const createdLabel = computed(() => formatDetailDate(props.article.created_at))
const updatedLabel = computed(() => formatDetailDate(props.article.updated_at, true))
const publicUrl = computed(() => `/v1/article/${props.article.id}`)
const imagesCount = computed(() => props.article.images.length)
const imagesCount = computed(() => bodyImageCount(props.article.body))
const apiNote = computed(() =>
props.article.is_online
? 'Live. Your website can read this article.'
+12
View File
@@ -208,6 +208,18 @@ defineExpose({
insertImage(src: string, alt: string, imageId: number) {
editorInstance.value?.commands.setArticleImage({ src, alt, imageId })
},
imageSrc(imageId: number): string | null {
const editor = editorInstance.value
if (!editor) {
return null
}
const pos = findArticleImagePos(editor, imageId)
if (pos === null) {
return null
}
const src = editor.state.doc.nodeAt(pos)?.attrs.src
return typeof src === 'string' ? src : null
},
removeImage(imageId: number) {
const editor = editorInstance.value
if (!editor) {
@@ -41,13 +41,14 @@ function onDeleteClick() {
v-if="showDeleteButton"
data-testid="remove-image-button"
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="xs"
class="absolute top-1 right-1 bg-default"
aria-label="Remove image"
color="neutral"
variant="outline"
class="absolute top-2 right-2 h-7 gap-1.5 border border-accented bg-default px-2.5 text-xs font-medium text-highlighted ring-0"
:ui="{ leadingIcon: 'size-3.5' }"
@mousedown.prevent
@click="onDeleteClick"
/>
>
Remove image
</UButton>
</NodeViewWrapper>
</template>
+29
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import MediaThumb from '@/components/MediaThumb.vue'
const props = withDefaults(
defineProps<{
@@ -11,6 +12,7 @@ const props = withDefaults(
tone: 'danger' | 'neutral'
action: () => Promise<void | boolean>
dismissResolvesTo?: 'cancel' | 'confirm'
preview?: { src: string; filename?: string; size?: string }
}>(),
{
dismissResolvesTo: 'cancel',
@@ -25,6 +27,14 @@ const error = ref('')
const cancelButton = ref<{ $el: HTMLElement } | null>(null)
let settled = false
const previewDetail = computed(() =>
props.preview?.size ? `Stays in the library · ${props.preview.size}` : 'Stays in the library',
)
const previewThumb = computed(() => ({
url: props.preview?.src ?? '',
content_type: '',
sha256: '',
}))
const confirmColor = computed(() => (props.tone === 'danger' ? 'error' : 'primary'))
const confirmText = computed(() => (pending.value ? props.pendingLabel : props.confirmLabel))
@@ -107,6 +117,25 @@ function onCancel() {
role="alert"
:ui="{ root: 'gap-2 px-3 py-2.5', description: 'text-sm text-toned' }"
/>
<div
v-if="preview"
data-testid="confirm-preview"
class="flex items-center gap-3 border border-default px-3 py-2.5"
>
<MediaThumb
:item="previewThumb"
size="sm"
class="h-10.5 w-14 border border-default"
/>
<div class="flex min-w-0 flex-col">
<span
v-if="preview.filename"
class="truncate font-medium text-highlighted"
>{{ preview.filename }}</span
>
<span class="text-xs/5 text-muted">{{ previewDetail }}</span>
</div>
</div>
<slot name="body" :pending="pending" :confirm="onConfirm" />
</div>
</template>
+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>
+337
View File
@@ -0,0 +1,337 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import MediaThumb from '@/components/MediaThumb.vue'
import type { LibraryStatus } from '@/composables/useLibraryList'
import type { MediaItem } from '@/composables/useMedia'
import { accessibleName, formatDimensions, formatSize } from '@/lib/mediaFormat'
const SEARCH_DEBOUNCE_MS = 120
const DESKTOP_QUERY = '(min-width: 48rem)'
const FIRST_LOAD_SKELETONS_DESKTOP = 8
const FIRST_LOAD_SKELETONS_MOBILE = 9
const COLUMNS_DESKTOP = 4
const COLUMNS_MOBILE = 3
const SEARCH_ICON_SIZE = 'size-3.5'
const EMPTY_STATE_UI = {
root: 'gap-3 border border-default bg-default px-6 py-8',
header: 'gap-3',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-[0.9375rem] font-semibold',
description: 'max-w-80 text-muted text-pretty',
}
const props = defineProps<{
items: MediaItem[]
status: LibraryStatus
showSkeleton: boolean
loadingMore: boolean
hasMore: boolean
total: number
query: string
firstNewId: number | null
inArticleIds: number[]
selected: MediaItem | null
}>()
const emit = defineEmits<{
search: [query: string]
select: [item: MediaItem]
'load-more': []
retry: []
upload: []
}>()
const alt = defineModel<string>('alt', { default: '' })
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const grid = ref<HTMLElement | null>(null)
const searchText = ref(props.query)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const columns = computed(() => (isDesktop.value ? COLUMNS_DESKTOP : COLUMNS_MOBILE))
const firstLoadSkeletons = computed(() =>
isDesktop.value ? FIRST_LOAD_SKELETONS_DESKTOP : FIRST_LOAD_SKELETONS_MOBILE,
)
const searching = computed(() => props.query !== '')
const summary = computed(() =>
searching.value ? 'Newest first' : `Newest first · ${props.total} images`,
)
const scrollHeight = computed(() => (props.selected ? 'max-h-32 md:max-h-49.5' : 'max-h-77'))
const showEmptyLibrary = computed(
() => props.status === 'ready' && props.items.length === 0 && !searching.value,
)
const showNoResults = computed(
() => props.status === 'ready' && props.items.length === 0 && searching.value,
)
const showGrid = computed(() => props.status === 'ready' && props.items.length > 0)
const showLoadMore = computed(() => showGrid.value && props.hasMore && !props.selected)
const noResultsTitle = computed(() => `No images match "${props.query}"`)
function clearDebounce() {
if (debounceTimer !== null) {
clearTimeout(debounceTimer)
debounceTimer = null
}
}
watch(searchText, (value) => {
clearDebounce()
debounceTimer = setTimeout(() => {
debounceTimer = null
emit('search', value.trim())
}, SEARCH_DEBOUNCE_MS)
})
function clearSearch() {
clearDebounce()
searchText.value = ''
emit('search', '')
}
function isSelected(item: MediaItem) {
return props.selected?.id === item.id
}
function isInArticle(item: MediaItem) {
return props.inArticleIds.includes(item.id)
}
function onTileKeydown(event: KeyboardEvent, item: MediaItem) {
if (event.key !== 'Enter' && event.key !== ' ') {
return
}
event.preventDefault()
if (!event.repeat) {
emit('select', item)
}
}
function onTileKeyup(event: KeyboardEvent) {
if (event.key === ' ') {
event.preventDefault()
}
}
watch(
() => props.firstNewId,
async (id) => {
if (id === null) {
return
}
await nextTick()
grid.value?.querySelector<HTMLElement>(`[data-image-id="${id}"]`)?.focus()
},
)
onBeforeUnmount(clearDebounce)
</script>
<template>
<div class="flex min-h-0 flex-col gap-3">
<UInput
v-model="searchText"
type="text"
icon="i-lucide-search"
placeholder="Search by filename"
aria-label="Search by filename"
class="w-full"
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm', leadingIcon: SEARCH_ICON_SIZE }"
>
<template v-if="searchText" #trailing>
<UButton
color="neutral"
variant="link"
size="xs"
icon="i-lucide-x"
aria-label="Clear search"
class="text-muted"
@click="clearSearch"
/>
</template>
</UInput>
<UAlert
v-if="status === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
title="Couldn't load your library"
description="Check your connection and try again."
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('retry')"
>
Try again
</UButton>
</template>
</UAlert>
<UEmpty
v-else-if="showEmptyLibrary"
variant="naked"
icon="i-lucide-images"
title="Your library is empty"
description="Images you upload appear here, ready to use in any article."
:ui="EMPTY_STATE_UI"
>
<template #actions>
<UButton icon="i-lucide-upload" class="h-11 px-4 md:h-8" @click="emit('upload')">
Upload an image
</UButton>
</template>
</UEmpty>
<UEmpty
v-else-if="showNoResults"
variant="naked"
icon="i-lucide-search"
:title="noResultsTitle"
description="Check the spelling, or search for a shorter part of the filename."
:ui="EMPTY_STATE_UI"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-4 text-highlighted ring-0 md:h-8"
@click="clearSearch"
>
Clear search
</UButton>
</template>
</UEmpty>
<div
v-else-if="status === 'loading' && showSkeleton"
aria-busy="true"
class="grid grid-cols-3 gap-x-2 gap-y-3 md:grid-cols-4 md:gap-x-3"
>
<div
v-for="n in firstLoadSkeletons"
:key="n"
data-testid="picker-skeleton-tile"
class="flex flex-col gap-1.5"
>
<USkeleton class="aspect-square w-full rounded-none bg-accented" />
<USkeleton class="h-2.5 w-[70%] rounded-none bg-accented" />
</div>
</div>
<template v-else-if="showGrid">
<span data-testid="picker-summary" class="text-xs/5 text-muted">{{ summary }}</span>
<div class="-m-1 min-h-0 overflow-y-auto p-1" :class="scrollHeight">
<div ref="grid" class="grid grid-cols-3 gap-x-2 gap-y-3 md:grid-cols-4 md:gap-x-3">
<button
v-for="item in items"
:key="item.id"
type="button"
data-picker-tile
:data-image-id="item.id"
:aria-label="accessibleName(item)"
:aria-pressed="isSelected(item)"
class="group flex min-w-0 flex-col gap-1.5 text-start focus-visible:outline-none"
@click="emit('select', item)"
@keydown="onTileKeydown($event, item)"
@keyup="onTileKeyup"
>
<span
class="relative block w-full group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-primary"
:class="
isSelected(item) ? 'outline-2 outline-offset-2 outline-primary' : ''
"
>
<MediaThumb
:item="item"
size="md"
class="aspect-square w-full border border-default"
/>
<span
v-if="isInArticle(item)"
class="absolute bottom-1 left-1 inline-flex h-4.5 items-center bg-secondary-200 px-1.25 text-[0.6875rem] font-medium text-primary-900 dark:bg-secondary-300"
>In this article</span
>
<span
v-if="isSelected(item)"
data-testid="picker-check"
class="absolute top-1 right-1 flex size-5 items-center justify-center bg-primary text-inverted"
>
<UIcon name="i-lucide-check" class="size-3.5" />
</span>
</span>
<span class="truncate text-xs/5 text-toned">{{ item.filename }}</span>
</button>
<template v-if="loadingMore && showSkeleton">
<div
v-for="n in columns"
:key="`skeleton-${n}`"
data-testid="picker-skeleton-tile"
class="flex flex-col gap-1.5"
>
<USkeleton class="aspect-square w-full rounded-none bg-accented" />
<USkeleton class="h-2.5 w-[70%] rounded-none bg-accented" />
</div>
</template>
</div>
</div>
<div v-if="showLoadMore" class="flex justify-center">
<UButton
color="neutral"
variant="outline"
:icon="loadingMore ? 'i-lucide-loader-circle' : undefined"
:aria-disabled="loadingMore || undefined"
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 aria-disabled:opacity-40 md:h-8"
:ui="{ leadingIcon: loadingMore ? 'size-4 animate-spin' : 'size-4' }"
@click="emit('load-more')"
>
{{ loadingMore ? 'Loading…' : 'Load more' }}
</UButton>
</div>
</template>
<div
v-if="selected"
data-testid="picker-panel"
class="flex flex-col gap-3 border-t border-default pt-3"
>
<div class="flex items-center gap-3">
<MediaThumb :item="selected" size="sm" class="size-14 border border-default" />
<div class="flex min-w-0 flex-col">
<span class="truncate font-medium text-highlighted">{{
selected.filename
}}</span>
<span class="text-xs/5 text-muted"
>{{ formatDimensions(selected.width, selected.height) }} ·
{{ formatSize(selected.size) }}</span
>
</div>
</div>
<UFormField
label="Alt text (optional)"
help="Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration."
:ui="{
label: 'font-medium text-highlighted',
help: 'text-xs/5 text-muted',
}"
>
<UInput
v-model="alt"
type="text"
placeholder="e.g. North wall of the kitchen before plastering"
class="w-full"
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
/>
</UFormField>
</div>
</div>
</template>
+2 -4
View File
@@ -2,12 +2,10 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import type { MediaCounts } from '@/composables/useMedia'
import type { MediaControls, MediaFilter, MediaSort, MediaView } from '@/lib/mediaQuery'
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
const SEARCH_DEBOUNCE_MS = 120
const TAB_TRIGGER =
'h-9 px-0 py-0 data-[state=inactive]:text-toned in-[[data-slot=list]:not(:has([data-slot=indicator]))]:data-[state=active]:after:bottom-0 in-[[data-slot=list]:not(:has([data-slot=indicator]))]:data-[state=active]:after:h-0.5 max-md:h-11 max-md:text-[0.9375rem]'
const SORT_LABELS: Record<MediaSort, string> = {
newest: 'Newest',
oldest: 'Oldest',
@@ -107,7 +105,7 @@ const sortItems = computed(() =>
class="order-2 md:order-none md:-mb-px md:flex-1"
:ui="{
list: 'gap-5 border-b-0 p-0 max-md:border-b',
trigger: TAB_TRIGGER,
trigger: UNDERLINE_TAB_TRIGGER,
}"
>
<template #default="{ item }">
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import ArticleDetails from '../ArticleDetails.vue'
import type { Article } from '@/composables/useArticles'
afterEach(() => {
document.body.innerHTML = ''
})
function mountDetails(article: Omit<Article, 'images'>) {
return mount(ArticleDetails, {
props: { article: article as Article },
global: { plugins: [uiPlugin] },
attachTo: document.body,
})
}
const baseArticle = {
id: 3,
title: 'Kitchen',
body: '<p>a</p>',
is_online: false,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-09-26T11:02:00Z',
}
describe('An article from the API carries no images field', () => {
it('renders the details with an images count read from the body', () => {
const wrapper = mountDetails({
...baseArticle,
body: '<p>a</p><img src="/a.png"><img src="/b.png">',
})
expect(wrapper.text()).toContain('Images')
expect(wrapper.text()).toContain('2')
wrapper.unmount()
})
it('counts no image for a body without any', () => {
const wrapper = mountDetails(baseArticle)
expect(wrapper.text()).toContain('Images')
wrapper.unmount()
})
})
@@ -34,7 +34,6 @@ async function mountArticleEditor(body = '<p>Hello</p>') {
id: 12,
title: 'T',
body,
images: [],
is_online: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
@@ -106,3 +105,17 @@ describe('removeImage removes only the node whose data-image-id matches', () =>
expect(editor.emitted('update:modelValue')).toBeFalsy()
})
})
describe('imageSrc reads the src of the node whose data-image-id matches', () => {
it('returns the src of the matching image and null for an unknown id', async () => {
const wrapper = await mountArticleEditor(
'<p>Hello</p><img src="/static/uploadedImages/a.png" alt="" data-image-id="10">',
)
const vm = wrapper.findComponent(ArticleEditor).vm as unknown as {
imageSrc: (id: number) => string | null
}
expect(vm.imageSrc(10)).toBe('/static/uploadedImages/a.png')
expect(vm.imageSrc(999)).toBeNull()
})
})
@@ -74,14 +74,9 @@ describe('Cancelling — the action is in flight and the dialog cannot be dismis
await clickButton('Cancel registration')
expect(action).toHaveBeenCalledTimes(1)
expect(
(findButtonElement('Keep my account') as HTMLButtonElement)
.disabled,
).toBe(true)
expect((findButtonElement('Keep my account') as HTMLButtonElement).disabled).toBe(true)
expect(findButtonElement('Cancelling…')).toBeTruthy()
expect(
(findButtonElement('Cancelling…') as HTMLButtonElement).disabled,
).toBe(true)
expect((findButtonElement('Cancelling…') as HTMLButtonElement).disabled).toBe(true)
expect(document.querySelector('[aria-busy="true"]')).toBeTruthy()
resolveAction?.()
@@ -163,26 +158,60 @@ describe('dismissResolvesTo="confirm" makes Esc/overlay resolve as confirmed', (
describe('Cancel failed — the dialog stays open and shows the failure inline', () => {
it('re-enables both buttons and shows the rejection message inline', async () => {
const action = vi.fn(() =>
Promise.reject(new Error("Couldn't cancel registration.")),
)
const action = vi.fn(() => Promise.reject(new Error("Couldn't cancel registration.")))
const wrapper = mountDialog(action)
await flushPromises()
await clickButton('Cancel registration')
expect(wrapper.emitted('close')).toBeFalsy()
expect(document.body.textContent).toContain(
"Couldn't cancel registration.",
)
expect(
(findButtonElement('Keep my account') as HTMLButtonElement)
.disabled,
).toBe(false)
expect(
(findButtonElement('Cancel registration') as HTMLButtonElement)
.disabled,
).toBe(false)
expect(document.body.textContent).toContain("Couldn't cancel registration.")
expect((findButtonElement('Keep my account') as HTMLButtonElement).disabled).toBe(false)
expect((findButtonElement('Cancel registration') as HTMLButtonElement).disabled).toBe(false)
wrapper.unmount()
})
})
describe('A neutral confirm with an image preview', () => {
function mountPreview(preview: { src: string; filename?: string; size?: string }) {
return mount(ConfirmDialog, {
attachTo: document.body,
props: {
title: 'Remove this image from the article?',
description: 'It stays in your library.',
confirmLabel: 'Remove image',
cancelLabel: 'Cancel',
pendingLabel: 'Removing…',
tone: 'neutral',
preview,
action: async () => {},
},
global: { plugins: [uiPlugin] },
})
}
it('shows the thumbnail, the filename and the size, and a primary confirm', async () => {
const wrapper = mountPreview({
src: '/media/north-wall.jpg',
filename: 'north-wall.jpg',
size: '3.9 MB',
})
await flushPromises()
const row = document.body.querySelector('[data-testid="confirm-preview"]')!
expect(row.textContent).toContain('north-wall.jpg')
expect(row.textContent).toContain('Stays in the library · 3.9 MB')
expect(row.querySelector('img')?.getAttribute('src')).toBe('/media/north-wall.jpg')
expect(findButtonElement('Remove image')?.className).not.toContain('bg-error')
wrapper.unmount()
})
it('shows the thumbnail and "Stays in the library" for an image the session has not seen', async () => {
const wrapper = mountPreview({ src: '/media/seven.jpg' })
await flushPromises()
const row = document.body.querySelector('[data-testid="confirm-preview"]')!
expect(row.textContent?.replace(/\s+/g, ' ').trim()).toBe('Stays in the library')
wrapper.unmount()
})
})
+368 -164
View File
@@ -1,16 +1,44 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import ImageUploader from '../ImageUploader.vue'
import { nextTick } from 'vue'
import * as fileHash from '@/lib/fileHash'
import * as imageUpload from '@/lib/imageUpload'
import { ApiError, NetworkError, redirectToLogin, redirectUnverified } from '@/composables/useApi'
import { fixture } from '@/views/__tests__/mediaTestKit'
import {
altField,
apiKit,
click,
dialogText,
findButton,
hashCalls,
installApi,
installEmptyLibrary,
listCalls,
modalOpen,
mountUploader,
openUploader,
pageBody,
selectedTab,
settle,
tabButtons,
textOf,
} from './pickerKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
redirectToLogin: vi.fn(),
redirectUnverified: vi.fn(),
}
})
vi.mock('@/lib/fileHash', () => ({ hashFile: vi.fn() }))
vi.mock('@/lib/imageUpload', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/imageUpload')>()
return {
...actual,
validateImageFile: vi.fn(actual.validateImageFile),
}
return { ...actual, validateImageFile: vi.fn(actual.validateImageFile) }
})
vi.mock('@/lib/imageCrop', async (importOriginal) => {
@@ -22,6 +50,8 @@ vi.mock('@/lib/imageCrop', async (importOriginal) => {
}
})
const NORTH_WALL_HASH = 'a'.repeat(64)
class FakeXMLHttpRequest {
static instances: FakeXMLHttpRequest[] = []
@@ -88,7 +118,7 @@ class FakeXMLHttpRequest {
}
function latestXHR() {
return FakeXMLHttpRequest.instances[FakeXMLHttpRequest.instances.length - 1]
return FakeXMLHttpRequest.instances[FakeXMLHttpRequest.instances.length - 1]!
}
function mib(value: number) {
@@ -101,38 +131,6 @@ function fileFixture(name: string, type: string, sizeMB: number) {
return file
}
const Host = defineComponent({
components: { ImageUploader },
props: {
articleId: { type: Number, required: true },
initialFile: { type: Object as () => File | null, default: null },
},
emits: ['uploaded', 'close'],
setup(_props, { emit }) {
const open = ref(false)
return {
open,
onUploaded: (location: string, imageId: number) => emit('uploaded', location, imageId),
onClose: () => emit('close'),
}
},
template: `<ImageUploader v-model:open="open" :article-id="articleId" :initial-file="initialFile" @uploaded="onUploaded" @close="onClose" />`,
})
function mountUploader(props: { articleId?: number; initialFile?: File | null } = {}) {
return mount(Host, {
props: { articleId: 12, initialFile: null, ...props },
global: { plugins: [uiPlugin] },
attachTo: document.body,
})
}
async function openModal(wrapper: ReturnType<typeof mountUploader>) {
;(wrapper.vm as unknown as { open: boolean }).open = true
await nextTick()
await nextTick()
}
function fileInput() {
return document.body.querySelector<HTMLInputElement>('[data-testid="image-file-input"]')!
}
@@ -149,21 +147,11 @@ async function chooseFile(file: File) {
})
input.dispatchEvent(new Event('change'))
await nextTick()
await nextTick()
await flushPromises()
}
function findButton(text: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === text,
)
await settle()
}
async function clickButton(text: string) {
findButton(text)?.click()
await nextTick()
await nextTick()
await flushPromises()
await click(findButton(text))
}
async function chooseRasterFileAndConfirmUpload(file: File) {
@@ -171,47 +159,78 @@ async function chooseRasterFileAndConfirmUpload(file: File) {
await clickButton('Upload image')
}
function mediaItem(overrides: Record<string, unknown> = {}) {
return { ...fixture([1])[0]!, ...overrides }
}
let wrapper: ReturnType<typeof mountUploader> | null = null
async function openOnUpload(routes: Parameters<typeof installEmptyLibrary>[0] = {}) {
installEmptyLibrary(routes)
wrapper = mountUploader()
await openUploader(wrapper)
}
const RealXMLHttpRequest = global.XMLHttpRequest
beforeEach(() => {
FakeXMLHttpRequest.instances = []
global.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest
vi.mocked(fileHash.hashFile).mockResolvedValue(NORTH_WALL_HASH)
vi.mocked(redirectToLogin).mockClear()
vi.mocked(redirectUnverified).mockClear()
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
document.body.innerHTML = ''
global.XMLHttpRequest = RealXMLHttpRequest
})
describe('Opening the uploader from the toolbar image button shows the idle state', () => {
it('shows the idle copy and sends no request', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
describe("The toolbar's image button opens on Upload when the library is empty", () => {
it('shows both tabs, the drop zone copy and a footer with Cancel only', async () => {
await openOnUpload()
expect(document.body.textContent).toContain('Add an image')
expect(document.body.textContent).toContain('Drop an image here')
expect(document.body.textContent).toContain('PNG, JPEG, WEBP, SVG · max 10 MB')
expect(findButton('Choose file')).toBeTruthy()
expect(dialogText()).toContain('Add an image')
expect(tabButtons().map(textOf)).toEqual(['Upload', 'Library'])
expect(textOf(selectedTab())).toBe('Upload')
expect(dialogText()).toContain('Drop an image here, or choose a file')
expect(dialogText()).toContain('PNG, JPEG, WEBP, SVG · max 10 MB')
expect(findButton('Choose file')).toBeUndefined()
expect(findButton('Cancel')).toBeTruthy()
expect(findButton('Insert image')).toBeUndefined()
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
wrapper.unmount()
})
})
describe('Dragging a file over the drop zone shows the dragging-over state', () => {
it('shows "Drop to upload"', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await openOnUpload()
dropZone().dispatchEvent(new Event('dragover', { bubbles: true, cancelable: true }))
await nextTick()
expect(document.body.textContent).toContain('Drop to upload')
wrapper.unmount()
expect(dialogText()).toContain('Drop to upload')
})
})
describe('Choosing a file the browser can reject never sends a request', () => {
describe('Dropping a file opens Upload with the file', () => {
it('looks the hash up, sends no list request and opens the crop step', async () => {
installApi()
wrapper = mountUploader({ initialFile: fileFixture('before.png', 'image/png', 3.9) })
await openUploader(wrapper)
expect(hashCalls()).toEqual([NORTH_WALL_HASH])
expect(listCalls()).toHaveLength(0)
expect(dialogText()).toContain('Crop and resize')
expect(dialogText()).toContain('before.png')
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
})
})
describe('Choosing a file the browser can reject sends nothing', () => {
it.each([
{
filename: 'plan.pdf',
@@ -234,183 +253,368 @@ describe('Choosing a file the browser can reject never sends a request', () => {
])(
'rejects $filename with the exact copy',
async ({ filename, contentType, sizeMB, description }) => {
const wrapper = mountUploader()
await openModal(wrapper)
await openOnUpload()
await chooseFile(fileFixture(filename, contentType, sizeMB))
expect(hashCalls()).toHaveLength(0)
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
expect(document.body.textContent).toContain(`"${filename}" can't be used`)
expect(document.body.textContent).toContain(description)
expect(dialogText()).toContain(`"${filename}" can't be used`)
expect(dialogText()).toContain(description)
expect(findButton('Choose another file')).toBeTruthy()
expect(findButton('Cancel')).toBeTruthy()
wrapper.unmount()
expect(tabButtons()).toHaveLength(0)
},
)
})
describe('"Choose another file" from the rejected state returns to idle', () => {
it('shows the idle state again', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
it('shows the idle state again, with the tabs', async () => {
await openOnUpload()
await chooseFile(fileFixture('plan.pdf', 'application/pdf', 1.2))
await clickButton('Choose another file')
expect(document.body.textContent).toContain('Drop an image here')
wrapper.unmount()
expect(dialogText()).toContain('Drop an image here, or choose a file')
expect(tabButtons().map(textOf)).toEqual(['Upload', 'Library'])
})
})
describe('A file whose content exists shows the duplicate card and sends nothing', () => {
it('shows the card with the oldest match and its two actions', async () => {
await openOnUpload({ hash: () => pageBody([mediaItem()], null) })
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
const card = textOf(document.body.querySelector('[data-testid="duplicate-card"]'))
expect(card).toContain('Already in your library')
expect(card).toContain(
'north-wall.jpg matches an image uploaded on 12 Sep 2026 by Marta Alves.',
)
expect(findButton('Use existing')).toBeTruthy()
expect(findButton('Upload anyway')).toBeTruthy()
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
expect(dialogText()).not.toContain('Crop and resize')
expect(tabButtons()).toHaveLength(2)
})
it('names the oldest of several matches', async () => {
await openOnUpload({
hash: () =>
pageBody(
[
mediaItem({
id: 9,
created_at: '2026-09-20T12:00:00Z',
uploaded_by: 'jonas',
}),
mediaItem({
id: 3,
created_at: '2026-09-01T12:00:00Z',
uploaded_by: 'Inès Duarte',
}),
],
null,
),
})
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
expect(dialogText()).toContain(
'north-wall.jpg matches an image uploaded on 1 Sep 2026 by Inès Duarte.',
)
})
it('drops the "by" part when the uploader is unknown', async () => {
await openOnUpload({ hash: () => pageBody([mediaItem({ uploaded_by: '' })], null) })
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
expect(dialogText()).toContain('north-wall.jpg matches an image uploaded on 12 Sep 2026.')
})
it('goes to the Library with the existing image selected when "Use existing" is activated', async () => {
await openOnUpload({ hash: () => pageBody([mediaItem()], null) })
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
await clickButton('Use existing')
expect(textOf(selectedTab())).toBe('Library')
expect(textOf(document.body.querySelector('[data-testid="picker-panel"]'))).toContain(
'north-wall.jpg',
)
expect(altField()!.value).toBe('')
expect(findButton('Insert image')?.disabled).toBe(false)
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
})
it('continues to crop and upload without a second lookup when "Upload anyway" is activated', async () => {
await openOnUpload({ hash: () => pageBody([mediaItem()], null) })
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
await clickButton('Upload anyway')
expect(dialogText()).toContain('Crop and resize')
expect(hashCalls()).toHaveLength(1)
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
})
it('uploads an SVG at once when "Upload anyway" is activated', async () => {
await openOnUpload({ hash: () => pageBody([mediaItem()], null) })
await chooseFile(fileFixture('diagram.svg', 'image/svg+xml', 0.04))
await clickButton('Upload anyway')
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
expect(latestXHR().url).toBe('/v1/media/')
})
})
describe('The duplicate check is skipped when it cannot run', () => {
it.each([
{
name: 'crypto.subtle is undefined',
arrange: () => vi.mocked(fileHash.hashFile).mockResolvedValue(null),
routes: {},
},
{
name: 'the lookup resolves with a 500 response',
arrange: () => undefined,
routes: { hash: () => Promise.reject(new ApiError(500)) },
},
{
name: 'the lookup never reaches the server',
arrange: () => undefined,
routes: { hash: () => Promise.reject(new NetworkError()) },
},
])('continues to the crop step with no card when $name', async ({ arrange, routes }) => {
arrange()
await openOnUpload(routes)
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
expect(dialogText()).toContain('Crop and resize')
expect(document.body.querySelector('[data-testid="duplicate-card"]')).toBeNull()
})
it('uploads an SVG when the lookup fails', async () => {
await openOnUpload({ hash: () => Promise.reject(new NetworkError()) })
await chooseFile(fileFixture('diagram.svg', 'image/svg+xml', 0.04))
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
})
})
describe('A valid upload shows progress and its cancel action reads "Cancel upload"', () => {
it('sends the resized file and reports live progress', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
it('sends the resized file to /v1/media/ and reports live progress', async () => {
await openOnUpload()
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
const xhr = latestXHR()
expect(xhr.url).toBe('/v1/article/12/image')
expect(xhr.method).toBe('POST')
expect(xhr.url).toBe('/v1/media/')
expect(xhr.withCredentials).toBe(true)
expect(xhr.sentBody?.get('file')).toBeInstanceOf(File)
expect(document.body.textContent).toContain('north-wall.jpg 0%')
expect(document.body.textContent).toContain('Uploading 0.0 of 420 KB')
expect(dialogText()).toContain('north-wall.jpg 0%')
expect(dialogText()).toContain('Uploading 0.0 of 420 KB')
expect(findButton('Cancel upload')).toBeTruthy()
expect(tabButtons()).toHaveLength(0)
xhr.fireProgress(180 * 1024, 420 * 1024)
await nextTick()
expect(document.body.textContent).toContain('north-wall.jpg 43%')
expect(document.body.textContent).toContain('Uploading 180 of 420 KB')
wrapper.unmount()
expect(dialogText()).toContain('north-wall.jpg 43%')
expect(dialogText()).toContain('Uploading 180 of 420 KB')
})
it('reads megabytes for a larger file', async () => {
await openOnUpload()
await chooseFile(fileFixture('logo.svg', 'image/svg+xml', 3.9))
latestXHR().fireProgress(mib(2.4), mib(3.9))
await nextTick()
expect(dialogText()).toContain('logo.svg 62%')
expect(dialogText()).toContain('Uploading 2.4 of 3.9 MB')
})
})
describe('Closing the dialog during an upload cancels it', () => {
it('aborts the in-flight XMLHttpRequest and closes the modal', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await openOnUpload()
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
const xhr = latestXHR()
await clickButton('Cancel upload')
expect(xhr.aborted).toBe(true)
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
wrapper.unmount()
expect(modalOpen()).toBe(false)
expect(wrapper!.emitted('uploaded')).toBeUndefined()
})
})
describe('A successful upload emits the location and auto-closes 600 ms later', () => {
describe('A successful upload emits the library item and auto-closes 600 ms later', () => {
it('shows "Image added" then closes', async () => {
vi.useFakeTimers()
const wrapper = mountUploader()
await openModal(wrapper)
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
const xhr = latestXHR()
installEmptyLibrary()
wrapper = mountUploader()
;(wrapper.vm as unknown as { open: boolean }).open = true
await vi.advanceTimersByTimeAsync(0)
await chooseFile(fileFixture('diagram.svg', 'image/svg+xml', 0.04))
const item = mediaItem({ id: 13, url: '/static/uploadedImages/13.jpg' })
xhr.resolve(
201,
JSON.stringify({ id: 7, location: '/static/uploadedImages/north-wall.jpg' }),
)
latestXHR().resolve(201, JSON.stringify(item))
await vi.advanceTimersByTimeAsync(0)
expect(wrapper.emitted('uploaded')).toEqual([['/static/uploadedImages/north-wall.jpg', 7]])
expect(document.body.textContent).toContain('Image added')
expect(document.querySelector('[role="dialog"]')).toBeTruthy()
expect(wrapper.emitted('uploaded')).toEqual([[item]])
expect(dialogText()).toContain('Image added')
expect(modalOpen()).toBe(true)
await vi.advanceTimersByTimeAsync(590)
expect(document.querySelector('[role="dialog"]')).toBeTruthy()
expect(modalOpen()).toBe(true)
await vi.advanceTimersByTimeAsync(10)
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
wrapper.unmount()
vi.useRealTimers()
expect(modalOpen()).toBe(false)
})
})
describe('A server-side upload failure keeps the article unchanged and offers a retry', () => {
describe('A server answer for a file that passed the browser checks', () => {
it.each([
{ name: 'a 500 response', trigger: (xhr: FakeXMLHttpRequest) => xhr.resolve(500, '') },
{ name: 'a network failure', trigger: (xhr: FakeXMLHttpRequest) => xhr.fail() },
])('shows the failed state for $name', async ({ trigger }) => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
const xhr = latestXHR()
{
file: 'notes.pdf',
type: 'image/png',
sizeMB: 0.2,
status: 415,
title: '"notes.pdf" can\'t be used',
text: 'Use a PNG, JPEG, WEBP or SVG image, up to 10 MB.',
confirm: 'Choose another file',
},
{
file: 'north-wall.tif',
type: 'image/png',
sizeMB: 14.2,
status: 413,
title: '"north-wall.tif" can\'t be used',
text: 'north-wall.tif is 14.2 MB. Images can be up to 10 MB.',
confirm: 'Choose another file',
},
{
file: 'broken.png',
type: 'image/png',
sizeMB: 3,
status: 400,
title: '"broken.png" can\'t be used',
text: 'Use a PNG, JPEG, WEBP or SVG image, up to 10 MB.',
confirm: 'Choose another file',
},
{
file: 'north-wall.jpg',
type: 'image/jpeg',
sizeMB: 3.9,
status: 500,
title: 'Upload failed',
text: "The server didn't accept the file. Your article hasn't changed.",
confirm: 'Try again',
},
{
file: 'north-wall.jpg',
type: 'image/jpeg',
sizeMB: 3.9,
status: 429,
title: 'Upload failed',
text: "The server didn't accept the file. Your article hasn't changed.",
confirm: 'Try again',
},
{
file: 'north-wall.jpg',
type: 'image/jpeg',
sizeMB: 3.9,
status: 0,
title: 'Upload failed',
text: "The server didn't accept the file. Your article hasn't changed.",
confirm: 'Try again',
},
])(
'answers $status with $title',
async ({ file, type, sizeMB, status, title, text, confirm }) => {
await openOnUpload()
const chosen = fileFixture(file, type, sizeMB)
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({ ok: true })
await chooseFile(chosen)
if (findButton('Upload image')) {
await clickButton('Upload image')
}
const xhr = latestXHR()
trigger(xhr)
await flushPromises()
if (status === 0) {
xhr.fail()
} else {
xhr.resolve(status, '')
}
await settle()
expect(document.body.textContent).toContain('Upload failed')
expect(document.body.textContent).toContain(
"The server didn't accept the file. Your article hasn't changed.",
)
expect(findButton('Try again')).toBeTruthy()
expect(wrapper.emitted('uploaded')).toBeFalsy()
wrapper.unmount()
})
expect(dialogText()).toContain(title)
expect(dialogText()).toContain(text)
expect(findButton(confirm)).toBeTruthy()
expect(wrapper!.emitted('uploaded')).toBeUndefined()
expect(tabButtons()).toHaveLength(0)
},
)
})
describe('"Try again" re-attempts the upload with the same file', () => {
it('resends the same file under field "file"', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
it('resends the same file under field "file" without a new lookup', async () => {
await openOnUpload()
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
latestXHR().resolve(500, '')
await flushPromises()
await settle()
await clickButton('Try again')
expect(FakeXMLHttpRequest.instances).toHaveLength(2)
const xhr = latestXHR()
expect(xhr.url).toBe('/v1/article/12/image')
expect(xhr.url).toBe('/v1/media/')
expect((xhr.sentBody?.get('file') as File).name).toBe('north-wall.jpg')
expect(document.body.textContent).toContain('north-wall.jpg 0%')
wrapper.unmount()
expect(hashCalls()).toHaveLength(1)
expect(dialogText()).toContain('north-wall.jpg 0%')
})
})
describe('A server 400 for a type the client should have caught shows the same rejected copy', () => {
it('shows the type-rejection copy', async () => {
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({ ok: true })
const wrapper = mountUploader()
await openModal(wrapper)
await chooseRasterFileAndConfirmUpload(fileFixture('notes.pdf', 'image/png', 1.2))
const xhr = latestXHR()
describe('Session errors are global', () => {
it('hands a 401 on the upload to the login redirect with no local copy', async () => {
await openOnUpload()
await chooseFile(fileFixture('diagram.svg', 'image/svg+xml', 0.04))
xhr.resolve(400, 'the file must be a PNG, JPEG, WEBP, or SVG image')
await flushPromises()
latestXHR().resolve(401, '')
await settle()
expect(document.body.textContent).toContain(`"notes.pdf" can't be used`)
expect(document.body.textContent).toContain(
'Use a PNG, JPEG, WEBP or SVG image, up to 10 MB.',
)
expect(findButton('Choose another file')).toBeTruthy()
expect(wrapper.emitted('uploaded')).toBeFalsy()
wrapper.unmount()
expect(redirectToLogin).toHaveBeenCalledTimes(1)
expect(dialogText()).not.toContain('Upload failed')
})
})
describe('A server 400 for a size the client should have caught shows the size-named copy', () => {
it('shows the size-rejection copy computed from the file', async () => {
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({ ok: true })
const wrapper = mountUploader()
await openModal(wrapper)
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.tif', 'image/png', 14.2))
const xhr = latestXHR()
it('hands a 403 on the upload to the unverified redirect with no local copy', async () => {
await openOnUpload()
await chooseFile(fileFixture('diagram.svg', 'image/svg+xml', 0.04))
xhr.resolve(400, 'the file exceeds the maximum upload size')
await flushPromises()
latestXHR().resolve(403, '')
await settle()
expect(document.body.textContent).toContain(`"north-wall.tif" can't be used`)
expect(document.body.textContent).toContain(
'north-wall.tif is 14.2 MB. Images can be up to 10 MB.',
)
expect(findButton('Choose another file')).toBeTruthy()
expect(wrapper.emitted('uploaded')).toBeFalsy()
wrapper.unmount()
expect(redirectUnverified).toHaveBeenCalledTimes(1)
expect(dialogText()).not.toContain('Upload failed')
})
it('stops on a 401 from the hash lookup with no local copy', async () => {
await openOnUpload({ hash: () => Promise.reject(new ApiError(401)) })
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
expect(dialogText()).not.toContain('Crop and resize')
expect(apiKit.request).toHaveBeenCalled()
})
})
@@ -1,11 +1,18 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import { nextTick } from 'vue'
import { flushPromises } from '@vue/test-utils'
import ImageUploader from '../ImageUploader.vue'
import { installEmptyLibrary, mountUploader, openUploader } from './pickerKit'
import { renderCroppedImage } from '@/lib/imageCrop'
import type { CropRect } from '@/lib/imageCrop'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
vi.mock('@/lib/fileHash', () => ({ hashFile: vi.fn(async () => null) }))
vi.mock('@/lib/imageCrop', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
return {
@@ -95,36 +102,7 @@ function fileFixture(name: string, type: string, sizeMB: number) {
return file
}
const Host = defineComponent({
components: { ImageUploader },
props: {
articleId: { type: Number, required: true },
},
emits: ['uploaded', 'close'],
setup(_props, { emit }) {
const open = ref(false)
return {
open,
onUploaded: (location: string) => emit('uploaded', location),
onClose: () => emit('close'),
}
},
template: `<ImageUploader v-model:open="open" :article-id="articleId" @uploaded="onUploaded" @close="onClose" />`,
})
function mountUploader() {
return mount(Host, {
props: { articleId: 12 },
global: { plugins: [uiPlugin] },
attachTo: document.body,
})
}
async function openModal(wrapper: ReturnType<typeof mountUploader>) {
;(wrapper.vm as unknown as { open: boolean }).open = true
await nextTick()
await nextTick()
}
const openModal = openUploader
function fileInput() {
return document.body.querySelector<HTMLInputElement>('[data-testid="image-file-input"]')!
@@ -138,8 +116,7 @@ async function chooseFile(file: File) {
})
input.dispatchEvent(new Event('change'))
await nextTick()
await nextTick()
await nextTick()
await flushPromises()
}
function findButton(text: string) {
@@ -151,8 +128,7 @@ function findButton(text: string) {
async function clickButton(text: string) {
findButton(text)?.click()
await nextTick()
await nextTick()
await nextTick()
await flushPromises()
}
function radioPill(groupLabel: string, text: string) {
@@ -187,6 +163,7 @@ function north_wall() {
const RealXMLHttpRequest = global.XMLHttpRequest
beforeEach(() => {
installEmptyLibrary()
FakeXMLHttpRequest.instances = []
global.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest
})
@@ -223,7 +200,7 @@ describe('An SVG skips the crop step and uploads immediately', () => {
await chooseFile(fileFixture('diagram.svg', 'image/svg+xml', 0.04))
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
expect(latestXHR().url).toBe('/v1/article/12/image')
expect(latestXHR().url).toBe('/v1/media/')
expect(document.body.textContent).not.toContain('Crop and resize')
wrapper.unmount()
})
@@ -0,0 +1,522 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { ApiError, NetworkError } from '@/composables/useApi'
import { MOBILE_WIDTH } from './viewport'
import { fixture } from '@/views/__tests__/mediaTestKit'
import {
altField,
apiKit,
click,
clickTab,
dialogText,
findButton,
installApi,
listCalls,
modalOpen,
mountUploader,
openUploader,
pageBody,
press,
searchField,
secondPage,
selectedTab,
selectTile,
settle,
skeletonTiles,
textOf,
tile,
tiles,
typeInto,
type PickerRoutes,
} from './pickerKit'
vi.mock('@/lib/imageCrop', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
return {
...actual,
readNaturalImageSize: vi.fn(async () => ({ width: 4032, height: 3024 })),
}
})
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
let wrapper: ReturnType<typeof mountUploader> | null = null
afterEach(() => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
vi.unstubAllGlobals()
document.body.innerHTML = ''
})
async function openLibrary(routes: PickerRoutes = {}) {
installApi(routes)
wrapper = mountUploader()
await openUploader(wrapper)
}
async function openWithFakeTimers(routes: PickerRoutes = {}, width?: number) {
vi.useFakeTimers()
installApi(routes)
wrapper = mountUploader({}, width)
;(wrapper.vm as unknown as { open: boolean }).open = true
await vi.advanceTimersByTimeAsync(0)
}
function summary() {
return textOf(document.body.querySelector('[data-testid="picker-summary"]'))
}
async function searchFor(value: string) {
await typeInto(searchField(), value)
await new Promise((resolve) => setTimeout(resolve, 150))
await settle()
}
describe('The library has images, so Library opens', () => {
it('selects Library with a disabled "Insert image" and draws skeletons only after 150 ms', async () => {
const pending = deferred<unknown>()
await openWithFakeTimers({ list: () => pending.promise })
expect(textOf(selectedTab())).toBe('Library')
expect(findButton('Cancel')?.disabled).toBe(false)
expect(findButton('Insert image')?.disabled).toBe(true)
expect(skeletonTiles()).toHaveLength(0)
expect(tiles()).toHaveLength(0)
await vi.advanceTimersByTimeAsync(150)
expect(skeletonTiles()).toHaveLength(8)
pending.resolve(pageBody())
await vi.advanceTimersByTimeAsync(0)
expect(skeletonTiles()).toHaveLength(0)
expect(tiles()).toHaveLength(12)
})
it('draws 9 skeleton tiles on mobile', async () => {
await openWithFakeTimers({ list: () => new Promise(() => undefined) }, MOBILE_WIDTH)
await vi.advanceTimersByTimeAsync(150)
expect(skeletonTiles()).toHaveLength(9)
})
it('asks for 24 images and nothing else', async () => {
await openLibrary()
expect(listCalls()).toHaveLength(1)
expect(listCalls()[0]!.toString()).toBe('limit=24')
})
})
describe('The library is empty, so Upload opens', () => {
it('opens the Upload tab idle, and Library then shows the empty panel', async () => {
await openLibrary({ list: () => pageBody([], null) })
expect(textOf(selectedTab())).toBe('Upload')
expect(dialogText()).toContain('Drop an image here, or choose a file')
await clickTab('Library')
expect(dialogText()).toContain('Your library is empty')
expect(dialogText()).toContain(
'Images you upload appear here, ready to use in any article.',
)
expect(findButton('Insert image')?.disabled).toBe(true)
})
it('switches to Upload from "Upload an image"', async () => {
await openLibrary({ list: () => pageBody([], null) })
await clickTab('Library')
await click(findButton('Upload an image'))
expect(textOf(selectedTab())).toBe('Upload')
})
})
describe('A dropped file opens Upload and sends no list request', () => {
it('stays quiet until the Library tab is selected', async () => {
installApi({ hash: () => new Promise(() => undefined) })
wrapper = mountUploader({
initialFile: new File(['x'], 'before.png', { type: 'image/png' }),
})
await openUploader(wrapper)
expect(textOf(selectedTab())).toBe('Upload')
expect(listCalls()).toHaveLength(0)
await clickTab('Library')
expect(listCalls()).toHaveLength(1)
})
})
describe('Library, nothing selected', () => {
it('shows the line, the 12 tiles in order and the tags on the images in the article', async () => {
await openLibrary()
expect(summary()).toBe('Newest first · 48 images')
expect(tiles().map((element) => element.getAttribute('data-image-id'))).toEqual(
fixture().map((item) => String(item.id)),
)
const tagged = tiles()
.filter((element) => textOf(element).includes('In this article'))
.map((element) => element.getAttribute('data-image-id'))
expect(tagged).toEqual(['1', '4'])
expect(textOf(tile('north-wall.jpg'))).toContain('north-wall.jpg')
expect(findButton('Load more')?.getAttribute('aria-disabled')).toBeNull()
expect(findButton('Insert image')?.disabled).toBe(true)
})
})
describe('A search is active, so the count is not shown', () => {
it('reads "Newest first"', async () => {
await openLibrary({
list: (query) => (query.get('q') ? pageBody(fixture([1, 12]), null) : pageBody()),
})
await searchFor('wall')
expect(summary()).toBe('Newest first')
})
})
describe('Load more is hidden on the last page', () => {
it('does not show "Load more" without a cursor', async () => {
await openLibrary({ list: () => pageBody(fixture(), null) })
expect(findButton('Load more')).toBeUndefined()
})
})
describe("A tile's accessible name", () => {
it.each([
['north-wall.jpg', 'north-wall.jpg, 3.9 MB, used in 2 articles'],
['kitchen-before.webp', 'kitchen-before.webp, 1.1 MB, used in 1 article'],
['window-frame.jpg', 'window-frame.jpg, 1.8 MB, unused'],
])('names %s', async (filename, name) => {
await openLibrary()
expect(tile(filename).getAttribute('aria-label')).toBe(name)
})
})
describe('A tile is selected by pointer or keyboard', () => {
it.each([
['clicks', async () => click(tile('kitchen-after.webp'))],
[
'presses Enter',
async () => {
press(tile('kitchen-after.webp'), 'Enter')
await settle()
},
],
[
'presses Space',
async () => {
press(tile('kitchen-after.webp'), ' ')
await settle()
},
],
])('selects the tile when the user %s on it', async (_name, act) => {
await openLibrary()
await act()
expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('true')
expect(
tile('kitchen-after.webp').querySelector('[data-testid="picker-check"]'),
).not.toBeNull()
expect(findButton('Insert image')?.disabled).toBe(false)
})
})
describe('The selection panel', () => {
it('shows the thumbnail, the details and the alt text field', async () => {
await openLibrary()
await selectTile('kitchen-after.webp')
const panel = textOf(document.body.querySelector('[data-testid="picker-panel"]'))
expect(panel).toContain('kitchen-after.webp')
expect(panel).toContain('2400 × 1600 · 1.2 MB')
expect(panel).toContain('Alt text (optional)')
expect(panel).toContain(
"Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration.",
)
expect(altField()?.value).toBe('')
expect(findButton('Load more')).toBeUndefined()
})
it('reads "Vector · 8 KB" for an SVG', async () => {
await openLibrary()
await selectTile('logo-renew.svg')
expect(textOf(document.body.querySelector('[data-testid="picker-panel"]'))).toContain(
'Vector · 8 KB',
)
})
it('allows an image already in the article to be selected', async () => {
await openLibrary()
await selectTile('north-wall.jpg')
expect(findButton('Insert image')?.disabled).toBe(false)
})
})
describe('Selection is single', () => {
it('selects only the last tile and empties the alt text', async () => {
await openLibrary()
await selectTile('kitchen-after.webp')
await typeInto(altField()!, 'A kitchen')
await selectTile('window-frame.jpg')
expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('false')
expect(tile('window-frame.jpg').getAttribute('aria-pressed')).toBe('true')
expect(altField()!.value).toBe('')
})
it('keeps the tile selected, and its alt text, when it is activated again', async () => {
await openLibrary()
await selectTile('kitchen-after.webp')
await typeInto(altField()!, 'A kitchen')
await selectTile('kitchen-after.webp')
expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('true')
expect(altField()!.value).toBe('A kitchen')
})
})
describe('Insert', () => {
function kitchenAfterAtStaticUrl() {
return pageBody(
fixture().map((item) =>
item.id === 2 ? { ...item, url: '/static/uploadedImages/2.webp' } : item,
),
)
}
it('emits the item and the alt text, then closes at once with no write request', async () => {
await openLibrary({ list: kitchenAfterAtStaticUrl })
await selectTile('kitchen-after.webp')
await typeInto(altField()!, 'A kitchen with new oak worktops and white tiles')
await click(findButton('Insert image'))
const [item, alt] = wrapper!.emitted('inserted')![0] as [
{ id: number; url: string },
string,
]
expect(item.id).toBe(2)
expect(item.url).toBe('/static/uploadedImages/2.webp')
expect(alt).toBe('A kitchen with new oak worktops and white tiles')
expect(modalOpen()).toBe(false)
expect(apiKit.request.mock.calls.every(([, options]) => !options?.method)).toBe(true)
})
it('inserts an empty alt for a decorative image', async () => {
await openLibrary()
await selectTile('kitchen-after.webp')
await click(findButton('Insert image'))
expect(wrapper!.emitted('inserted')![0]![1]).toBe('')
})
it.each([
['activates "Cancel"', async () => click(findButton('Cancel'))],
[
'activates the x',
async () =>
click(document.body.querySelector('[role="dialog"] button[aria-label="Close"]')),
],
[
'presses Escape',
async () => {
document.body
.querySelector('[role="dialog"]')!
.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await settle()
},
],
])('changes nothing when the user %s', async (_name, act) => {
await openLibrary()
await selectTile('kitchen-after.webp')
await typeInto(altField()!, 'A kitchen')
await act()
expect(modalOpen()).toBe(false)
expect(wrapper!.emitted('inserted')).toBeUndefined()
expect(wrapper!.emitted('uploaded')).toBeUndefined()
})
})
describe('Search', () => {
it('searches after a 120 ms debounce, with no cursor, and clears the selection', async () => {
await openWithFakeTimers()
tile('kitchen-after.webp').click()
await vi.advanceTimersByTimeAsync(0)
expect(findButton('Insert image')?.disabled).toBe(false)
await typeInto(searchField(), 'wall')
await vi.advanceTimersByTimeAsync(119)
expect(listCalls()).toHaveLength(1)
await vi.advanceTimersByTimeAsync(1)
expect(listCalls()).toHaveLength(2)
expect(listCalls()[1]!.get('q')).toBe('wall')
expect(listCalls()[1]!.get('limit')).toBe('24')
expect(listCalls()[1]!.has('cursor')).toBe(false)
expect(findButton('Insert image')?.disabled).toBe(true)
})
it('draws skeleton tiles while a search loads, after 150 ms', async () => {
await openWithFakeTimers({
list: (query) => (query.get('q') ? new Promise(() => undefined) : pageBody()),
})
await typeInto(searchField(), 'wall')
await vi.advanceTimersByTimeAsync(120)
expect(skeletonTiles()).toHaveLength(0)
await vi.advanceTimersByTimeAsync(150)
expect(skeletonTiles()).toHaveLength(8)
})
it('clears the search from the x in the field', async () => {
await openLibrary({
list: (query) => (query.get('q') ? pageBody(fixture([1, 12]), null) : pageBody()),
})
await searchFor('wall')
await click(document.body.querySelector('button[aria-label="Clear search"]'))
expect(searchField().value).toBe('')
const last = listCalls().at(-1)!
expect(last.has('q')).toBe(false)
expect(last.get('limit')).toBe('24')
})
it('shows the no results panel and "Clear search" empties the field', async () => {
await openLibrary({
list: (query) => (query.get('q') ? pageBody([], null) : pageBody()),
})
await searchFor('wall')
expect(dialogText()).toContain('No images match "wall"')
expect(dialogText()).toContain(
'Check the spelling, or search for a shorter part of the filename.',
)
expect(searchField().value).toBe('wall')
expect(findButton('Insert image')?.disabled).toBe(true)
await click(findButton('Clear search'))
expect(searchField().value).toBe('')
expect(listCalls().at(-1)!.has('q')).toBe(false)
expect(tiles()).toHaveLength(12)
})
})
describe('Load more', () => {
it('shows progress, appends the new tiles and moves focus to the first one', async () => {
const more = deferred<unknown>()
await openWithFakeTimers({
list: (query) => (query.get('cursor') ? more.promise : pageBody()),
})
const button = findButton('Load more')!
button.focus()
button.click()
await vi.advanceTimersByTimeAsync(0)
const last = listCalls().at(-1)!
expect(last.get('cursor')).toBe('c24')
expect(last.get('limit')).toBe('24')
const loading = findButton('Loading…')!
expect(loading.getAttribute('aria-disabled')).toBe('true')
expect(document.activeElement).toBe(loading)
await vi.advanceTimersByTimeAsync(150)
expect(skeletonTiles()).toHaveLength(4)
more.resolve(pageBody(secondPage(), null))
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(tiles()).toHaveLength(24)
expect(skeletonTiles()).toHaveLength(0)
expect(document.activeElement).toBe(tiles()[12])
})
it('draws a row of 3 skeleton tiles on mobile', async () => {
await openWithFakeTimers(
{ list: (query) => (query.get('cursor') ? new Promise(() => undefined) : pageBody()) },
MOBILE_WIDTH,
)
findButton('Load more')!.click()
await vi.advanceTimersByTimeAsync(150)
expect(skeletonTiles()).toHaveLength(3)
})
it('combines the cursor with the search', async () => {
await openLibrary({
list: (query) => pageBody(query.get('q') ? fixture([1, 2]) : fixture(), 'c24'),
})
await searchFor('wall')
await click(findButton('Load more'))
const last = listCalls().at(-1)!
expect(last.get('q')).toBe('wall')
expect(last.get('limit')).toBe('24')
expect(last.get('cursor')).toBe('c24')
})
})
describe('The library fails to load', () => {
it.each([
['a 500 response', () => Promise.reject(new ApiError(500))],
['a request that never reaches the server', () => Promise.reject(new NetworkError())],
])('shows the alert for %s and retries', async (_name, failure) => {
let failing = true
await openLibrary({ list: () => (failing ? failure() : pageBody()) })
expect(dialogText()).toContain("Couldn't load your library")
expect(dialogText()).toContain('Check your connection and try again.')
expect(findButton('Insert image')?.disabled).toBe(true)
failing = false
await click(findButton('Try again'))
expect(listCalls()).toHaveLength(2)
expect(tiles()).toHaveLength(12)
expect(dialogText()).not.toContain("Couldn't load your library")
})
})
+190
View File
@@ -0,0 +1,190 @@
import { vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import ImageUploader from '../ImageUploader.vue'
import { useApi } from '@/composables/useApi'
import type { MediaItem } from '@/composables/useMedia'
import { DESKTOP_WIDTH, setViewportWidth } from './viewport'
import { fixture, textOf } from '@/views/__tests__/mediaTestKit'
export { textOf }
export const FIRST_PAGE_CURSOR = 'c24'
export const PICKER_COUNTS = { all: 48, unused: 2, duplicates: 2 }
export function pageBody(
items: MediaItem[] = fixture(),
next_cursor: string | null = FIRST_PAGE_CURSOR,
) {
return { items, next_cursor, counts: PICKER_COUNTS }
}
export function secondPage() {
return fixture().map((item) => ({
...item,
id: item.id + 12,
filename: `more-${item.filename}`,
url: `/media/more-${item.filename}`,
}))
}
export interface PickerRoutes {
list?: (query: URLSearchParams) => unknown
hash?: (hash: string) => unknown
}
export const apiKit = { request: vi.fn() }
export function installApi(routes: PickerRoutes = {}) {
apiKit.request = vi.fn(async (path: string) => {
const url = new URL(path, 'http://localhost')
const hash = url.searchParams.get('hash')
if (hash !== null) {
return (routes.hash ?? (() => pageBody([], null)))(hash)
}
return (routes.list ?? (() => pageBody()))(url.searchParams)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
request: apiKit.request,
})
}
export function installEmptyLibrary(routes: PickerRoutes = {}) {
installApi({ list: () => pageBody([], null), ...routes })
}
export function listCalls() {
return apiKit.request.mock.calls
.map(([path]) => new URL(path as string, 'http://localhost'))
.filter((url) => url.pathname === '/v1/media/' && !url.searchParams.has('hash'))
.map((url) => url.searchParams)
}
export function hashCalls() {
return apiKit.request.mock.calls
.map(([path]) => new URL(path as string, 'http://localhost'))
.filter((url) => url.searchParams.has('hash'))
.map((url) => url.searchParams.get('hash'))
}
export const Host = defineComponent({
components: { ImageUploader },
props: {
initialFile: { type: Object as () => File | null, default: null },
inArticleIds: { type: Array as () => number[], default: () => [1, 4] },
},
emits: ['uploaded', 'inserted', 'close'],
setup(_props, { emit }) {
const open = ref(false)
return {
open,
onUploaded: (item: MediaItem) => emit('uploaded', item),
onInserted: (item: MediaItem, alt: string) => emit('inserted', item, alt),
onClose: () => emit('close'),
}
},
template: `<ImageUploader v-model:open="open" :initial-file="initialFile" :in-article-ids="inArticleIds" @uploaded="onUploaded" @inserted="onInserted" @close="onClose" />`,
})
export function mountUploader(
props: { initialFile?: File | null; inArticleIds?: number[] } = {},
width = DESKTOP_WIDTH,
) {
setViewportWidth(width)
setActivePinia(createPinia())
return mount(Host, {
props,
global: { plugins: [uiPlugin] },
attachTo: document.body,
})
}
export async function openUploader(wrapper: ReturnType<typeof mountUploader>) {
;(wrapper.vm as unknown as { open: boolean }).open = true
await nextTick()
await nextTick()
await flushPromises()
}
export async function settle() {
await flushPromises()
await nextTick()
await flushPromises()
}
export function tabButtons() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]'))
}
export function tabButton(label: string) {
return tabButtons().find((element) => textOf(element) === label)
}
export function selectedTab() {
return tabButtons().find((element) => element.getAttribute('aria-selected') === 'true')
}
export function tiles() {
return Array.from(document.body.querySelectorAll<HTMLButtonElement>('[data-picker-tile]'))
}
export function tile(filename: string) {
return tiles().find((element) =>
element.getAttribute('aria-label')?.startsWith(`${filename},`),
)!
}
export function skeletonTiles() {
return document.body.querySelectorAll('[data-testid="picker-skeleton-tile"]')
}
export function findButton(label: string) {
return Array.from(document.body.querySelectorAll<HTMLButtonElement>('button')).find(
(button) => textOf(button) === label,
)
}
export async function click(element: Element | null | undefined) {
;(element as HTMLElement).click()
await settle()
}
export async function clickTab(label: string) {
tabButton(label)!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
await settle()
}
export async function selectTile(filename: string) {
await click(tile(filename))
}
export function altField() {
return document.body.querySelector<HTMLInputElement>(
'input[placeholder="e.g. North wall of the kitchen before plastering"]',
)
}
export async function typeInto(input: HTMLInputElement, value: string) {
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
}
export function searchField() {
return document.body.querySelector<HTMLInputElement>('input[aria-label="Search by filename"]')!
}
export function dialogText() {
return textOf(document.body.querySelector('[role="dialog"]'))
}
export function modalOpen() {
return document.body.querySelector('[role="dialog"]') !== null
}
export function press(element: Element, key: string) {
element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
}