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

@@ -12,7 +12,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
const cacheControl = "public, max-age=31536000, immutable" const cacheControl = "public, no-cache"
const svgContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; sandbox" const svgContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
const fallbackContentType = "application/octet-stream" const fallbackContentType = "application/octet-stream"
+4 -4
View File
@@ -88,7 +88,7 @@ Rows update when the server responds, not optimistically. Search, tab and sort a
- Breadcrumbs: Articles (mobile title "Articles") - Breadcrumbs: Articles (mobile title "Articles")
- Modal: **Delete this article?** - Modal: **Delete this article?**
- text ""Winter checklist for timber windows" and its 3 images will be deleted. This can't be undone." - text ""Winter checklist for timber windows" will be deleted. Its images stay in your library. This can't be undone."
- cancel "Cancel" - cancel "Cancel"
- confirm "Delete article" (danger) - confirm "Delete article" (danger)
@@ -98,7 +98,7 @@ Rows update when the server responds, not optimistically. Search, tab and sort a
- Breadcrumbs: Articles (mobile title "Articles") - Breadcrumbs: Articles (mobile title "Articles")
- Modal: **Delete this article?** - Modal: **Delete this article?**
- text ""Winter checklist for timber windows" and its 3 images will be deleted. This can't be undone." - text ""Winter checklist for timber windows" will be deleted. Its images stay in your library. This can't be undone."
- cancel "Cancel" - cancel "Cancel"
- confirm "Deleting…" (danger), loading, not dismissible - confirm "Deleting…" (danger), loading, not dismissible
@@ -108,7 +108,7 @@ Rows update when the server responds, not optimistically. Search, tab and sort a
- Breadcrumbs: Articles (mobile title "Articles") - Breadcrumbs: Articles (mobile title "Articles")
- Modal: **Delete this article?** - Modal: **Delete this article?**
- text ""Winter checklist for timber windows" and its 3 images will be deleted. This can't be undone." - text ""Winter checklist for timber windows" will be deleted. Its images stay in your library. This can't be undone."
- Alert: "Couldn't delete the article, so it's still there. Try again in a moment." - Alert: "Couldn't delete the article, so it's still there. Try again in a moment."
- cancel "Cancel" - cancel "Cancel"
- confirm "Delete article" (danger) - confirm "Delete article" (danger)
@@ -118,5 +118,5 @@ Rows update when the server responds, not optimistically. Search, tab and sort a
**Trigger:** 204: the row is removed and focus moves to the next row. This is also where a delete from the editor ends up. **Trigger:** 204: the row is removed and focus moves to the next row. This is also where a delete from the editor ends up.
- Breadcrumbs: Articles (mobile title "Articles") - Breadcrumbs: Articles (mobile title "Articles")
- Toast (success, circle-check): **Article deleted** ""Winter checklist for timber windows" and its images are gone." - Toast (success, circle-check): **Article deleted** ""Winter checklist for timber windows" is gone."
- Tab counts (All, Online, Drafts): 5, 4, 1 - Tab counts (All, Online, Drafts): 5, 4, 1
+1 -1
View File
@@ -183,7 +183,7 @@ On mobile, Save and Publish sit in a sticky bottom bar and the details panel ope
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article") - Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Modal: **Delete this article?** - Modal: **Delete this article?**
- text ""How we price a bathroom renovation" and its 3 images will be deleted. This can't be undone." - text ""How we price a bathroom renovation" will be deleted. Its images stay in your library. This can't be undone."
- cancel "Cancel" - cancel "Cancel"
- confirm "Delete article" (danger) - confirm "Delete article" (danger)
- Editor: status "draft", save "saved" - Editor: status "draft", save "saved"
+6 -6
View File
@@ -59,9 +59,9 @@ Feature: Articles list screen
remaining articles, and raises an info toast (icon info, id "article-not-found-{id}"): title remaining articles, and raises an info toast (icon info, id "article-not-found-{id}"): title
"That article no longer exists" text "It was deleted somewhere else, so we removed it from "That article no longer exists" text "It was deleted somewhere else, so we removed it from
the list." the list."
And "Delete…" opens ConfirmDialog: title "Delete this article?" text "\"{title}\" and its And "Delete…" opens ConfirmDialog: title "Delete this article?" text "\"{title}\" will be
{n} images will be deleted. This can't be undone." (a components.md convention: the row's deleted. Its images stay in your library. This can't be undone." (the second sentence is
title plus its image count, "and its image" for exactly 1 and no image clause at all for 0), present only when the article body holds at least one image),
"Cancel" and "Delete article" (danger); while the DELETE request is in flight the dialog "Cancel" and "Delete article" (danger); while the DELETE request is in flight the dialog
cannot be dismissed by Cancel, Esc or a backdrop click, both buttons are disabled, the cannot be dismissed by Cancel, Esc or a backdrop click, both buttons are disabled, the
confirm button reads "Deleting…" with a loading indicator, and aria-busy is set confirm button reads "Deleting…" with a loading indicator, and aria-busy is set
@@ -72,7 +72,7 @@ Feature: Articles list screen
And a successful delete (204) closes the dialog, removes the row, moves focus to the next row And a successful delete (204) closes the dialog, removes the row, moves focus to the next row
(or the page heading if the deleted row was last), recomputes the tab counts, and raises a (or the page heading if the deleted row was last), recomputes the tab counts, and raises a
success toast (icon circle-check, id "article-deleted-{id}"): title "Article deleted" text success toast (icon circle-check, id "article-deleted-{id}"): title "Article deleted" text
"\"{title}\" and its images are gone."; this is also where a delete initiated from the editor "\"{title}\" is gone."; this is also where a delete initiated from the editor
ends up ends up
And the list, sorted by last updated with the most recently updated article first, holds And the list, sorted by last updated with the most recently updated article first, holds
these 6 articles by default: id 1 "How we price a bathroom renovation" (online, updated "2 these 6 articles by default: id 1 "How we price a bathroom renovation" (online, updated "2
@@ -231,7 +231,7 @@ Feature: Articles list screen
Given the Default state has loaded, and id 4 "Winter checklist for timber windows" has 3 images Given the Default state has loaded, and id 4 "Winter checklist for timber windows" has 3 images
When the user chooses "Delete…" from id 4's row menu When the user chooses "Delete…" from id 4's row menu
Then ConfirmDialog opens: title "Delete this article?" text "\"Winter checklist for timber Then ConfirmDialog opens: title "Delete this article?" text "\"Winter checklist for timber
windows\" and its 3 images will be deleted. This can't be undone.", "Cancel" and "Delete windows\" will be deleted. Its images stay in your library. This can't be undone.", "Cancel" and "Delete
article" (danger) article" (danger)
Scenario: Delete in progress — the dialog cannot be dismissed while the request is in flight Scenario: Delete in progress — the dialog cannot be dismissed while the request is in flight
@@ -256,4 +256,4 @@ Feature: Articles list screen
Then the dialog closes, id 4's row is removed from the list, and focus moves to the next row Then the dialog closes, id 4's row is removed from the list, and focus moves to the next row
And the tabs read "All 5" "Online 4" "Drafts 1" And the tabs read "All 5" "Online 4" "Drafts 1"
And a success toast appears: icon circle-check, id "article-deleted-4", title "Article And a success toast appears: icon circle-check, id "article-deleted-4", title "Article
deleted", text "\"Winter checklist for timber windows\" and its images are gone." deleted", text "\"Winter checklist for timber windows\" is gone."
+1 -1
View File
@@ -114,7 +114,7 @@ as listed), unless a scenario's Examples row substitutes a smaller fixture.
18. **Deleted** — continuing from 16 (a fresh attempt), resolve the DELETE 204; assert the dialog 18. **Deleted** — continuing from 16 (a fresh attempt), resolve the DELETE 204; assert the dialog
closes, id 4's row is removed, focus moves to the next row; assert tabs read "All 5" "Online closes, id 4's row is removed, focus moves to the next row; assert tabs read "All 5" "Online
4" "Drafts 1"; assert a toast with id "article-deleted-4", icon circle-check, title "Article 4" "Drafts 1"; assert a toast with id "article-deleted-4", icon circle-check, title "Article
deleted", text "\"Winter checklist for timber windows\" and its images are gone." deleted", text "\"Winter checklist for timber windows\" is gone."
## Visual (manual verification — desktop and mobile, light and dark) ## Visual (manual verification — desktop and mobile, light and dark)
+8 -6
View File
@@ -23,9 +23,11 @@ Feature: Serving uploaded images as static files
"application/octet-stream" "application/octet-stream"
And every response from this handler, success or 404, sets And every response from this handler, success or 404, sets
"X-Content-Type-Options: nosniff" "X-Content-Type-Options: nosniff"
And every successful response sets a long-lived, public Cache-Control And every successful response sets the Cache-Control header
header, e.g. "public, max-age=31536000, immutable", because a stored "public, no-cache", so a browser may store the file but must revalidate
filename is a UUID and its bytes never change it before reusing it; a stored filename is a UUID and its bytes never
change, but the file can be deleted from the media library, and a
long-lived cache would keep showing it
And an "image/svg+xml" response additionally sets And an "image/svg+xml" response additionally sets
"Content-Security-Policy: default-src 'none'; style-src "Content-Security-Policy: default-src 'none'; style-src
'unsafe-inline'; sandbox", so a script embedded in an uploaded SVG 'unsafe-inline'; sandbox", so a script embedded in an uploaded SVG
@@ -42,7 +44,7 @@ Feature: Serving uploaded images as static files
cookie cookie
Then the response status is 200 Then the response status is 200
And the response Content-Type is <content_type> And the response Content-Type is <content_type>
And the response Cache-Control is "public, max-age=31536000, immutable" And the response Cache-Control is "public, no-cache"
And the response X-Content-Type-Options is "nosniff" And the response X-Content-Type-Options is "nosniff"
And the response has no Content-Security-Policy header And the response has no Content-Security-Policy header
And the response body is that file's exact content And the response body is that file's exact content
@@ -61,7 +63,7 @@ Feature: Serving uploaded images as static files
with no jwt cookie with no jwt cookie
Then the response status is 200 Then the response status is 200
And the response Content-Type is "image/svg+xml" And the response Content-Type is "image/svg+xml"
And the response Cache-Control is "public, max-age=31536000, immutable" And the response Cache-Control is "public, no-cache"
And the response X-Content-Type-Options is "nosniff" And the response X-Content-Type-Options is "nosniff"
And the response Content-Security-Policy is And the response Content-Security-Policy is
"default-src 'none'; style-src 'unsafe-inline'; sandbox" "default-src 'none'; style-src 'unsafe-inline'; sandbox"
@@ -128,7 +130,7 @@ Feature: Serving uploaded images as static files
with no jwt cookie with no jwt cookie
Then the response status is 200 Then the response status is 200
And the response Content-Type is "image/webp" And the response Content-Type is "image/webp"
And the response Cache-Control is "public, max-age=31536000, immutable" And the response Cache-Control is "public, no-cache"
And the response X-Content-Type-Options is "nosniff" And the response X-Content-Type-Options is "nosniff"
And the response body is empty And the response body is empty
+1 -1
View File
@@ -29,7 +29,7 @@ Package: `test` (black-box, new file `test/serve_uploaded_images_test.go`).
non-empty byte content. For each, `httptest.NewRequest("GET", non-empty byte content. For each, `httptest.NewRequest("GET",
"/static/uploadedImages/"+name, nil)`, no cookie. Assert status `200`; "/static/uploadedImages/"+name, nil)`, no cookie. Assert status `200`;
`Content-Type` equals the expected value; `Cache-Control` equals `Content-Type` equals the expected value; `Cache-Control` equals
`"public, max-age=31536000, immutable"`; `X-Content-Type-Options` equals `"public, no-cache"`; `X-Content-Type-Options` equals
`"nosniff"`; `Content-Security-Policy` header is absent; body bytes equal `"nosniff"`; `Content-Security-Policy` header is absent; body bytes equal
what was written. what was written.
7. Write a `.svg` file whose content contains an inline `onload` handler 7. Write a `.svg` file whose content contains an inline `onload` handler
+3 -3
View File
@@ -64,7 +64,7 @@ func TestServeUploadedImagesHttp(t *testing.T) {
if got := rec.Header().Get("Content-Type"); got != example.contentType { if got := rec.Header().Get("Content-Type"); got != example.contentType {
t.Fatalf("expected Content-Type %q, got %q", example.contentType, got) t.Fatalf("expected Content-Type %q, got %q", example.contentType, got)
} }
if got := rec.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" { if got := rec.Header().Get("Cache-Control"); got != "public, no-cache" {
t.Fatalf("unexpected Cache-Control: %q", got) t.Fatalf("unexpected Cache-Control: %q", got)
} }
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
@@ -96,7 +96,7 @@ func TestServeUploadedImagesHttp(t *testing.T) {
if got := rec.Header().Get("Content-Type"); got != "image/svg+xml" { if got := rec.Header().Get("Content-Type"); got != "image/svg+xml" {
t.Fatalf("expected Content-Type image/svg+xml, got %q", got) t.Fatalf("expected Content-Type image/svg+xml, got %q", got)
} }
if got := rec.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" { if got := rec.Header().Get("Cache-Control"); got != "public, no-cache" {
t.Fatalf("unexpected Cache-Control: %q", got) t.Fatalf("unexpected Cache-Control: %q", got)
} }
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
@@ -237,7 +237,7 @@ func TestServeUploadedImagesHttp(t *testing.T) {
if got := rec.Header().Get("Content-Type"); got != "image/webp" { if got := rec.Header().Get("Content-Type"); got != "image/webp" {
t.Fatalf("expected Content-Type image/webp, got %q", got) t.Fatalf("expected Content-Type image/webp, got %q", got)
} }
if got := rec.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" { if got := rec.Header().Get("Cache-Control"); got != "public, no-cache" {
t.Fatalf("unexpected Cache-Control: %q", got) t.Fatalf("unexpected Cache-Control: %q", got)
} }
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
+34 -5
View File
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref, watch } from 'vue'
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3' import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
import { articleImageId } from '@/lib/articleImage' import { articleImageId } from '@/lib/articleImage'
import { mediaRevision, withMediaRevision } from '@/lib/mediaRevision'
const props = defineProps(nodeViewProps) 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'] } 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() { function onDeleteClick() {
if (imageId.value === null) { if (imageId.value === null) {
return return
@@ -31,14 +45,29 @@ function onDeleteClick() {
<template> <template>
<NodeViewWrapper <NodeViewWrapper
as="div" as="div"
class="relative block w-fit" class="relative block"
:class="{ 'outline-2 outline-primary rounded-md': selected }" :class="[failed ? 'w-full' : 'w-fit', { 'outline-2 outline-primary rounded-md': selected }]"
data-testid="article-editor-image" data-testid="article-editor-image"
@dragstart.prevent @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 <UButton
v-if="showDeleteButton" v-if="showDeleteButton || (failed && imageId !== null)"
data-testid="remove-image-button" data-testid="remove-image-button"
icon="i-lucide-trash-2" icon="i-lucide-trash-2"
color="neutral" color="neutral"
+9 -2
View File
@@ -2,6 +2,7 @@ import { ref } from 'vue'
import { tryOnBeforeUnmount } from '@vueuse/core' import { tryOnBeforeUnmount } from '@vueuse/core'
import { ApiError, useApi } from '@/composables/useApi' import { ApiError, useApi } from '@/composables/useApi'
import { toRequestQuery, type MediaControls } from '@/lib/mediaQuery' import { toRequestQuery, type MediaControls } from '@/lib/mediaQuery'
import { bumpMediaRevision } from '@/lib/mediaRevision'
export interface MediaItem { export interface MediaItem {
id: number id: number
@@ -150,6 +151,7 @@ export function useMedia() {
async function deleteItem(id: number) { async function deleteItem(id: number) {
await request<void>(`/v1/media/${id}`, { method: 'DELETE' }) await request<void>(`/v1/media/${id}`, { method: 'DELETE' })
bumpMediaRevision()
removeLocal(id) removeLocal(id)
} }
@@ -171,16 +173,21 @@ export function useMedia() {
const pick = (outcome: string) => const pick = (outcome: string) =>
outcomes.filter((entry) => entry.outcome === outcome).map((entry) => entry.item) outcomes.filter((entry) => entry.outcome === outcome).map((entry) => entry.item)
const deleted = pick('deleted') const deleted = pick('deleted')
if (deleted.length > 0) {
bumpMediaRevision()
}
items.value = items.value.filter((entry) => !deleted.some((gone) => gone.id === entry.id)) items.value = items.value.filter((entry) => !deleted.some((gone) => gone.id === entry.id))
return { deleted, failed: pick('failed'), handled: pick('handled').length > 0 } return { deleted, failed: pick('failed'), handled: pick('handled').length > 0 }
} }
function mergeCopies(keepId: number, mergeIds: number[]) { async function mergeCopies(keepId: number, mergeIds: number[]) {
return request<MediaMergeResult>('/v1/media/merge', { const result = await request<MediaMergeResult>('/v1/media/merge', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep_id: keepId, merge_ids: mergeIds }), body: JSON.stringify({ keep_id: keepId, merge_ids: mergeIds }),
}) })
bumpMediaRevision()
return result
} }
function removeLocalMany(ids: number[]) { function removeLocalMany(ids: number[]) {
@@ -4,6 +4,7 @@ import { mount } from '@vue/test-utils'
import { Editor, EditorContent } from '@tiptap/vue-3' import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import { ArticleImage, findArticleImagePos } from '../articleImage' import { ArticleImage, findArticleImagePos } from '../articleImage'
import { bumpMediaRevision } from '../mediaRevision'
function mountHarness(onRequestRemove = vi.fn()) { function mountHarness(onRequestRemove = vi.fn()) {
let editor: Editor let editor: Editor
@@ -263,3 +264,68 @@ describe('removeImage removes the matching image node from the document', () =>
expect(findArticleImagePos(editor, 11)).not.toBeNull() 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', () => { describe('Indenting inside a list item sinks it instead of touching the paragraph', () => {
it('nests the list item under the previous one', () => { it('nests the list item under the previous one', () => {
const editor = createEditor( const editor = createEditor('<ul><li><p>First</p></li><li><p>Second</p></li></ul>')
'<ul><li><p>First</p></li><li><p>Second</p></li></ul>',
)
editor.commands.setTextSelection(editor.state.doc.content.size - 2) editor.commands.setTextSelection(editor.state.doc.content.size - 2)
const indented = editor.commands.indent() 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', () => { describe('formatRelativeDate', () => {
it('formats under an hour in minutes', () => { it('formats under an hour in minutes', () => {
expect(formatRelativeDate('2026-08-25T11:45:00Z', now)).toBe( expect(formatRelativeDate('2026-08-25T11:45:00Z', now)).toBe('15 minutes ago')
'15 minutes ago',
)
}) })
it('clamps a sub-minute difference to "1 minute ago"', () => { it('clamps a sub-minute difference to "1 minute ago"', () => {
expect(formatRelativeDate('2026-08-25T11:59:45Z', now)).toBe( expect(formatRelativeDate('2026-08-25T11:59:45Z', now)).toBe('1 minute ago')
'1 minute ago',
)
}) })
it('uses the singular for exactly one minute ago', () => { it('uses the singular for exactly one minute ago', () => {
expect(formatRelativeDate('2026-08-25T11:59:00Z', now)).toBe( expect(formatRelativeDate('2026-08-25T11:59:00Z', now)).toBe('1 minute ago')
'1 minute ago',
)
}) })
it('formats under a day in hours', () => { it('formats under a day in hours', () => {
expect(formatRelativeDate('2026-08-25T10:00:00Z', now)).toBe( expect(formatRelativeDate('2026-08-25T10:00:00Z', now)).toBe('2 hours ago')
'2 hours ago',
)
}) })
it('uses the singular for exactly one hour ago', () => { it('uses the singular for exactly one hour ago', () => {
expect(formatRelativeDate('2026-08-25T11:00:00Z', now)).toBe( expect(formatRelativeDate('2026-08-25T11:00:00Z', now)).toBe('1 hour ago')
'1 hour ago',
)
}) })
it('formats exactly one day back as Yesterday', () => { it('formats exactly one day back as Yesterday', () => {
expect(formatRelativeDate('2026-08-24T11:00:00Z', now)).toBe( expect(formatRelativeDate('2026-08-24T11:00:00Z', now)).toBe('Yesterday')
'Yesterday',
)
}) })
it('formats within the week in days', () => { it('formats within the week in days', () => {
expect(formatRelativeDate('2026-08-22T12:00:00Z', now)).toBe( expect(formatRelativeDate('2026-08-22T12:00:00Z', now)).toBe('3 days ago')
'3 days ago',
)
}) })
it('formats exactly 7 days ago as an absolute date, not "7 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( expect(formatRelativeDate('2026-08-18T12:00:00Z', now)).toBe('18 Aug 2026')
'18 Aug 2026',
)
}) })
it('formats a week or more ago as an absolute date', () => { it('formats a week or more ago as an absolute date', () => {
expect(formatRelativeDate('2026-08-10T12:00:00Z', now)).toBe( expect(formatRelativeDate('2026-08-10T12:00:00Z', now)).toBe('10 Aug 2026')
'10 Aug 2026',
)
}) })
}) })
+5 -19
View File
@@ -12,9 +12,7 @@ declare module '@tiptap/core' {
} }
} }
function currentIndentableNode(state: { function currentIndentableNode(state: { selection: { $from: { parent: unknown } } }) {
selection: { $from: { parent: unknown } }
}) {
return state.selection.$from.parent as { return state.selection.$from.parent as {
type: { name: string } type: { name: string }
attrs: { indent?: number } attrs: { indent?: number }
@@ -30,8 +28,7 @@ export const ArticleIndent = Extension.create({
attributes: { attributes: {
indent: { indent: {
default: 0, default: 0,
parseHTML: (element) => parseHTML: (element) => Number(element.getAttribute('data-indent')) || 0,
Number(element.getAttribute('data-indent')) || 0,
renderHTML: (attributes) => { renderHTML: (attributes) => {
if (!attributes.indent) { if (!attributes.indent) {
return {} return {}
@@ -55,17 +52,10 @@ export const ArticleIndent = Extension.create({
if (!INDENT_TYPES.includes(node.type.name)) { if (!INDENT_TYPES.includes(node.type.name)) {
return false return false
} }
const level = Math.min( const level = Math.min(INDENT_MAX, (node.attrs.indent ?? 0) + 1)
INDENT_MAX,
(node.attrs.indent ?? 0) + 1,
)
if (dispatch) { if (dispatch) {
const { $from } = state.selection const { $from } = state.selection
tr.setNodeAttribute( tr.setNodeAttribute($from.before($from.depth), 'indent', level)
$from.before($from.depth),
'indent',
level,
)
dispatch(tr) dispatch(tr)
} }
return true return true
@@ -83,11 +73,7 @@ export const ArticleIndent = Extension.create({
const level = Math.max(0, (node.attrs.indent ?? 0) - 1) const level = Math.max(0, (node.attrs.indent ?? 0) - 1)
if (dispatch) { if (dispatch) {
const { $from } = state.selection const { $from } = state.selection
tr.setNodeAttribute( tr.setNodeAttribute($from.before($from.depth), 'indent', level)
$from.before($from.depth),
'indent',
level,
)
dispatch(tr) dispatch(tr)
} }
return true 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) { function deleteDescription(article: Article) {
const count = bodyImageCount(article.body) const count = bodyImageCount(article.body)
const imagesPhrase = const imagesPhrase =
count === 0 ? '' : count === 1 ? ' and its image' : ` and its ${count} images` count === 0 ? '' : ' Its images stay in your library.'
return `"${article.title}"${imagesPhrase} will be deleted. This can't be undone.` return `"${article.title}" will be deleted.${imagesPhrase} This can't be undone.`
} }
async function performDelete(article: Article) { async function performDelete(article: Article) {
@@ -255,7 +255,7 @@ async function performDelete(article: Article) {
color: 'success', color: 'success',
icon: 'i-lucide-circle-check', icon: 'i-lucide-circle-check',
title: 'Article deleted', title: 'Article deleted',
description: `"${article.title}" and its images are gone.`, description: `"${article.title}" is gone.`,
}) })
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 404) { if (err instanceof ApiError && err.status === 404) {
+3 -3
View File
@@ -167,8 +167,8 @@ function handleUnpublish() {
function deleteDescription() { function deleteDescription() {
const count = bodyImageCount(article.value?.body ?? '') const count = bodyImageCount(article.value?.body ?? '')
const imagesPhrase = const imagesPhrase =
count === 0 ? '' : count === 1 ? ' and its image' : ` and its ${count} images` count === 0 ? '' : ' Its images stay in your library.'
return `"${article.value?.title ?? ''}"${imagesPhrase} will be deleted. This can't be undone.` return `"${article.value?.title ?? ''}" will be deleted.${imagesPhrase} This can't be undone.`
} }
async function performDelete() { async function performDelete() {
@@ -179,7 +179,7 @@ async function performDelete() {
color: 'success', color: 'success',
icon: 'i-lucide-circle-check', icon: 'i-lucide-circle-check',
title: 'Article deleted', title: 'Article deleted',
description: `"${article.value?.title ?? ''}" and its images are gone.`, description: `"${article.value?.title ?? ''}" is gone.`,
}) })
router.push('/article') router.push('/article')
} catch (err) { } 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> const props = overlayMock.getProps() as Record<string, unknown>
expect(props.title).toBe('Delete this article?') expect(props.title).toBe('Delete this article?')
expect(props.description).toBe( 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.confirmLabel).toBe('Delete article')
expect(props.cancelLabel).toBe('Cancel') expect(props.cancelLabel).toBe('Cancel')
@@ -632,7 +632,7 @@ describe('Deleted — a 204 closes the dialog, removes the row, and moves focus
color: 'success', color: 'success',
icon: 'i-lucide-circle-check', icon: 'i-lucide-circle-check',
title: 'Article deleted', title: 'Article deleted',
description: '"Winter checklist for timber windows" and its images are gone.', description: '"Winter checklist for timber windows" is gone.',
}) })
}) })
}) })