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 }))
}
-6
View File
@@ -1,15 +1,9 @@
import { useApi } from '@/composables/useApi'
export interface ArticleImage {
id: number
url: string
}
export interface Article {
id: number
title: string
body: string
images: ArticleImage[]
is_online: boolean
created_at: string
updated_at: string
+46 -17
View File
@@ -1,4 +1,14 @@
import { ref } from 'vue'
import { redirectToLogin, redirectUnverified } from '@/composables/useApi'
import type { MediaItem } from '@/composables/useMedia'
const UPLOAD_URL = '/v1/media/'
const HTTP_CREATED = 201
const HTTP_BAD_REQUEST = 400
const HTTP_UNAUTHORIZED = 401
const HTTP_FORBIDDEN = 403
const HTTP_TOO_LARGE = 413
const HTTP_UNSUPPORTED = 415
export interface UploadProgress {
loaded: number
@@ -6,10 +16,41 @@ export interface UploadProgress {
}
export type UploadOutcome =
| { kind: 'success'; location: string; id: number }
| { kind: 'rejected'; body: string }
| { kind: 'success'; item: MediaItem }
| { kind: 'rejected'; reason: 'size' | 'type' }
| { kind: 'handled' }
| { kind: 'failed' }
function readItem(responseText: string): MediaItem | null {
try {
return JSON.parse(responseText) as MediaItem
} catch {
return null
}
}
function outcomeOf(request: XMLHttpRequest): UploadOutcome {
switch (request.status) {
case HTTP_CREATED: {
const item = readItem(request.responseText)
return item ? { kind: 'success', item } : { kind: 'failed' }
}
case HTTP_TOO_LARGE:
return { kind: 'rejected', reason: 'size' }
case HTTP_UNSUPPORTED:
case HTTP_BAD_REQUEST:
return { kind: 'rejected', reason: 'type' }
case HTTP_UNAUTHORIZED:
redirectToLogin()
return { kind: 'handled' }
case HTTP_FORBIDDEN:
redirectUnverified()
return { kind: 'handled' }
default:
return { kind: 'failed' }
}
}
export function useImageUpload() {
const progress = ref<UploadProgress>({ loaded: 0, total: 0 })
let activeRequest: XMLHttpRequest | null = null
@@ -19,31 +60,19 @@ export function useImageUpload() {
activeRequest = null
}
function start(articleId: number, file: File): Promise<UploadOutcome> {
function start(file: File): Promise<UploadOutcome> {
progress.value = { loaded: 0, total: file.size }
return new Promise((resolve) => {
const request = new XMLHttpRequest()
activeRequest = request
request.open('POST', `/v1/article/${articleId}/image`)
request.open('POST', UPLOAD_URL)
request.withCredentials = true
request.upload.addEventListener('progress', (event) => {
progress.value = { loaded: event.loaded, total: event.total }
})
request.addEventListener('load', () => {
activeRequest = null
if (request.status === 201) {
const body = JSON.parse(request.responseText) as {
id: number
location: string
}
resolve({ kind: 'success', location: body.location, id: body.id })
return
}
if (request.status === 400) {
resolve({ kind: 'rejected', body: request.responseText })
return
}
resolve({ kind: 'failed' })
resolve(outcomeOf(request))
})
request.addEventListener('error', () => {
activeRequest = null
+171
View File
@@ -0,0 +1,171 @@
import { ref } from 'vue'
import { tryOnBeforeUnmount } from '@vueuse/core'
import { useApi } from '@/composables/useApi'
import {
MEDIA_SKELETON_DELAY_MS,
classifyMediaError,
type MediaCounts,
type MediaItem,
} from '@/composables/useMedia'
import { useSeenImagesStore } from '@/stores/seenImages'
export const LIBRARY_PAGE_LIMIT = 24
export type LibraryStatus = 'idle' | 'loading' | 'ready' | 'error' | 'handled'
interface LibraryResponse {
items: MediaItem[]
next_cursor: string | null
counts: MediaCounts
}
function requestQuery(query: string, cursor: string | null) {
return [
...(query ? [`q=${encodeURIComponent(query)}`] : []),
`limit=${LIBRARY_PAGE_LIMIT}`,
...(cursor ? [`cursor=${encodeURIComponent(cursor)}`] : []),
].join('&')
}
export function useLibraryList() {
const { request } = useApi()
const seen = useSeenImagesStore()
const items = ref<MediaItem[]>([])
const total = ref(0)
const nextCursor = ref<string | null>(null)
const status = ref<LibraryStatus>('idle')
const query = ref('')
const showSkeleton = ref(false)
const loadingMore = ref(false)
const firstNewId = ref<number | null>(null)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let latest = 0
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
function startSkeletonTimer() {
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, MEDIA_SKELETON_DELAY_MS)
}
function stopSkeleton() {
clearSkeletonTimer()
showSkeleton.value = false
}
function reset() {
latest += 1
items.value = []
total.value = 0
nextCursor.value = null
status.value = 'idle'
query.value = ''
loadingMore.value = false
firstNewId.value = null
stopSkeleton()
}
async function load(nextQuery = query.value) {
latest += 1
const ticket = latest
query.value = nextQuery
items.value = []
nextCursor.value = null
loadingMore.value = false
firstNewId.value = null
status.value = 'loading'
startSkeletonTimer()
try {
const response = await request<LibraryResponse>(
`/v1/media/?${requestQuery(nextQuery, null)}`,
)
if (ticket !== latest) {
return
}
items.value = response.items
total.value = response.counts.all
nextCursor.value = response.next_cursor
seen.remember(response.items)
status.value = 'ready'
} catch (error) {
if (ticket !== latest) {
return
}
status.value = classifyMediaError(error) === 'handled' ? 'handled' : 'error'
} finally {
if (ticket === latest) {
stopSkeleton()
}
}
}
async function loadMore() {
if (nextCursor.value === null || loadingMore.value || status.value !== 'ready') {
return
}
const ticket = latest
loadingMore.value = true
startSkeletonTimer()
try {
const response = await request<LibraryResponse>(
`/v1/media/?${requestQuery(query.value, nextCursor.value)}`,
)
if (ticket !== latest) {
return
}
const known = new Set(items.value.map((item) => item.id))
const added = response.items.filter((item) => !known.has(item.id))
items.value = [...items.value, ...added]
nextCursor.value = response.next_cursor
firstNewId.value = added[0]?.id ?? null
seen.remember(added)
} catch {
return
} finally {
if (ticket === latest) {
loadingMore.value = false
stopSkeleton()
}
}
}
async function search(nextQuery: string) {
if (nextQuery === query.value && status.value !== 'idle') {
return
}
await load(nextQuery)
}
async function ensureLoaded() {
if (status.value === 'idle') {
await load()
}
}
tryOnBeforeUnmount(clearSkeletonTimer)
return {
items,
total,
nextCursor,
status,
query,
showSkeleton,
loadingMore,
firstNewId,
load,
loadMore,
search,
ensureLoaded,
reset,
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ function toneOf(name: string) {
return Array.from(name).reduce((sum, letter) => sum + letter.charCodeAt(0), 0) % TONE_COUNT
}
function oldestOf(items: MediaItem[]) {
export function oldestOf(items: MediaItem[]) {
return [...items].sort(
(first, second) =>
Date.parse(first.created_at) - Date.parse(second.created_at) || first.id - second.id,
@@ -61,6 +61,23 @@ describe('Selecting an inserted image reveals its delete button', () => {
})
})
describe('The remove button is labelled and neutral', () => {
it('reads "Remove image" with a trash icon and is not styled as destructive', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
editor.commands.setNodeSelection(findArticleImagePos(editor, 10) as number)
await flush()
const button = deleteButton()!
expect(button.textContent?.trim()).toBe('Remove image')
expect(button.className).not.toContain('text-error')
expect(button.className).toContain('border-accented')
})
})
describe('An image with no data-image-id gets no delete affordance', () => {
it('renders no delete button even when the node is selected', async () => {
const { wrapper, getEditor } = mountHarness()
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { bodyImageIds } from '../bodyImageIds'
describe('bodyImageIds', () => {
it('lists the ids of the images that carry data-image-id', () => {
const body =
'<p>a</p><img src="/a.jpg" alt="" data-image-id="1"><p>b</p><img data-image-id="4" src="/b.svg">'
expect(bodyImageIds(body)).toEqual([1, 4])
})
it('ignores images without an id and non numeric ids', () => {
const body =
'<img src="/a.jpg"><img src="/b.jpg" data-image-id="abc"><img data-image-id="">'
expect(bodyImageIds(body)).toEqual([])
})
it('returns nothing for a body without images', () => {
expect(bodyImageIds('<p>Hello</p>')).toEqual([])
})
})
+13
View File
@@ -0,0 +1,13 @@
const IMAGE_ID_SELECTOR = 'img[data-image-id]'
const IMAGE_ID_ATTRIBUTE = 'data-image-id'
export function bodyImageCount(html: string): number {
return new DOMParser().parseFromString(html, 'text/html').querySelectorAll('img').length
}
export function bodyImageIds(html: string): number[] {
const document = new DOMParser().parseFromString(html, 'text/html')
return Array.from(document.querySelectorAll(IMAGE_ID_SELECTOR))
.map((image) => Number.parseInt(image.getAttribute(IMAGE_ID_ATTRIBUTE) ?? '', 10))
.filter((id) => !Number.isNaN(id))
}
+8
View File
@@ -76,3 +76,11 @@ export function joinNames(names: string[]) {
}
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
}
export function libraryMatchText(
filename: string,
existing: { created_at: string; uploaded_by: string },
) {
const by = existing.uploaded_by ? ` by ${existing.uploaded_by}` : ''
return `${filename} matches an image uploaded on ${formatMediaDate(existing.created_at)}${by}.`
}
+2
View File
@@ -0,0 +1,2 @@
export const UNDERLINE_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]'
@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useSeenImagesStore } from '../seenImages'
beforeEach(() => {
setActivePinia(createPinia())
})
describe('seenImages store', () => {
it('finds a remembered image by id', () => {
const store = useSeenImagesStore()
store.remember([{ id: 1, filename: 'north-wall.jpg', size: 4089446, extra: true } as never])
expect(store.find(1)).toEqual({ id: 1, filename: 'north-wall.jpg', size: 4089446 })
})
it('knows nothing about an image it has not seen', () => {
expect(useSeenImagesStore().find(7)).toBeNull()
})
it('keeps the latest record of an image', () => {
const store = useSeenImagesStore()
store.remember([{ id: 1, filename: 'north-wall.jpg', size: 10 }])
store.remember([{ id: 1, filename: 'renamed.jpg', size: 10 }])
expect(store.find(1)?.filename).toBe('renamed.jpg')
})
})
+24
View File
@@ -0,0 +1,24 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface SeenImage {
id: number
filename: string
size: number
}
export const useSeenImagesStore = defineStore('seenImages', () => {
const byId = ref<Record<number, SeenImage>>({})
function remember(images: SeenImage[]) {
for (const { id, filename, size } of images) {
byId.value[id] = { id, filename, size }
}
}
function find(id: number): SeenImage | null {
return byId.value[id] ?? null
}
return { remember, find }
})
+2 -1
View File
@@ -10,6 +10,7 @@ import NewArticleModal from '@/components/NewArticleModal.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { ApiError, useApi } from '@/composables/useApi'
import { useArticles, type Article } from '@/composables/useArticles'
import { bodyImageCount } from '@/lib/bodyImageIds'
const SKELETON_DELAY_MS = 150
const SEARCH_DEBOUNCE_MS = 120
@@ -239,7 +240,7 @@ async function handleCopyUrl(id: number) {
}
function deleteDescription(article: Article) {
const count = article.images.length
const count = bodyImageCount(article.body)
const imagesPhrase =
count === 0 ? '' : count === 1 ? ' and its image' : ` and its ${count} images`
return `"${article.title}"${imagesPhrase} will be deleted. This can't be undone.`
+38 -13
View File
@@ -15,6 +15,10 @@ import ImageUploader from '@/components/ImageUploader.vue'
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
import { useArticleDraft } from '@/composables/useArticleDraft'
import type { MediaItem } from '@/composables/useMedia'
import { bodyImageCount, bodyImageIds } from '@/lib/bodyImageIds'
import { formatSize } from '@/lib/mediaFormat'
import { useSeenImagesStore } from '@/stores/seenImages'
const SKELETON_DELAY_MS = 150
const BODY_MAX_LENGTH = 10000
@@ -29,6 +33,7 @@ const toast = useToast()
const articleId = Number(route.params.id)
const draft = useArticleDraft(articleId)
const seenImages = useSeenImagesStore()
const loadState = ref<LoadState>('loading')
const showSkeleton = ref(false)
@@ -39,6 +44,7 @@ const publishPending = ref<string | undefined>(undefined)
const editorRef = ref<InstanceType<typeof ArticleEditor> | null>(null)
const imageUploaderOpen = ref(false)
const imageUploaderFile = ref<File | null>(null)
const imageIdsInArticle = ref<number[]>([])
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
function clearSkeletonTimer() {
@@ -159,7 +165,7 @@ function handleUnpublish() {
}
function deleteDescription() {
const count = article.value?.images.length ?? 0
const count = bodyImageCount(article.value?.body ?? '')
const imagesPhrase =
count === 0 ? '' : count === 1 ? ' and its image' : ` and its ${count} images`
return `"${article.value?.title ?? ''}"${imagesPhrase} will be deleted. This can't be undone.`
@@ -206,6 +212,7 @@ async function handleDelete() {
function openImageUploader(file: File | null) {
imageUploaderFile.value = file
imageIdsInArticle.value = bodyImageIds(draft.body.value)
imageUploaderOpen.value = true
}
@@ -221,8 +228,7 @@ function onEditorDrop(event: DragEvent) {
openImageUploader(file)
}
function onImageUploaded(location: string, imageId: number) {
editorRef.value?.insertImage(location, '', imageId)
function announceImageAdded() {
toast.add({
color: 'success',
icon: 'i-lucide-circle-check',
@@ -231,31 +237,49 @@ function onImageUploaded(location: string, imageId: number) {
})
}
function onImageUploaded(item: MediaItem) {
seenImages.remember([item])
editorRef.value?.insertImage(item.url, '', item.id)
announceImageAdded()
}
function onImageInserted(item: MediaItem, alt: string) {
seenImages.remember([item])
editorRef.value?.insertImage(item.url, alt, item.id)
announceImageAdded()
}
async function performRemoveImage(imageId: number) {
try {
await request(`/v1/article/${articleId}/image/${imageId}`, { method: 'DELETE' })
} catch {
throw new Error("Couldn't remove the image, so it's still there. Try again in a moment.")
}
editorRef.value?.removeImage(imageId)
toast.add({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image removed',
description: 'Save to keep this change.',
description: "It's still in your library.",
})
}
function removalPreview(imageId: number) {
const seen = seenImages.find(imageId)
return {
src: editorRef.value?.imageSrc(imageId) ?? '',
filename: seen?.filename,
size: seen ? formatSize(seen.size) : undefined,
}
}
async function onRequestRemoveImage(imageId: number) {
await overlay
.create(ConfirmDialog, {
props: {
title: 'Remove this image?',
description: "This image will be removed from the article. This can't be undone.",
title: 'Remove this image from the article?',
description:
"It's removed from this article only. The file stays in your library and in any other article that uses it.",
confirmLabel: 'Remove image',
cancelLabel: 'Cancel',
pendingLabel: 'Removing…',
tone: 'danger',
tone: 'neutral',
preview: removalPreview(imageId),
action: () => performRemoveImage(imageId),
},
})
@@ -476,9 +500,10 @@ onBeforeRouteLeave(async () => {
<ImageUploader
v-model:open="imageUploaderOpen"
:article-id="articleId"
:initial-file="imageUploaderFile"
:in-article-ids="imageIdsInArticle"
@uploaded="onImageUploaded"
@inserted="onImageInserted"
@close="onImageUploaderClosed"
/>
+1 -11
View File
@@ -77,7 +77,6 @@ function sixArticleFixture(): Article[] {
id: 1,
title: 'How we price a bathroom renovation',
body: '',
images: [],
is_online: true,
created_at: '2026-03-12T09:14:00Z',
updated_at: iso(2 * HOUR_MS),
@@ -86,7 +85,6 @@ function sixArticleFixture(): Article[] {
id: 2,
title: 'Choosing lime plaster for old stone walls',
body: '',
images: [],
is_online: false,
created_at: '2026-09-09T09:00:00Z',
updated_at: iso(1 * DAY_MS),
@@ -95,7 +93,6 @@ function sixArticleFixture(): Article[] {
id: 3,
title: 'Before and after: the Rue Verte kitchen',
body: '',
images: [],
is_online: true,
created_at: '2026-08-28T09:00:00Z',
updated_at: iso(3 * DAY_MS),
@@ -103,12 +100,7 @@ function sixArticleFixture(): Article[] {
{
id: 4,
title: 'Winter checklist for timber windows',
body: '',
images: [
{ id: 10, url: '/i/10.png' },
{ id: 11, url: '/i/11.png' },
{ id: 12, url: '/i/12.png' },
],
body: '<img src="/i/10.png"><img src="/i/11.png"><img src="/i/12.png">',
is_online: false,
created_at: '2026-09-02T09:00:00Z',
updated_at: iso(5 * DAY_MS),
@@ -117,7 +109,6 @@ function sixArticleFixture(): Article[] {
id: 5,
title: 'Why we reuse original floorboards',
body: '',
images: [],
is_online: true,
created_at: '2026-06-14T09:00:00Z',
updated_at: '2026-08-18T09:00:00Z',
@@ -126,7 +117,6 @@ function sixArticleFixture(): Article[] {
id: 6,
title: 'Our first passive-house retrofit',
body: '',
images: [],
is_online: true,
created_at: '2026-04-03T09:00:00Z',
updated_at: '2026-07-02T09:00:00Z',
@@ -10,6 +10,8 @@ import RootComponent from '@/App.vue'
const FIXED_NOW = '2026-09-26T11:02:00Z'
vi.mock('@/lib/fileHash', () => ({ hashFile: vi.fn(async () => 'a'.repeat(64)) }))
vi.mock('@/lib/imageCrop', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
return {
@@ -118,7 +120,6 @@ function articleFixture(overrides: Partial<Article> = {}): Article {
id: 12,
title: 'How we price a bathroom renovation',
body: '<p>a</p>',
images: [],
is_online: false,
created_at: '2026-03-12T09:14:00Z',
updated_at: FIXED_NOW,
@@ -186,29 +187,39 @@ afterEach(async () => {
await router.push('/login')
})
describe("Opening the uploader from the toolbar's image button shows the idle state", () => {
it('opens ImageUploader idle and sends no request', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
const EMPTY_LIBRARY = { items: [], next_cursor: null, counts: { all: 0, unused: 0, duplicates: 0 } }
function articleHandlers(extra: Record<string, () => Response | Promise<Response>> = {}) {
return {
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'GET /v1/media/?limit=24': () => mockResponse(200, EMPTY_LIBRARY),
...extra,
}
}
describe("Opening the uploader from the toolbar's image button on an empty library shows Upload", () => {
it('opens ImageUploader on the idle Upload tab and sends no POST', async () => {
const wrapper = await mountEditor(articleHandlers())
await toolbarImageButton(wrapper)?.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('Add an image')
expect(document.body.textContent).toContain('Drop an image here')
expect(document.body.textContent).toContain('Drop an image here, or choose a file')
expect(document.body.textContent).toContain('PNG, JPEG, WEBP, SVG · max 10 MB')
expect(findButton('Choose file')).toBeTruthy()
expect(findButton('Cancel')).toBeTruthy()
expect(findButton('Insert image')).toBeUndefined()
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
})
})
describe('Dropping a valid file onto the editor opens the uploader already showing the crop step', () => {
it('opens the crop step, and confirming it sends the resized file', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
describe('Dropping a valid file onto the editor opens the uploader on the crop step', () => {
it('looks the hash up, skips the list, and uploads to /v1/media/ from the crop step', async () => {
const wrapper = await mountEditor(
articleHandlers({
[`GET /v1/media/?hash=${'a'.repeat(64)}`]: () => mockResponse(200, EMPTY_LIBRARY),
}),
)
const file = fileFixture('before.png', 'image/png', 3.9)
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
@@ -219,22 +230,22 @@ describe('Dropping a valid file onto the editor opens the uploader already showi
await flushPromises()
expect(document.body.textContent).toContain('Crop and resize')
expect(document.body.textContent).not.toContain('Drop an image here')
expect(vi.mocked(global.fetch).mock.calls.map(([url]) => url)).not.toContain(
'/v1/media/?limit=24',
)
findButton('Upload image')?.click()
await flushPromises()
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
expect(latestXHR().url).toBe('/v1/article/12/image')
expect(latestXHR().url).toBe('/v1/media/')
expect((latestXHR().sentBody?.get('file') as File).name).toBe('before.png')
})
})
describe('A successful upload inserts the image at the cursor and marks the draft dirty', () => {
it('inserts the image, marks the draft dirty and toasts', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
it('inserts the image with its id, marks the draft dirty and toasts', async () => {
const wrapper = await mountEditor(articleHandlers())
await toolbarImageButton(wrapper)?.trigger('click')
await flushPromises()
@@ -247,20 +258,25 @@ describe('A successful upload inserts the image at the cursor and marks the draf
})
fileInput.dispatchEvent(new Event('change'))
await flushPromises()
findButton('Upload image')?.click()
await flushPromises()
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
latestXHR().resolve(
201,
JSON.stringify({ location: '/static/uploadedImages/north-wall.jpg' }),
JSON.stringify({
id: 13,
filename: 'north-wall.jpg',
url: '/static/uploadedImages/13.jpg',
size: 4089446,
}),
)
await flushPromises()
const insertedBody = wrapper.findComponent(ArticleEditor).props('modelValue')
expect(insertedBody).toContain('/static/uploadedImages/north-wall.jpg')
expect(insertedBody).toContain('<img src="/static/uploadedImages/north-wall.jpg" alt="">')
expect(insertedBody).toContain(
'<img src="/static/uploadedImages/13.jpg" alt="" data-image-id="13">',
)
expect(wrapper.text()).toContain('Unsaved changes')
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
expect(toastAdd).toHaveBeenCalledWith({
@@ -0,0 +1,191 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleEditor from '@/components/ArticleEditor.vue'
import RootComponent from '@/App.vue'
import { fixture } from './mediaTestKit'
const FIXED_NOW = '2026-09-26T11:02:00Z'
const BODY_WITH_TWO_IMAGES =
'<p>Intro</p><img src="/media/north-wall.jpg" alt="" data-image-id="1"><img src="/media/logo-renew.svg" alt="" data-image-id="4">'
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
function articleFixture(body = BODY_WITH_TWO_IMAGES): Article {
return {
id: 12,
title: 'How we price a bathroom renovation',
body,
is_online: false,
created_at: '2026-03-12T09:14:00Z',
updated_at: FIXED_NOW,
}
}
function libraryPage() {
const items = fixture().map((item) =>
item.id === 2 ? { ...item, url: '/static/uploadedImages/2.webp' } : item,
)
return { items, next_cursor: 'c24', counts: { all: 48, unused: 2, duplicates: 2 } }
}
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', () => ({
useOverlay: () => ({ create: vi.fn() }),
useToast: () => ({ add: toastAdd }),
}))
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountEditor(body = BODY_WITH_TWO_IMAGES) {
setActivePinia(createPinia())
toastAdd = vi.fn()
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = vi.fn(async (url: string) => {
if (url === '/v1/article/12') {
return mockResponse(200, articleFixture(body))
}
if (url === '/v1/media/?limit=24') {
return mockResponse(200, libraryPage())
}
throw new Error(`Unexpected fetch call: ${url}`)
}) as unknown as typeof fetch
await router.push('/article/12/edit')
const wrapper = mount(RootComponent, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
currentWrapper = wrapper
await flushPromises()
return wrapper
}
function findButton(text: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === text,
)
}
async function openPicker(wrapper: Awaited<ReturnType<typeof mount>>) {
await wrapper
.findAll('button')
.find((button) => button.text() === 'Image')
?.trigger('click')
await flushPromises()
}
function tile(filename: string) {
return Array.from(document.body.querySelectorAll<HTMLButtonElement>('[data-picker-tile]')).find(
(element) => element.getAttribute('aria-label')?.startsWith(`${filename},`),
)!
}
function taggedIds() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-picker-tile]'))
.filter((element) => element.textContent?.includes('In this article'))
.map((element) => element.getAttribute('data-image-id'))
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
document.body.innerHTML = ''
await router.push('/login')
})
describe('An image is in this article when its node is in the current content', () => {
it('tags images 1 and 4 and no other when the dialog opens', async () => {
const wrapper = await mountEditor()
await openPicker(wrapper)
expect(taggedIds()).toEqual(['1', '4'])
})
it('counts an insertion that has not been saved yet', async () => {
const wrapper = await mountEditor('<p>Intro</p>')
wrapper.findComponent(ArticleEditor).vm.insertImage('/static/uploadedImages/2.webp', '', 2)
await flushPromises()
await openPicker(wrapper)
expect(taggedIds()).toEqual(['2'])
})
it('no longer tags an image that was removed from the body', async () => {
const wrapper = await mountEditor()
wrapper.findComponent(ArticleEditor).vm.removeImage(1)
await flushPromises()
await openPicker(wrapper)
expect(taggedIds()).toEqual(['4'])
})
})
describe('Insert puts the chosen image in the body with its alt text', () => {
it('inserts it at the cursor, marks the draft dirty, toasts and closes the dialog', async () => {
const wrapper = await mountEditor('<p>Intro</p>')
await openPicker(wrapper)
tile('kitchen-after.webp').click()
await flushPromises()
const alt = document.body.querySelector<HTMLInputElement>(
'input[placeholder="e.g. North wall of the kitchen before plastering"]',
)!
alt.value = 'A kitchen with new oak worktops and white tiles'
alt.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
findButton('Insert image')?.click()
await flushPromises()
const body = wrapper.findComponent(ArticleEditor).props('modelValue')
expect(body).toContain(
'<img src="/static/uploadedImages/2.webp" alt="A kitchen with new oak worktops and white tiles" data-image-id="2">',
)
expect(wrapper.text()).toContain('Unsaved changes')
expect(
(wrapper.find('[data-testid="save-button"]').element as HTMLButtonElement).disabled,
).toBe(false)
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image added',
description: 'Save to keep it in the article.',
})
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
const methods = vi
.mocked(global.fetch)
.mock.calls.map(([, init]) => (init as RequestInit | undefined)?.method)
expect(methods.every((method) => method === undefined)).toBe(true)
})
it('leaves the content and the draft alone when the dialog is cancelled', async () => {
const wrapper = await mountEditor('<p>Intro</p>')
await openPicker(wrapper)
tile('kitchen-after.webp').click()
await flushPromises()
findButton('Cancel')?.click()
await flushPromises()
expect(wrapper.findComponent(ArticleEditor).props('modelValue')).not.toContain('<img')
expect(wrapper.text()).not.toContain('Unsaved changes')
expect(toastAdd).not.toHaveBeenCalled()
})
})
+66 -57
View File
@@ -3,6 +3,7 @@ import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useSeenImagesStore } from '@/stores/seenImages'
import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleEditor from '@/components/ArticleEditor.vue'
@@ -615,8 +616,13 @@ async function requestRemoveImage(wrapper: Awaited<ReturnType<typeof mount>>, im
await flushPromises()
}
describe('Requesting removal of an inline image opens the confirm dialog', () => {
it('opens ConfirmDialog with the exact copy', async () => {
function requestCalls(wrapper: Awaited<ReturnType<typeof mount>>) {
void wrapper
return vi.mocked(global.fetch).mock.calls.map(([url, init]) => routeFor(url as string, init))
}
describe('Requesting removal of an inline image opens the neutral confirm dialog', () => {
it('opens ConfirmDialog with the exact copy and sends nothing', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
})
@@ -624,27 +630,76 @@ describe('Requesting removal of an inline image opens the confirm dialog', () =>
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as Record<string, unknown>
expect(props.title).toBe('Remove this image?')
expect(props.title).toBe('Remove this image from the article?')
expect(props.description).toBe(
"This image will be removed from the article. This can't be undone.",
"It's removed from this article only. The file stays in your library and in any other article that uses it.",
)
expect(props.confirmLabel).toBe('Remove image')
expect(props.cancelLabel).toBe('Cancel')
expect(props.pendingLabel).toBe('Removing…')
expect(props.tone).toBe('danger')
expect(props.tone).toBe('neutral')
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12'])
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
})
it('previews the image the session has seen with its filename and size', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
})
useSeenImagesStore().remember([{ id: 10, filename: 'before.png', size: 4089446 }])
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as { preview: Record<string, string> }
expect(props.preview).toEqual({
src: '/static/uploadedImages/before.png',
filename: 'before.png',
size: '3.9 MB',
})
})
it('previews only the thumbnail for an image the session has not seen', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
})
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as { preview: Record<string, string> }
expect(props.preview).toEqual({
src: '/static/uploadedImages/before.png',
filename: undefined,
size: undefined,
})
})
})
describe('Confirming removes the image, marks the draft dirty, and shows a success toast', () => {
it('removes the image from the content and reads Unsaved changes', async () => {
describe('Cancelling the confirm changes nothing', () => {
it('keeps the image and the draft state when the dialog resolves to cancel', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
})
await requestRemoveImage(wrapper)
overlayMock.resolveOpen(false)
await flushPromises()
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
expect(wrapper.text()).not.toContain('Unsaved changes')
expect(toastAdd).not.toHaveBeenCalled()
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12'])
})
})
describe('Confirming removes the image from the body only, marks the draft dirty and toasts', () => {
it('removes the image from the content, reads Unsaved changes and sends no request', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
'DELETE /v1/article/12/image/10': () => mockResponse(204),
})
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
await flushPromises()
expect(articleEditorBody(wrapper)).not.toContain('data-image-id="10"')
expect(wrapper.text()).toContain('Unsaved changes')
@@ -653,55 +708,9 @@ describe('Confirming removes the image, marks the draft dirty, and shows a succe
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image removed',
description: 'Save to keep this change.',
})
})
})
describe('A failed removal keeps the image and shows the dialog inline error', () => {
it.each([
{ name: 'a 404 response', handler: () => mockResponse(404) },
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('rejects with the inline error message for $name', async ({ handler }) => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
'DELETE /v1/article/12/image/10': handler,
})
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as { action: () => Promise<void> }
await expect(props.action()).rejects.toThrow(
"Couldn't remove the image, so it's still there. Try again in a moment.",
)
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
expect(wrapper.text()).not.toContain('Unsaved changes')
})
})
describe('Confirming a removal started by Backspace behaves exactly like confirming one started by the button', () => {
it('removes the image, marks the draft dirty, and shows the same success toast', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
'DELETE /v1/article/12/image/10': () => mockResponse(204),
})
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
expect(articleEditorBody(wrapper)).not.toContain('data-image-id="10"')
expect(wrapper.text()).toContain('Unsaved changes')
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image removed',
description: 'Save to keep this change.',
description: "It's still in your library.",
})
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12'])
})
})