a
', + 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: 'a

',
+ })
+
+ 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()
+ })
+})
diff --git a/web/src/components/__tests__/ArticleEditor.spec.ts b/web/src/components/__tests__/ArticleEditor.spec.ts
index 04da746..83635c2 100644
--- a/web/src/components/__tests__/ArticleEditor.spec.ts
+++ b/web/src/components/__tests__/ArticleEditor.spec.ts
@@ -34,7 +34,6 @@ async function mountArticleEditor(body = 'Hello
') { 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( + 'Hello
',
+ )
+ 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()
+ })
+})
diff --git a/web/src/components/__tests__/ConfirmDialog.spec.ts b/web/src/components/__tests__/ConfirmDialog.spec.ts
index c05edca..eb32d3c 100644
--- a/web/src/components/__tests__/ConfirmDialog.spec.ts
+++ b/web/src/components/__tests__/ConfirmDialog.spec.ts
@@ -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()
})
})
diff --git a/web/src/components/__tests__/ImageUploader.spec.ts b/web/src/components/__tests__/ImageUploader.spec.ts
index 055b40d..cad0606 100644
--- a/web/src/components/__tests__/ImageUploader.spec.ts
+++ b/web/src/components/__tests__/ImageUploader.spec.ts
@@ -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 importOriginala

b


Hello
')).toEqual([]) + }) +}) diff --git a/web/src/lib/bodyImageIds.ts b/web/src/lib/bodyImageIds.ts new file mode 100644 index 0000000..436d33f --- /dev/null +++ b/web/src/lib/bodyImageIds.ts @@ -0,0 +1,13 @@ +const IMAGE_ID_SELECTOR = 'img[data-image-id]' +const IMAGE_ID_ATTRIBUTE = 'data-image-id' + +export function bodyImageCount(html: string): number { + return new DOMParser().parseFromString(html, 'text/html').querySelectorAll('img').length +} + +export function bodyImageIds(html: string): number[] { + const document = new DOMParser().parseFromString(html, 'text/html') + return Array.from(document.querySelectorAll(IMAGE_ID_SELECTOR)) + .map((image) => Number.parseInt(image.getAttribute(IMAGE_ID_ATTRIBUTE) ?? '', 10)) + .filter((id) => !Number.isNaN(id)) +} diff --git a/web/src/lib/mediaFormat.ts b/web/src/lib/mediaFormat.ts index 7471269..041b91f 100644 --- a/web/src/lib/mediaFormat.ts +++ b/web/src/lib/mediaFormat.ts @@ -76,3 +76,11 @@ export function joinNames(names: string[]) { } return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}` } + +export function libraryMatchText( + filename: string, + existing: { created_at: string; uploaded_by: string }, +) { + const by = existing.uploaded_by ? ` by ${existing.uploaded_by}` : '' + return `${filename} matches an image uploaded on ${formatMediaDate(existing.created_at)}${by}.` +} diff --git a/web/src/lib/tabStyles.ts b/web/src/lib/tabStyles.ts new file mode 100644 index 0000000..f9748f4 --- /dev/null +++ b/web/src/lib/tabStyles.ts @@ -0,0 +1,2 @@ +export const UNDERLINE_TAB_TRIGGER = + 'h-9 px-0 py-0 data-[state=inactive]:text-toned in-[[data-slot=list]:not(:has([data-slot=indicator]))]:data-[state=active]:after:bottom-0 in-[[data-slot=list]:not(:has([data-slot=indicator]))]:data-[state=active]:after:h-0.5 max-md:h-11 max-md:text-[0.9375rem]' diff --git a/web/src/stores/__tests__/seenImages.spec.ts b/web/src/stores/__tests__/seenImages.spec.ts new file mode 100644 index 0000000..db412a2 --- /dev/null +++ b/web/src/stores/__tests__/seenImages.spec.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useSeenImagesStore } from '../seenImages' + +beforeEach(() => { + setActivePinia(createPinia()) +}) + +describe('seenImages store', () => { + it('finds a remembered image by id', () => { + const store = useSeenImagesStore() + + store.remember([{ id: 1, filename: 'north-wall.jpg', size: 4089446, extra: true } as never]) + + expect(store.find(1)).toEqual({ id: 1, filename: 'north-wall.jpg', size: 4089446 }) + }) + + it('knows nothing about an image it has not seen', () => { + expect(useSeenImagesStore().find(7)).toBeNull() + }) + + it('keeps the latest record of an image', () => { + const store = useSeenImagesStore() + store.remember([{ id: 1, filename: 'north-wall.jpg', size: 10 }]) + + store.remember([{ id: 1, filename: 'renamed.jpg', size: 10 }]) + + expect(store.find(1)?.filename).toBe('renamed.jpg') + }) +}) diff --git a/web/src/stores/seenImages.ts b/web/src/stores/seenImages.ts new file mode 100644 index 0000000..be2e76f --- /dev/null +++ b/web/src/stores/seenImages.ts @@ -0,0 +1,24 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export interface SeenImage { + id: number + filename: string + size: number +} + +export const useSeenImagesStore = defineStore('seenImages', () => { + const byId = ref

',
is_online: false,
created_at: '2026-09-02T09:00:00Z',
updated_at: iso(5 * DAY_MS),
@@ -117,7 +109,6 @@ function sixArticleFixture(): Article[] {
id: 5,
title: 'Why we reuse original floorboards',
body: '',
- images: [],
is_online: true,
created_at: '2026-06-14T09:00:00Z',
updated_at: '2026-08-18T09:00:00Z',
@@ -126,7 +117,6 @@ function sixArticleFixture(): Article[] {
id: 6,
title: 'Our first passive-house retrofit',
body: '',
- images: [],
is_online: true,
created_at: '2026-04-03T09:00:00Z',
updated_at: '2026-07-02T09:00:00Z',
diff --git a/web/src/views/__tests__/EditorImageUpload.spec.ts b/web/src/views/__tests__/EditorImageUpload.spec.ts
index 9959d93..266f185 100644
--- a/web/src/views/__tests__/EditorImageUpload.spec.ts
+++ b/web/src/views/__tests__/EditorImageUpload.spec.ts
@@ -10,6 +10,8 @@ import RootComponent from '@/App.vue'
const FIXED_NOW = '2026-09-26T11:02:00Z'
+vi.mock('@/lib/fileHash', () => ({ hashFile: vi.fn(async () => 'a'.repeat(64)) }))
+
vi.mock('@/lib/imageCrop', async (importOriginal) => {
const actual = await importOriginala
', - images: [], is_online: false, created_at: '2026-03-12T09:14:00Z', updated_at: FIXED_NOW, @@ -186,29 +187,39 @@ afterEach(async () => { await router.push('/login') }) -describe("Opening the uploader from the toolbar's image button shows the idle state", () => { - it('opens ImageUploader idle and sends no request', async () => { - const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleFixture()), - }) +const EMPTY_LIBRARY = { items: [], next_cursor: null, counts: { all: 0, unused: 0, duplicates: 0 } } + +function articleHandlers(extra: Record
')
+ expect(insertedBody).toContain(
+ '
',
+ )
expect(wrapper.text()).toContain('Unsaved changes')
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
expect(toastAdd).toHaveBeenCalledWith({
diff --git a/web/src/views/__tests__/EditorMediaPicker.spec.ts b/web/src/views/__tests__/EditorMediaPicker.spec.ts
new file mode 100644
index 0000000..c076a32
--- /dev/null
+++ b/web/src/views/__tests__/EditorMediaPicker.spec.ts
@@ -0,0 +1,191 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
+import uiPlugin from '@nuxt/ui/vue-plugin'
+import router from '@/router'
+import { useSessionStore } from '@/stores/session'
+import type { Article } from '@/composables/useArticles'
+import ArticleEditor from '@/components/ArticleEditor.vue'
+import RootComponent from '@/App.vue'
+import { fixture } from './mediaTestKit'
+
+const FIXED_NOW = '2026-09-26T11:02:00Z'
+
+const BODY_WITH_TWO_IMAGES =
+ 'Intro

Intro
') + wrapper.findComponent(ArticleEditor).vm.insertImage('/static/uploadedImages/2.webp', '', 2) + await flushPromises() + + await openPicker(wrapper) + + expect(taggedIds()).toEqual(['2']) + }) + + it('no longer tags an image that was removed from the body', async () => { + const wrapper = await mountEditor() + wrapper.findComponent(ArticleEditor).vm.removeImage(1) + await flushPromises() + + await openPicker(wrapper) + + expect(taggedIds()).toEqual(['4']) + }) +}) + +describe('Insert puts the chosen image in the body with its alt text', () => { + it('inserts it at the cursor, marks the draft dirty, toasts and closes the dialog', async () => { + const wrapper = await mountEditor('Intro
') + await openPicker(wrapper) + tile('kitchen-after.webp').click() + await flushPromises() + const alt = document.body.querySelector
',
+ )
+ expect(wrapper.text()).toContain('Unsaved changes')
+ expect(
+ (wrapper.find('[data-testid="save-button"]').element as HTMLButtonElement).disabled,
+ ).toBe(false)
+ expect(toastAdd).toHaveBeenCalledWith({
+ color: 'success',
+ icon: 'i-lucide-circle-check',
+ title: 'Image added',
+ description: 'Save to keep it in the article.',
+ })
+ expect(document.body.querySelector('[role="dialog"]')).toBeNull()
+ const methods = vi
+ .mocked(global.fetch)
+ .mock.calls.map(([, init]) => (init as RequestInit | undefined)?.method)
+ expect(methods.every((method) => method === undefined)).toBe(true)
+ })
+
+ it('leaves the content and the draft alone when the dialog is cancelled', async () => {
+ const wrapper = await mountEditor('Intro
') + await openPicker(wrapper) + tile('kitchen-after.webp').click() + await flushPromises() + + findButton('Cancel')?.click() + await flushPromises() + + expect(wrapper.findComponent(ArticleEditor).props('modelValue')).not.toContain('${pickerIntro}
${nodes.join('')}${pickerOutro}
` +} + +const pickerCounts = { all: 48, unused: 2, duplicates: 2 } +const pickerFirstPageCursor = 'c24' +const pickerSearchTerm = 'wall' + +function pickerList(url: URL) { + if (url.searchParams.has('hash')) { + return mediaList(mediaItems([1]), pickerCounts) + } + if (url.searchParams.has('q')) { + return { items: [], next_cursor: null, counts: pickerCounts } + } + return { items: mediaItems(), next_cursor: pickerFirstPageCursor, counts: pickerCounts } +} + +function pickerApi( + overrides: Record
`. Mock `useApi`'s `request` for `DELETE /v1/article/12/image/10`
-(fetch-based, not XHR — reuse whatever mock
-`api_client_global_error_handling.qa.md` already sets up for `request`).
+Mount EditorPage at `/article/12/edit` with the `editor_core.qa.md` fixture article and a body of `
`. Seed the session's image record with id 1 ("north-wall.jpg", 3.9 MB). Spy on `request` and on `XMLHttpRequest` and assert they are never called by any step below.
-1. **Selecting the image reveals the delete button** — click the image
- node; assert a delete button (trash icon, error color) renders anchored
- to it; click elsewhere in the editor; assert the button is gone.
-2. **Activating the delete button opens the confirm dialog** — select the
- image, activate its delete button; assert a dialog titled "Remove this
- image?", description "This image will be removed from the article. This
- can't be undone.", cancel "Cancel", confirm "Remove image"; assert no
- request was sent yet; assert the image node is still present (Tiptap's
- native deletion did not run).
-3. **Backspace on the selected image opens the same confirm dialog instead
- of deleting the node** — select the image node (NodeSelection), fire a
- `keydown` for `Backspace`; assert the same dialog as step 2 opens;
- assert no request was sent yet; assert the image node is still present
- in the editor's document (i.e. the keydown was intercepted before
- Tiptap's default node-deletion ran, not just undone afterwards).
-4. **Delete on the selected image opens the same confirm dialog instead of
- deleting the node** — same as step 3 but firing `keydown` for `Delete`.
-5. **Cancelling changes nothing** — from the open dialog (opened via the
- button), click "Cancel"; assert the dialog closes, no request was sent,
- the image node is still in the editor's content, and SaveStatus/dirty
- state is unchanged.
-6. **Confirming removes the image, marks dirty, and toasts (button
- trigger)** — from the open dialog (Saved state), click "Remove image";
- assert a DELETE request to `/v1/article/12/image/10` was made; resolve
- it 204; assert the dialog closes, the image node is gone from the
- editor's content, SaveStatus reads "Unsaved changes" and Save is
- enabled, and a toast appears with color success, icon
- `i-lucide-circle-check`, title "Image removed", description "Save to
- keep this change."
-7. **Confirming removes the image the same way when opened via
- Backspace/Delete** — repeat step 6's assertions, but open the dialog via
- the Backspace keydown from step 3 instead of the button; assert the
- outcome (request, node removal, dirty state, toast) is identical.
-8. **A failed removal keeps the image and shows the inline error,
- Examples-driven** — for a 404 response, a 500 response, and a rejected/
- network-failure request: from the open dialog (button trigger), click
- "Remove image"; resolve/reject accordingly; assert the dialog stays open
- showing the inline error "Couldn't remove the image, so it's still
- there. Try again in a moment."; assert the image node is still present
- and the draft's dirty state is unchanged.
+1. **Selecting reveals the button** - click the image; assert a button named "Remove image" with a trash-2 icon renders at the corner; click elsewhere; assert it is gone.
+2. **No id, no button** - an image without `data-image-id`: no button, and a Backspace keydown does not open the dialog.
+3. **Triggers, Examples-driven** - button click, Backspace keydown and Delete keydown each open the dialog with the exact title, description, "Cancel" and a primary (not error) "Remove image"; assert the body shows the thumbnail, "north-wall.jpg" and "Stays in the library · 3.9 MB"; assert no request; assert the node is still in the document.
+4. **Unseen image** - id 7 not in the record; assert the row shows the thumbnail and "Stays in the library" only.
+5. **Cancel changes nothing.**
+6. **Confirm** - click "Remove image"; assert the dialog closes, the node is gone, SaveStatus "Unsaved changes", Save enabled, the toast (color success, icon `i-lucide-circle-check`, "Image removed" / "It's still in your library."), and zero requests.
+7. **Backspace-opened confirm** gives the same outcome as 6.
+8. **Removed image loses its tag** - open the Library after step 6; assert "north-wall.jpg" has no "In this article" tag.
-## Visual
+## Visual: `task visual:check -- media-picker`
-No `docs/design/` board or state exists for this trigger (see the feature's
-Background). Per the user's explicit direction this slice ships the SPA
-behaviour anyway, but `task visual:check -- editor` has nothing to compare
-it against, so no visual capture is added for it — this is an intentional
-gap, `skip: no-design-coverage` (distinct from `skip: clock-sensitive`), not
-a QA oversight. If a board for this state is added to `docs/design/` later,
-it needs its own follow-up slice to add the visual check.
+The states "Remove image: confirm" and "Removed" are in the `media-picker` group; the setups are in `editor_media_picker.qa.md`. The sample data is north-wall.jpg, image id 1, 3.9 MB. The "Remove image" button is visible in "Remove image: confirm" and "Inserted", so the setup must select the image node. The confirm's confirm button must not be error-coloured.
-- [ ] Keyboard/focus: once a delete button is shown, it is reachable by Tab
- and activates on Enter/Space; pressing Backspace/Delete with the
- image node selected but focus elsewhere in the page does not trigger
- removal (only an actual Tiptap NodeSelection on the image should);
- ConfirmDialog's existing focus trap and cancel-button-default-focus
- behaviour (already covered by the article-delete flow) applies
- unchanged regardless of which trigger opened it.
+- [ ] Keyboard and focus: the button is reachable by Tab and activates with Enter or Space. Backspace or Delete with focus outside the editor does nothing. ConfirmDialog's focus trap and default focus on "Cancel" are unchanged.
## Manual verification
-- **Real DELETE round trip against a running server** — Vitest only mocks
- `useApi`'s request; confirm a real removal (204/404/500) against
- `task dev` with a live SQLite file and the real
- `DELETE /v1/article/{id}/image/{imageId}` handler, since
- `features/remove_article_image.feature`'s httptest suite covers the
- handler in isolation but never through this SPA flow.
-- **Real keyboard behaviour** — confirm in a real browser that selecting an
- image (click) and pressing Backspace/Delete opens the confirm dialog
- rather than deleting the node, including with an IME or on a platform
- where Backspace/Delete semantics differ, since jsdom's `keydown`
- simulation doesn't exercise Tiptap's real ProseMirror keymap precedence.
-- **Design review of the delete-button/ConfirmDialog approach** — since
- there is no board for this state, once a board is added, re-verify this
- implementation still matches it (or update the implementation to match);
- until then this is a best-effort, design-team-unreviewed UI.
+- **Real keyboard behaviour** in a real browser, including an IME: Backspace or Delete on a selected image opens the confirm instead of deleting the node (jsdom doesn't exercise ProseMirror's keymap precedence).
+- **Save after removal** against `task dev`: remove an image, save, reload; the image is gone from the article and still listed in `/media`.
diff --git a/features/editor_image_upload.feature b/features/editor_image_upload.feature
index 74f3cbc..a20baa0 100644
--- a/features/editor_image_upload.feature
+++ b/features/editor_image_upload.feature
@@ -1,136 +1,191 @@
-Feature: Wiring the editor's image upload flow
+Feature: The editor's Upload tab uploads to the library
As a verified RenewCMS owner writing an article
- I want the editor's image button (and dropping a file onto the editor) to open an upload
- modal that validates the file, shows progress, and inserts the result at my cursor
+ I want the Upload tab of the "Add an image" dialog to check my file, spot duplicates,
+ upload to my library and insert the result at my cursor
So that I can add images to an article's body without leaving the editor, with clear
feedback when a file is rejected or the upload fails
Background:
- Given EditorView.vue listens for ArticleEditor's "request-image" event and, on it, opens
- ImageUploader (a UModal titled "Add an image") with the current article's id
- And dropping a file directly onto the editor surface also opens ImageUploader, with that
- file already provided to it instead of an empty idle state
- And ImageUploader validates a chosen or dropped file in the browser, checking both its MIME
- type and its file extension against png/jpeg/webp/svg, and that its size is at most
- 10 × 1024 × 1024 bytes, before sending anything
- And a file that passes validation is uploaded via a new useImageUpload composable, using
- XMLHttpRequest (not useApi) so upload progress is observable, as multipart/form-data with
- the file under the field name "file", to POST /v1/article/{articleId}/image, per
- features/upload_article_image.feature's already-implemented and already-mounted contract
- And a 201 response's {"location": "..."} is inserted into ArticleEditor's content at the
- current cursor position via an exposed insertImage(src, alt) method, with alt ""; this
- moves useArticleDraft's state to "dirty" the same way any other body edit does
- And closing the modal while an upload is in flight aborts the XMLHttpRequest
- And a 401 response is handled entirely by useApi's global handling
- (features/api_client_global_error_handling.feature) and is out of scope for new scenarios
- here; the same is true of a 404 (the article was deleted elsewhere), which the editor's
- existing not-found handling already covers (features/editor_core.feature)
+ Given this slice is SPA only for this file: the API from the media library slice is used
+ unchanged
+ And EditorView listens for ArticleEditor's "request-image" event and opens ImageUploader
+ (a UModal titled "Add an image") with the picker tabs, whose Library tab is specified in
+ features/editor_media_picker.feature
+ And dropping a file onto the editor surface opens ImageUploader on the Upload tab with that
+ file already provided, and no list request is sent until the Library tab is opened
+ And this file supersedes these statements of the earlier version: the upload goes to
+ POST /v1/media/ and no longer to POST /v1/article/{articleId}/image; the 201 body is the
+ library item { id, filename, url, size, content_type, width, height, sha256, uploaded_by,
+ created_at, used_in, duplicate }; there is no article id in any upload request and no 404
+ for a deleted article on upload
+ And ImageUploader validates a chosen or dropped file in the browser with validateImageFile
+ from web/src/lib/imageUpload.ts, size first (10485760 bytes allowed, one byte more is too
+ large), then MIME type and extension must both be allowed (png, jpeg, webp, svg), before
+ hashing or sending anything
+ And a file that passes validation is hashed with hashFile from web/src/lib/fileHash.ts (the
+ lib shared with MediaPage), and GET /v1/media/?hash=