feat: media picker and image removal

This commit is contained in:
fsylvain committed 2026-10-01 05:04:58 +02:00
1 parent 008b0dfbb9
commit b3793d5dbb
54 files changed
+3769 -1412

No files matched your search

+171 -116
View File
@@ -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=<sha256 hex> is sent through useApi;
when hashFile resolves null, or the lookup fails with 500 or a network error, the check
is skipped and the flow continues as if nothing matched
And a file with no match continues exactly as in features/editor_image_crop_resize.feature:
a JPEG, PNG or WEBP opens the crop and resize step, an SVG is uploaded immediately
And the upload is sent with XMLHttpRequest (not useApi) so progress is observable, as
multipart/form-data with the file under the field name "file" to POST /v1/media/, with the
jwt cookie
And a 201 inserts the item's url at the cursor through insertImage(item.url, "", item.id),
so the node carries data-image-id, and useArticleDraft moves to "dirty"
And closing the dialog while an upload is in flight aborts the XMLHttpRequest
And the tabs show only on the choose step (idle, the duplicate card and the Library tab);
the crop, uploading, rejected and failed steps keep the earlier look without tabs
And the footer on the Upload tab holds only the step's own actions ("Cancel" in idle);
there is no "Insert image" button on it
Scenario: Opening the uploader from the toolbar's image button shows the idle state
Given the user is on /article/12/edit with the editor focused
When the user activates the toolbar's "Image" button
Then ImageUploader opens titled "Add an image"
And it shows "Drop an image here", a "Choose file" action, and the help text
"PNG, JPEG, WEBP, SVG · max 10 MB"
And its cancel action reads "Cancel"
And no request has been sent
Rule: Opening
Scenario: Dragging a file over the editor surface shows the dragging-over state
Given ImageUploader is open in the idle state
When the user drags a file over the drop zone
Then the drop zone reads "Drop to upload"
Scenario: The toolbar's image button opens on Upload when the library is empty
Given the user is on /article/12/edit with the editor focused
And GET /v1/media/?limit=24 resolves 200 with no item
When the user activates the toolbar's "Image" button
Then ImageUploader opens titled "Add an image" with the tabs "Upload" (icon upload) and "Library" (icon images)
And the Upload tab is selected and shows the image-plus icon, "Drop an image here, or choose a file" and "PNG, JPEG, WEBP, SVG · max 10 MB"
And its footer reads "Cancel" only
And no POST has been sent
Scenario: Dropping a valid file onto the editor opens the uploader already uploading it
Given the user is on /article/12/edit with the editor focused and ImageUploader is not open
When the user drops a file named "before.png", content type "image/png", size 3.9 MB, onto
the editor surface
Then ImageUploader opens titled "Add an image"
And POST /v1/article/12/image is sent with that file under the field name "file"
And the uploading state is shown, not the idle state
Scenario: Dropping a file opens Upload with the file
Given the user is on /article/12/edit and ImageUploader is not open
When the user drops a file named "before.png", content type "image/png", size 3.9 MB, onto the editor surface
Then ImageUploader opens on the Upload tab
And GET /v1/media/?hash=<sha256 of the file> is sent, and no request to GET /v1/media/?limit=24 is sent
And when the lookup resolves 200 with no item the crop and resize step shows for "before.png"
And no POST /v1/media/ has been sent
Scenario Outline: Choosing a file the browser can reject never sends a request
Given ImageUploader is open in the idle state for article 12
When the user chooses a file named "<filename>", content type "<content_type>", size <size>
Then no request is sent to POST /v1/article/12/image
And the rejected state shows the title "\"<filename>\" can't be used"
And its text reads "<description>"
And its confirm action reads "Choose another file" and its cancel action reads "Cancel"
Scenario: Dragging a file over the drop zone shows the dragging-over state
Given ImageUploader is open on the Upload tab in the idle state
When the user drags a file over the drop zone
Then the drop zone reads "Drop to upload"
Examples:
| filename | content_type | size | description |
| plan.pdf | application/pdf | 1.2 MB | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. |
| disguised.png | application/pdf | 1.2 MB | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. |
| north-wall.tif | image/png | 14.2 MB | north-wall.tif is 14.2 MB. Images can be up to 10 MB. |
Rule: Browser checks, nothing is sent
Scenario: "Choose another file" from the rejected state returns to idle
Given ImageUploader is in the rejected state for a file named "plan.pdf"
When the user activates "Choose another file"
Then the idle state is shown again, with the file picker available
Scenario Outline: Choosing a file the browser can reject sends nothing
Given ImageUploader is open in the idle state
When the user chooses a file named "<filename>", content type "<content_type>", size <size>
Then no hash lookup and no POST /v1/media/ is sent
And the rejected state shows the title "\"<filename>\" can't be used"
And its text reads "<description>"
And its confirm action reads "Choose another file" and its cancel action reads "Cancel"
Scenario: A valid upload shows progress and its cancel action reads "Cancel upload"
Given ImageUploader is open in the idle state for article 12
When the user chooses a file named "north-wall.jpg", content type "image/jpeg", size 3.9 MB
Then POST /v1/article/12/image is sent with that file under the field name "file"
And the uploading state shows "north-wall.jpg 0%" and "Uploading 0.0 of 3.9 MB"
And its cancel action reads "Cancel upload"
When the upload reports 2.4 MB sent of 3.9 MB
Then the uploading state shows "north-wall.jpg 62%" and "Uploading 2.4 of 3.9 MB"
Examples:
| filename | content_type | size | description |
| plan.pdf | application/pdf | 1.2 MB | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. |
| disguised.png | application/pdf | 1.2 MB | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. |
| north-wall.tif | image/png | 14.2 MB | north-wall.tif is 14.2 MB. Images can be up to 10 MB. |
Scenario: Closing the dialog during an upload cancels it
Given ImageUploader is uploading a file named "north-wall.jpg" to article 12
When the user activates "Cancel upload" (or otherwise closes the modal)
Then the in-flight XMLHttpRequest is aborted
And the modal closes
And the editor's body is unchanged and remains in its previous dirty/clean state
Scenario: "Choose another file" from the rejected state returns to idle
Given ImageUploader is in the rejected state for "plan.pdf"
When the user activates "Choose another file"
Then the idle state is shown again, with the tabs
Scenario: A successful upload inserts the image at the cursor and marks the draft dirty
Given the user is on /article/12/edit in the Saved state and ImageUploader is uploading a
file named "north-wall.jpg" to article 12
When POST /v1/article/12/image resolves 201 with {"location": "/static/uploadedImages/north-wall.jpg"}
Then the uploading state briefly shows "Image added"
And the image at "/static/uploadedImages/north-wall.jpg" is inserted into the editor's
content at the cursor via insertImage
And SaveStatus reads "Unsaved changes" and the Save button becomes enabled
And a toast appears: color success, icon i-lucide-circle-check, title "Image added",
description "Save to keep it in the article."
And the modal closes 600 ms after the response resolves
Rule: Already in your library
Scenario Outline: A server-side upload failure keeps the article unchanged and offers a retry
Given ImageUploader is uploading a file named "north-wall.jpg" to article 12
When POST /v1/article/12/image <failure>
Then the failed state shows the title "Upload failed"
And its text reads "The server didn't accept the file. Your article hasn't changed."
And its confirm action reads "Try again"
And the editor's body is unchanged, and no image is inserted
Scenario: A file whose content exists shows the duplicate card and sends nothing
Given ImageUploader is open in the idle state
And GET /v1/media/?hash=<sha256 of north-wall.jpg> resolves 200 with the image "north-wall.jpg" (id 1, 4032 × 3024, uploaded 12 Sep 2026 by Marta Alves)
When the user chooses a valid file named "north-wall.jpg" of 3.9 MB
Then the Upload tab shows a card with an info border: a 72 × 54 thumbnail of the existing image, the title "Already in your library" and the text "north-wall.jpg matches an image uploaded on 12 Sep 2026 by Marta Alves."
And the card has the buttons "Use existing" (primary) and "Upload anyway", stacked and full width below md
And no POST /v1/media/ has been sent and the crop step has not opened
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario: Several matches name the oldest one
Given the hash lookup resolves 200 with several images
Then the card names the oldest match
Scenario: "Try again" re-attempts the upload with the same file
Given ImageUploader is in the failed state for a file named "north-wall.jpg", article 12,
after a 500 response
When the user activates "Try again"
Then POST /v1/article/12/image is sent again with that same file under the field name "file"
And the uploading state is shown
Scenario: A match without a known uploader drops the "by" part
Given the matched image's uploaded_by is empty
Then the text reads "north-wall.jpg matches an image uploaded on 12 Sep 2026."
Scenario: A server 400 for a type the client should have caught shows the same rejected copy
Given ImageUploader is uploading a file named "notes.pdf" to article 12 (a type that
defensively passed the browser's own check, e.g. a MIME type the browser misreported)
When POST /v1/article/12/image resolves 400
Then the rejected state shows the title "\"notes.pdf\" can't be used"
And its text reads "Use a PNG, JPEG, WEBP or SVG image, up to 10 MB."
And its confirm action reads "Choose another file"
And the editor's body is unchanged, and no image is inserted
Scenario: "Use existing" goes to the alt text step for the existing image
Given the duplicate card is showing for image id 1
When the user activates "Use existing"
Then the Library tab is selected with "north-wall.jpg" selected, its panel visible with the empty "Alt text (optional)" field, and "Insert image" enabled
And no POST /v1/media/ has been sent
Scenario: A server 400 for a size the client should have caught shows the same size-named copy
Given ImageUploader is uploading a file named "north-wall.tif" to article 12 (a file that
defensively passed the browser's own size check somehow, e.g. its reported size changed
between selection and send)
When POST /v1/article/12/image resolves 400
Then the rejected state shows the title "\"north-wall.tif\" can't be used"
And its text reads "north-wall.tif is 14.2 MB. Images can be up to 10 MB."
And its confirm action reads "Choose another file"
And the editor's body is unchanged, and no image is inserted
Scenario: "Upload anyway" continues to crop and upload
Given the duplicate card is showing for "north-wall.jpg" (4032 × 3024)
When the user activates "Upload anyway"
Then the crop and resize step opens for that file
And the hash lookup is not repeated
And uploading from that step sends POST /v1/media/ and follows the normal states
Scenario: "Upload anyway" for an SVG uploads immediately
Given the duplicate card is showing for "diagram.svg"
When the user activates "Upload anyway"
Then POST /v1/media/ is sent with that file
Scenario Outline: The duplicate check is skipped when it cannot run
Given ImageUploader is open in the idle state
When the user chooses a valid file and <condition>
Then the flow continues to the crop step (or the upload for an SVG) with no card
Examples:
| condition |
| crypto.subtle is undefined (an insecure context) |
| GET /v1/media/?hash= resolves with a 500 response |
| GET /v1/media/?hash= never reaches the server |
Rule: Uploading
Scenario: A valid upload shows progress and its cancel action reads "Cancel upload"
Given the crop step was confirmed (or an SVG chosen) for "north-wall.jpg", 3.9 MB
Then POST /v1/media/ is sent with the file under the field name "file"
And the uploading state shows "north-wall.jpg 0%" and "Uploading 0.0 of 3.9 MB", and its cancel action reads "Cancel upload"
When the upload reports 2.4 MB sent of 3.9 MB
Then it shows "north-wall.jpg 62%" and "Uploading 2.4 of 3.9 MB"
Scenario: Closing the dialog during an upload cancels it
Given ImageUploader is uploading "north-wall.jpg"
When the user activates "Cancel upload" (or otherwise closes the dialog)
Then the in-flight XMLHttpRequest is aborted and the dialog closes
And the editor's body and its dirty or clean state are unchanged
Scenario: A successful upload inserts the image at the cursor and marks the draft dirty
Given the user is on /article/12/edit in the Saved state and "north-wall.jpg" is uploading
When POST /v1/media/ resolves 201 with the item { id: 13, url: "/static/uploadedImages/13.jpg", filename: "north-wall.jpg" }
Then the uploading state briefly shows "Image added"
And the image "/static/uploadedImages/13.jpg" is inserted at the cursor with alt "" and data-image-id 13
And SaveStatus reads "Unsaved changes" and Save is enabled
And a toast appears: color success, icon i-lucide-circle-check, title "Image added", description "Save to keep it in the article."
And the dialog closes 600 ms after the response
Scenario Outline: A server answer for a file that passed the browser checks
Given ImageUploader is uploading "<file>" of <size>
When POST /v1/media/ resolves <status>
Then the <state> state shows the title "<title>"
And its text reads "<text>"
And its confirm action reads "<confirm>"
And no image is inserted and the editor's body is unchanged
Examples:
| file | size | status | state | title | text | confirm |
| notes.pdf | 210 KB | 415 | rejected | "notes.pdf" can't be used | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. | Choose another file |
| north-wall.tif | 14.2 MB | 413 | rejected | "north-wall.tif" can't be used | north-wall.tif is 14.2 MB. Images can be up to 10 MB. | Choose another file |
| broken.png | 3.0 MB | 400 | rejected | "broken.png" can't be used | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. | Choose another file |
| north-wall.jpg | 3.9 MB | 500 | failed | Upload failed | The server didn't accept the file. Your article hasn't changed. | Try again |
| north-wall.jpg | 3.9 MB | 429 | failed | Upload failed | The server didn't accept the file. Your article hasn't changed. | Try again |
| north-wall.jpg | 3.9 MB | none (network) | failed | Upload failed | The server didn't accept the file. Your article hasn't changed. | Try again |
Scenario: "Try again" re-attempts the upload with the same file
Given ImageUploader is in the failed state for "north-wall.jpg" after a 500
When the user activates "Try again"
Then POST /v1/media/ is sent again with that same file and no new hash lookup, and the uploading state shows
Rule: Sessions
Scenario Outline: Session errors are global
When <call> resolves <status>
Then the user is sent where useApi sends a <status> from /article/12/edit, and no local copy shows
Examples:
| call | status |
| POST /v1/media/ (XHR) | 401 |
| POST /v1/media/ (XHR) | 403 |
| GET /v1/media/?hash= | 401 |