fix: deleted image wrongfully caching
This commit is contained in:
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"
|
||||
)
|
||||
|
||||
const cacheControl = "public, max-age=31536000, immutable"
|
||||
const cacheControl = "public, no-cache"
|
||||
const svgContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
|
||||
const fallbackContentType = "application/octet-stream"
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ Rows update when the server responds, not optimistically. Search, tab and sort a
|
||||
|
||||
- Breadcrumbs: Articles (mobile title "Articles")
|
||||
- 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"
|
||||
- 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")
|
||||
- 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"
|
||||
- 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")
|
||||
- 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."
|
||||
- cancel "Cancel"
|
||||
- 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.
|
||||
|
||||
- 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
|
||||
@@ -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")
|
||||
- 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"
|
||||
- confirm "Delete article" (danger)
|
||||
- Editor: status "draft", save "saved"
|
||||
|
||||
@@ -59,9 +59,9 @@ Feature: Articles list screen
|
||||
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
|
||||
the list."
|
||||
And "Delete…" opens ConfirmDialog: title "Delete this article?" text "\"{title}\" and its
|
||||
{n} images will be deleted. This can't be undone." (a components.md convention: the row's
|
||||
title plus its image count, "and its image" for exactly 1 and no image clause at all for 0),
|
||||
And "Delete…" opens ConfirmDialog: title "Delete this article?" text "\"{title}\" will be
|
||||
deleted. Its images stay in your library. This can't be undone." (the second sentence is
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
(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
|
||||
"\"{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
|
||||
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
|
||||
@@ -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
|
||||
When the user chooses "Delete…" from id 4's row menu
|
||||
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)
|
||||
|
||||
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
|
||||
And the tabs read "All 5" "Online 4" "Drafts 1"
|
||||
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."
|
||||
@@ -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
|
||||
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
|
||||
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)
|
||||
|
||||
|
||||
@@ -23,9 +23,11 @@ Feature: Serving uploaded images as static files
|
||||
"application/octet-stream"
|
||||
And every response from this handler, success or 404, sets
|
||||
"X-Content-Type-Options: nosniff"
|
||||
And every successful response sets a long-lived, public Cache-Control
|
||||
header, e.g. "public, max-age=31536000, immutable", because a stored
|
||||
filename is a UUID and its bytes never change
|
||||
And every successful response sets the Cache-Control header
|
||||
"public, no-cache", so a browser may store the file but must revalidate
|
||||
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
|
||||
"Content-Security-Policy: default-src 'none'; style-src
|
||||
'unsafe-inline'; sandbox", so a script embedded in an uploaded SVG
|
||||
@@ -42,7 +44,7 @@ Feature: Serving uploaded images as static files
|
||||
cookie
|
||||
Then the response status is 200
|
||||
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 has no Content-Security-Policy header
|
||||
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
|
||||
Then the response status is 200
|
||||
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 Content-Security-Policy is
|
||||
"default-src 'none'; style-src 'unsafe-inline'; sandbox"
|
||||
@@ -128,7 +130,7 @@ Feature: Serving uploaded images as static files
|
||||
with no jwt cookie
|
||||
Then the response status is 200
|
||||
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 body is empty
|
||||
|
||||
|
||||
@@ -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",
|
||||
"/static/uploadedImages/"+name, nil)`, no cookie. Assert status `200`;
|
||||
`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
|
||||
what was written.
|
||||
7. Write a `.svg` file whose content contains an inline `onload` handler
|
||||
|
||||
@@ -64,7 +64,7 @@ func TestServeUploadedImagesHttp(t *testing.T) {
|
||||
if got := rec.Header().Get("Content-Type"); got != example.contentType {
|
||||
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)
|
||||
}
|
||||
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" {
|
||||
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)
|
||||
}
|
||||
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" {
|
||||
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)
|
||||
}
|
||||
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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=')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user