fix: deleted image wrongfully caching

This commit is contained in:
fsylvain committed 2026-10-01 05:27:25 +02:00
1 parent b3793d5dbb
commit ff0854f331
19 files changed
+201 -87

No files matched your search

+34 -5
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
import { articleImageId } from '@/lib/articleImage'
import { mediaRevision, withMediaRevision } from '@/lib/mediaRevision'
const props = defineProps(nodeViewProps)
@@ -20,6 +21,19 @@ const imageAttrs = computed(() => {
return { src, alt, title, width, height, 'data-image-id': props.node.attrs['data-image-id'] }
})
const failed = ref(false)
const displayAttrs = computed(() => ({
...imageAttrs.value,
src: withMediaRevision(imageAttrs.value.src, mediaRevision.value),
}))
watch(
() => displayAttrs.value.src,
() => {
failed.value = false
},
)
function onDeleteClick() {
if (imageId.value === null) {
return
@@ -31,14 +45,29 @@ function onDeleteClick() {
<template>
<NodeViewWrapper
as="div"
class="relative block w-fit"
:class="{ 'outline-2 outline-primary rounded-md': selected }"
class="relative block"
:class="[failed ? 'w-full' : 'w-fit', { 'outline-2 outline-primary rounded-md': selected }]"
data-testid="article-editor-image"
@dragstart.prevent
>
<img v-bind="imageAttrs" draggable="false" class="block max-w-full" />
<div
v-if="failed"
data-testid="article-editor-image-missing"
class="flex min-h-40 flex-col items-center justify-center gap-2 border border-dashed border-accented bg-elevated p-6 text-center font-sans text-sm text-muted"
>
<UIcon name="i-lucide-image-off" class="size-6" />
<span class="font-medium text-highlighted">This image is no longer available</span>
<span>It was deleted from the library. Remove it from the article.</span>
</div>
<img
v-else
v-bind="displayAttrs"
draggable="false"
class="block max-w-full"
@error="failed = true"
/>
<UButton
v-if="showDeleteButton"
v-if="showDeleteButton || (failed && imageId !== null)"
data-testid="remove-image-button"
icon="i-lucide-trash-2"
color="neutral"
+9 -2
View File
@@ -2,6 +2,7 @@ import { ref } from 'vue'
import { tryOnBeforeUnmount } from '@vueuse/core'
import { ApiError, useApi } from '@/composables/useApi'
import { toRequestQuery, type MediaControls } from '@/lib/mediaQuery'
import { bumpMediaRevision } from '@/lib/mediaRevision'
export interface MediaItem {
id: number
@@ -150,6 +151,7 @@ export function useMedia() {
async function deleteItem(id: number) {
await request<void>(`/v1/media/${id}`, { method: 'DELETE' })
bumpMediaRevision()
removeLocal(id)
}
@@ -171,16 +173,21 @@ export function useMedia() {
const pick = (outcome: string) =>
outcomes.filter((entry) => entry.outcome === outcome).map((entry) => entry.item)
const deleted = pick('deleted')
if (deleted.length > 0) {
bumpMediaRevision()
}
items.value = items.value.filter((entry) => !deleted.some((gone) => gone.id === entry.id))
return { deleted, failed: pick('failed'), handled: pick('handled').length > 0 }
}
function mergeCopies(keepId: number, mergeIds: number[]) {
return request<MediaMergeResult>('/v1/media/merge', {
async function mergeCopies(keepId: number, mergeIds: number[]) {
const result = await request<MediaMergeResult>('/v1/media/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep_id: keepId, merge_ids: mergeIds }),
})
bumpMediaRevision()
return result
}
function removeLocalMany(ids: number[]) {
@@ -4,6 +4,7 @@ import { mount } from '@vue/test-utils'
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import { ArticleImage, findArticleImagePos } from '../articleImage'
import { bumpMediaRevision } from '../mediaRevision'
function mountHarness(onRequestRemove = vi.fn()) {
let editor: Editor
@@ -263,3 +264,68 @@ describe('removeImage removes the matching image node from the document', () =>
expect(findArticleImagePos(editor, 11)).not.toBeNull()
})
})
describe('An image that fails to load', () => {
it('shows a placeholder and keeps the remove button reachable without selecting it', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({
src: '/static/uploadedImages/gone.png',
alt: '',
imageId: 10,
})
await flush()
document.body.querySelector('img')!.dispatchEvent(new Event('error'))
await flush()
expect(document.body.querySelector('img')).toBeNull()
expect(
document.body.querySelector('[data-testid="article-editor-image-missing"]')
?.textContent,
).toContain('This image is no longer available')
expect(deleteButton()).toBeTruthy()
})
it('has no remove button for an old image without an id, but still shows the placeholder', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: '/static/uploadedImages/gone.png', alt: '' })
await flush()
document.body.querySelector('img')!.dispatchEvent(new Event('error'))
await flush()
expect(
document.body.querySelector('[data-testid="article-editor-image-missing"]'),
).toBeTruthy()
expect(deleteButton()).toBeNull()
})
})
describe('An image shown after a media delete or merge', () => {
it('asks the server again by adding a display-only revision to uploaded image URLs', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({
src: '/static/uploadedImages/a.png',
alt: '',
imageId: 10,
})
await flush()
expect(document.body.querySelector('img')?.getAttribute('src')).toBe(
'/static/uploadedImages/a.png',
)
bumpMediaRevision()
await flush()
expect(document.body.querySelector('img')?.getAttribute('src')).toBe(
'/static/uploadedImages/a.png?rev=1',
)
expect(editor.getHTML()).not.toContain('rev=')
})
})
+1 -3
View File
@@ -54,9 +54,7 @@ describe('Outdenting a paragraph decreases the indent level', () => {
describe('Indenting inside a list item sinks it instead of touching the paragraph', () => {
it('nests the list item under the previous one', () => {
const editor = createEditor(
'<ul><li><p>First</p></li><li><p>Second</p></li></ul>',
)
const editor = createEditor('<ul><li><p>First</p></li><li><p>Second</p></li></ul>')
editor.commands.setTextSelection(editor.state.doc.content.size - 2)
const indented = editor.commands.indent()
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { withMediaRevision } from '../mediaRevision'
describe('withMediaRevision', () => {
it('leaves a URL alone while no media was deleted or merged', () => {
expect(withMediaRevision('/static/uploadedImages/a.png', 0)).toBe(
'/static/uploadedImages/a.png',
)
})
it('adds the revision to relative and absolute uploaded image URLs', () => {
expect(withMediaRevision('/static/uploadedImages/a.png', 2)).toBe(
'/static/uploadedImages/a.png?rev=2',
)
expect(
withMediaRevision('http://localhost:5173/static/uploadedImages/a.png?x=1#top', 3),
).toBe('http://localhost:5173/static/uploadedImages/a.png?x=1&rev=3#top')
})
it('never touches other URLs or an empty source', () => {
expect(withMediaRevision('https://example.com/a.png', 2)).toBe('https://example.com/a.png')
expect(withMediaRevision(undefined, 2)).toBeUndefined()
})
})
+9 -27
View File
@@ -5,56 +5,38 @@ const now = new Date('2026-08-25T12:00:00Z')
describe('formatRelativeDate', () => {
it('formats under an hour in minutes', () => {
expect(formatRelativeDate('2026-08-25T11:45:00Z', now)).toBe(
'15 minutes ago',
)
expect(formatRelativeDate('2026-08-25T11:45:00Z', now)).toBe('15 minutes ago')
})
it('clamps a sub-minute difference to "1 minute ago"', () => {
expect(formatRelativeDate('2026-08-25T11:59:45Z', now)).toBe(
'1 minute ago',
)
expect(formatRelativeDate('2026-08-25T11:59:45Z', now)).toBe('1 minute ago')
})
it('uses the singular for exactly one minute ago', () => {
expect(formatRelativeDate('2026-08-25T11:59:00Z', now)).toBe(
'1 minute ago',
)
expect(formatRelativeDate('2026-08-25T11:59:00Z', now)).toBe('1 minute ago')
})
it('formats under a day in hours', () => {
expect(formatRelativeDate('2026-08-25T10:00:00Z', now)).toBe(
'2 hours ago',
)
expect(formatRelativeDate('2026-08-25T10:00:00Z', now)).toBe('2 hours ago')
})
it('uses the singular for exactly one hour ago', () => {
expect(formatRelativeDate('2026-08-25T11:00:00Z', now)).toBe(
'1 hour ago',
)
expect(formatRelativeDate('2026-08-25T11:00:00Z', now)).toBe('1 hour ago')
})
it('formats exactly one day back as Yesterday', () => {
expect(formatRelativeDate('2026-08-24T11:00:00Z', now)).toBe(
'Yesterday',
)
expect(formatRelativeDate('2026-08-24T11:00:00Z', now)).toBe('Yesterday')
})
it('formats within the week in days', () => {
expect(formatRelativeDate('2026-08-22T12:00:00Z', now)).toBe(
'3 days ago',
)
expect(formatRelativeDate('2026-08-22T12:00:00Z', now)).toBe('3 days ago')
})
it('formats exactly 7 days ago as an absolute date, not "7 days ago"', () => {
expect(formatRelativeDate('2026-08-18T12:00:00Z', now)).toBe(
'18 Aug 2026',
)
expect(formatRelativeDate('2026-08-18T12:00:00Z', now)).toBe('18 Aug 2026')
})
it('formats a week or more ago as an absolute date', () => {
expect(formatRelativeDate('2026-08-10T12:00:00Z', now)).toBe(
'10 Aug 2026',
)
expect(formatRelativeDate('2026-08-10T12:00:00Z', now)).toBe('10 Aug 2026')
})
})
+5 -19
View File
@@ -12,9 +12,7 @@ declare module '@tiptap/core' {
}
}
function currentIndentableNode(state: {
selection: { $from: { parent: unknown } }
}) {
function currentIndentableNode(state: { selection: { $from: { parent: unknown } } }) {
return state.selection.$from.parent as {
type: { name: string }
attrs: { indent?: number }
@@ -30,8 +28,7 @@ export const ArticleIndent = Extension.create({
attributes: {
indent: {
default: 0,
parseHTML: (element) =>
Number(element.getAttribute('data-indent')) || 0,
parseHTML: (element) => Number(element.getAttribute('data-indent')) || 0,
renderHTML: (attributes) => {
if (!attributes.indent) {
return {}
@@ -55,17 +52,10 @@ export const ArticleIndent = Extension.create({
if (!INDENT_TYPES.includes(node.type.name)) {
return false
}
const level = Math.min(
INDENT_MAX,
(node.attrs.indent ?? 0) + 1,
)
const level = Math.min(INDENT_MAX, (node.attrs.indent ?? 0) + 1)
if (dispatch) {
const { $from } = state.selection
tr.setNodeAttribute(
$from.before($from.depth),
'indent',
level,
)
tr.setNodeAttribute($from.before($from.depth), 'indent', level)
dispatch(tr)
}
return true
@@ -83,11 +73,7 @@ export const ArticleIndent = Extension.create({
const level = Math.max(0, (node.attrs.indent ?? 0) - 1)
if (dispatch) {
const { $from } = state.selection
tr.setNodeAttribute(
$from.before($from.depth),
'indent',
level,
)
tr.setNodeAttribute($from.before($from.depth), 'indent', level)
dispatch(tr)
}
return true
+20
View File
@@ -0,0 +1,20 @@
import { ref } from 'vue'
const UPLOADED_IMAGES_PATH = '/static/uploadedImages/'
export const mediaRevision = ref(0)
export function bumpMediaRevision() {
mediaRevision.value += 1
}
export function withMediaRevision(src: string | undefined, revision = mediaRevision.value) {
if (!src || revision === 0 || !src.includes(UPLOADED_IMAGES_PATH)) {
return src
}
const hashAt = src.indexOf('#')
const base = hashAt === -1 ? src : src.slice(0, hashAt)
const hash = hashAt === -1 ? '' : src.slice(hashAt)
const separator = base.includes('?') ? '&' : '?'
return `${base}${separator}rev=${revision}${hash}`
}
+3 -3
View File
@@ -242,8 +242,8 @@ async function handleCopyUrl(id: number) {
function deleteDescription(article: Article) {
const count = bodyImageCount(article.body)
const imagesPhrase =
count === 0 ? '' : count === 1 ? ' and its image' : ` and its ${count} images`
return `"${article.title}"${imagesPhrase} will be deleted. This can't be undone.`
count === 0 ? '' : ' Its images stay in your library.'
return `"${article.title}" will be deleted.${imagesPhrase} This can't be undone.`
}
async function performDelete(article: Article) {
@@ -255,7 +255,7 @@ async function performDelete(article: Article) {
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Article deleted',
description: `"${article.title}" and its images are gone.`,
description: `"${article.title}" is gone.`,
})
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
+3 -3
View File
@@ -167,8 +167,8 @@ function handleUnpublish() {
function deleteDescription() {
const count = bodyImageCount(article.value?.body ?? '')
const imagesPhrase =
count === 0 ? '' : count === 1 ? ' and its image' : ` and its ${count} images`
return `"${article.value?.title ?? ''}"${imagesPhrase} will be deleted. This can't be undone.`
count === 0 ? '' : ' Its images stay in your library.'
return `"${article.value?.title ?? ''}" will be deleted.${imagesPhrase} This can't be undone.`
}
async function performDelete() {
@@ -179,7 +179,7 @@ async function performDelete() {
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Article deleted',
description: `"${article.value?.title ?? ''}" and its images are gone.`,
description: `"${article.value?.title ?? ''}" is gone.`,
})
router.push('/article')
} catch (err) {
+2 -2
View File
@@ -599,7 +599,7 @@ describe('Delete confirm — opens the dialog with the row title and image count
const props = overlayMock.getProps() as Record<string, unknown>
expect(props.title).toBe('Delete this article?')
expect(props.description).toBe(
'"Winter checklist for timber windows" and its 3 images will be deleted. This can\'t be undone.',
'"Winter checklist for timber windows" will be deleted. Its images stay in your library. This can\'t be undone.',
)
expect(props.confirmLabel).toBe('Delete article')
expect(props.cancelLabel).toBe('Cancel')
@@ -632,7 +632,7 @@ describe('Deleted — a 204 closes the dialog, removes the row, and moves focus
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Article deleted',
description: '"Winter checklist for timber windows" and its images are gone.',
description: '"Winter checklist for timber windows" is gone.',
})
})
})