feat: media picker and image removal
This commit is contained in:
1 parent
008b0dfbb9
commit
b3793d5dbb
54 files changed
+3769
-1412
No files matched your search
@@ -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.'
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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()
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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 }))
|
||||
}
|
||||
Reference in new issue
Block a user