feat(SPA): media page T0
This commit is contained in:
1 parent
0d99544579
commit
67d456baf6
32 files changed
+4874
-40
No files matched your search
@@ -0,0 +1,383 @@
|
||||
Feature: Media page, part 1: browse and manage
|
||||
|
||||
As a verified RenewCMS owner
|
||||
I want /media to show every image in my library, let me search, filter, sort and switch view, and
|
||||
let me inspect, copy, rename and delete one image at a time
|
||||
So that I can manage the library from one place, with the page always reflecting what the
|
||||
server actually holds
|
||||
|
||||
Background:
|
||||
Given /media is a verified route (meta.auth "verified", meta.title "Media") rendered as
|
||||
MediaPage inside DashboardShell, per docs/design/routes.md
|
||||
And the sidebar gains the item "Media" (icon images) between "Articles" and "Users", in the
|
||||
desktop sidebar and in the mobile drawer, it is the active item on /media, and the
|
||||
breadcrumb reads "Media" (mobile title "Media")
|
||||
And this slice is SPA only: the API from slice A already exists and is used unchanged, and
|
||||
no Go code, use case or repository changes
|
||||
And this slice covers browse and manage only. Upload, multi-select, the bulk bar, the
|
||||
Duplicates tab content, duplicate merge, drag-and-drop and the rate-limited state are slice
|
||||
B2, and this slice renders none of their UI
|
||||
And the visual states of slice B2 carry skip 'pending' in web/visual/states.ts, print PENDING
|
||||
and are never counted as passing
|
||||
And useMedia is a composable over useApi. It exposes the list state (items, counts, loading,
|
||||
error), the request functions for list, usage, rename and delete, and the copy helper, and
|
||||
it maps ApiError and NetworkError to the states below
|
||||
And MediaPage calls GET /v1/media/ with the query built from the URL, always with limit=100,
|
||||
and every request carries the jwt cookie through useApi
|
||||
And search, tab, sort and view live in the URL query string so the view can be linked and
|
||||
survives a reload: ?q= (search text), ?filter= ("unused" or "duplicates"; absent means
|
||||
All), ?sort= ("oldest", "name" or "largest"; absent means Newest), ?view= ("list"; absent
|
||||
means grid); a value outside these sets is dropped and the default applies; the names
|
||||
mirror the API's own q, filter and sort parameters
|
||||
And the header shows the title "Media", the subtitle "Every image you've uploaded, ready to
|
||||
use in any article." and a primary "Upload" button (icon upload) on the right; in this slice
|
||||
that button is rendered disabled and does nothing, and on mobile it is the full-width 48 px
|
||||
button pinned to the bottom, disabled the same way
|
||||
And the toolbar holds the tabs "All", "Unused" and "Duplicates" with a count each, then the
|
||||
search field (placeholder "Search by filename"), the sort menu (options "Newest", "Oldest",
|
||||
"Name", "Largest", sent as sort=newest, oldest, name, largest) and the grid/list toggle
|
||||
(icons layout-grid and list); below md the toolbar stacks: search, tabs, then sort and toggle
|
||||
And the tab counts come from the response's counts object (all, unused, duplicates). The
|
||||
Duplicates count is a number of groups, drawn as an info-tinted badge only while it is
|
||||
above 0. The All and Unused counts are drawn as plain muted mono numbers, as in the board
|
||||
And the Duplicates tab is rendered and disabled in this slice (aria-disabled, not
|
||||
focusable, no navigation), so it keeps its place and its count badge but leads nowhere;
|
||||
slice B2 enables it and replaces the content area with the groups view. A ?filter=duplicates
|
||||
in the URL is treated as an unknown value and falls back to All
|
||||
And the grid is 5 columns on desktop and 2 on mobile, with a 16 px gap (10 px on mobile),
|
||||
and each card is one button holding MediaThumb (4:3, a checkerboard behind PNG and SVG),
|
||||
the filename (medium weight, ellipsis), "size · dimensions" and the MediaBadges; an SVG's
|
||||
dimensions read "Vector"
|
||||
And sizes read "8 KB" and "860 KB" (whole KB) below 1 MB and "3.9 MB" (one decimal, 1024
|
||||
base) from 1 MB up; dimensions read "4032 × 3024"; dates read "12 Sep 2026" (absolute,
|
||||
never relative); the Type column reads JPEG, PNG, WEBP or SVG from content_type
|
||||
And the MediaBadges are: "Unused" (warning-soft fill, icon unlink) when used_in is 0,
|
||||
"Duplicate" (info-soft fill, icon copy) when duplicate is true, and "Used in N"
|
||||
(outline, toned text) when used_in is above 0; in the detail panel the article rows use
|
||||
"Online" (icon globe, inverted fill) and "Draft" (icon pencil-line, outline)
|
||||
And each card's accessible name reads "north-wall.jpg, 3.9 MB, used in 2 articles",
|
||||
"kitchen-before.webp, 1.1 MB, used in 1 article" (singular for 1) or "window-frame.jpg,
|
||||
1.8 MB, unused"
|
||||
And list view is a table from md up (columns Filename with a 40 px thumbnail and the
|
||||
Duplicate badge, Dimensions, Size, Type, Used in, Uploaded by, and Uploaded with an
|
||||
arrow-down as the sorted column; 36 px header, 56 px rows) and a card list below md (56 px
|
||||
thumbnail, filename, "size · dimensions · type", badges plus "uploader · date", and a
|
||||
trailing chevron-right); in the table the "Used in" cell is an underlined count that
|
||||
opens the detail panel, and reads the Unused badge when used_in is 0
|
||||
And the checkbox column of the table (36 px), the 44 px checkbox column of the card list and
|
||||
the "Select all" row above the grid keep their space in the layout but show no checkbox
|
||||
and no "Select all" text in this slice (the row shows only "12 images"); slice B2 fills
|
||||
them
|
||||
And the detail panel is a USlideover: 460 px on the right on desktop (a close button x),
|
||||
full screen on mobile with an arrow-left back button; its title is the filename; the body
|
||||
holds the 16:10 preview (4:3 on mobile), the actions "Copy URL" (icon link), "Rename"
|
||||
(icon pencil) and "Delete" (icon trash-2, error-coloured text), the duplicate alert when
|
||||
the image is a duplicate, the metadata list (Type, Size, Dimensions, Uploaded, Uploaded by,
|
||||
and URL in mono) and the "Used in" section
|
||||
And opening the panel for an image with used_in above 0 calls GET /v1/media/{id}/usage; for an
|
||||
image with used_in 0 no usage call is sent
|
||||
And the dialogs (Rename, Delete) are UModal with sm:max-w-md, a bottom sheet on mobile and
|
||||
Cancel first; while a request runs the confirm button shows a loader-circle, both buttons
|
||||
are disabled, and the dialog cannot be dismissed by Cancel, Escape or a backdrop click; a
|
||||
failure shows an error alert at the top of the dialog body and the dialog stays open
|
||||
And every success toast lasts 5 s; toast ids follow the action-plus-id convention:
|
||||
"media-url-copied-{id}", "media-renamed-{id}", "media-deleted-{id}", "media-not-found-{id}"
|
||||
And the library is this 12-image fixture, newest first (id, filename, size, dimensions, type,
|
||||
used in, uploader, uploaded, flags), from the board's own script:
|
||||
| id | filename | size | dimensions | type | used | uploaded by | uploaded | flags |
|
||||
| 1 | north-wall.jpg | 3.9 MB | 4032 × 3024 | JPEG | 2 | Marta Alves | 12 Sep 2026 | |
|
||||
| 2 | kitchen-after.webp | 1.2 MB | 2400 × 1600 | WEBP | 3 | Inès Duarte | 8 Sep 2026 | |
|
||||
| 3 | kitchen-before.webp | 1.1 MB | 2400 × 1600 | WEBP | 1 | Inès Duarte | 8 Sep 2026 | |
|
||||
| 4 | logo-renew.svg | 8 KB | Vector | SVG | 5 | jonas | 5 Sep 2026 | alpha |
|
||||
| 5 | floorboards-copy.jpg | 2.4 MB | 3600 × 2400 | JPEG | 3 | Marta Alves | 2 Sep 2026 | duplicate |
|
||||
| 6 | IMG_2208.jpg | 2.4 MB | 3600 × 2400 | JPEG | 2 | jonas | 30 Aug 2026 | duplicate |
|
||||
| 7 | IMG_4471.jpg | 3.1 MB | 3600 × 2400 | JPEG | 2 | Marta Alves | 29 Aug 2026 | duplicate |
|
||||
| 8 | window-frame.jpg | 1.8 MB | 3000 × 2000 | JPEG | 0 | Inès Duarte | 28 Aug 2026 | |
|
||||
| 9 | stone-wall-detail.jpg | 3.1 MB | 3600 × 2400 | JPEG | 1 | jonas | 21 Aug 2026 | duplicate |
|
||||
| 10 | bathroom-tiles.png | 860 KB | 1800 × 1200 | PNG | 0 | Marta Alves | 4 Aug 2026 | alpha |
|
||||
| 11 | lime-plaster.jpg | 2.1 MB | 3200 × 2133 | JPEG | 2 | Inès Duarte | 19 Jul 2026 | |
|
||||
| 12 | floorboards.jpg | 2.4 MB | 3600 × 2400 | JPEG | 4 | Inès Duarte | 14 Jun 2026 | duplicate |
|
||||
with counts { all: 12, unused: 2, duplicates: 2 }, and next_cursor null
|
||||
|
||||
Scenario: Default, many images
|
||||
Given GET /v1/media/?filter=all&sort=newest&limit=100 resolves 200 with the 12-image fixture
|
||||
When the user navigates to /media
|
||||
Then the breadcrumb reads "Media" (mobile title "Media")
|
||||
And the tabs read "All 12", "Unused 2", "Duplicates 2", with "All" selected, and the
|
||||
Duplicates count drawn as an info-tinted badge
|
||||
And the grid shows the 12 images in exactly the fixture's order, 5 per row on desktop and 2
|
||||
on mobile, each card with its thumbnail, filename, "size · dimensions" and badges
|
||||
And the "Select all" row above the grid shows only "12 images"
|
||||
And the toolbar shows "Search by filename", the sort "Newest" and the grid toggle selected
|
||||
|
||||
Scenario Outline: Each card's badges and accessible name follow the image
|
||||
Given the Default state has loaded
|
||||
Then the card for "<filename>" has the accessible name "<name>" and the badges "<badges>"
|
||||
|
||||
Examples:
|
||||
| filename | name | badges |
|
||||
| north-wall.jpg | north-wall.jpg, 3.9 MB, used in 2 articles | Used in 2 |
|
||||
| kitchen-before.webp | kitchen-before.webp, 1.1 MB, used in 1 article | Used in 1 |
|
||||
| window-frame.jpg | window-frame.jpg, 1.8 MB, unused | Unused |
|
||||
| floorboards.jpg | floorboards.jpg, 2.4 MB, used in 4 articles | Duplicate, Used in 4 |
|
||||
| logo-renew.svg | logo-renew.svg, 8 KB, used in 5 articles | Used in 5 |
|
||||
|
||||
Scenario: Grid, few images, keeps card size
|
||||
Given GET /v1/media/ resolves 200 with ids 1, 4 and 8 and counts { all: 3, unused: 1, duplicates: 0 }
|
||||
When the user navigates to /media
|
||||
Then three cards render at the same size as in the 12-image grid, not stretched
|
||||
And a checkerboard shows behind the SVG thumbnail of "logo-renew.svg"
|
||||
And the tabs read "All 3", "Unused 1" and "Duplicates" with no count badge
|
||||
|
||||
Scenario: List view, desktop, is a table
|
||||
Given the Default state has loaded at md width or more
|
||||
When the user activates the list icon in the toggle
|
||||
Then the URL gains ?view=list
|
||||
And a table shows the columns Filename, Dimensions, Size, Type, Used in, Uploaded by and
|
||||
Uploaded (with an arrow-down), 12 rows of 56 px under a 36 px header, in fixture order
|
||||
And each Filename cell shows a 40 px thumbnail, the filename and, for duplicates, the
|
||||
Duplicate badge; the Used in cell shows an underlined count, or the Unused badge
|
||||
for id 8 and id 10
|
||||
And the Uploaded by cells read the uploader and the Uploaded cells read "12 Sep 2026"
|
||||
|
||||
Scenario: List view, mobile, is a card list
|
||||
Given the Default state has loaded below md width with ?view=list
|
||||
Then a card list replaces the grid; each row shows a 56 px thumbnail, the filename,
|
||||
"3.9 MB · 4032 × 3024 · JPEG", the badges followed by "Marta Alves · 12 Sep 2026", and a
|
||||
trailing chevron-right
|
||||
When the user taps a row
|
||||
Then the detail panel opens for that image
|
||||
|
||||
Scenario Outline: The query string is the source of truth for search, tab, sort and view
|
||||
Given GET /v1/media/ is mocked
|
||||
When the user opens /media<url>
|
||||
Then GET /v1/media/ is sent with "<request>" and limit=100
|
||||
And the control shows "<control>"
|
||||
|
||||
Examples:
|
||||
| url | request | control |
|
||||
| ?filter=unused | filter=unused&sort=newest | tab "Unused" selected |
|
||||
| ?sort=largest | filter=all&sort=largest | sort "Largest" |
|
||||
| ?sort=name | filter=all&sort=name | sort "Name" |
|
||||
| ?sort=oldest | filter=all&sort=oldest | sort "Oldest" |
|
||||
| ?q=wall | q=wall&filter=all | search field "wall" |
|
||||
| ?view=list | filter=all&sort=newest | list icon selected |
|
||||
| ?filter=bogus&sort=zzz | filter=all&sort=newest | tab "All" and sort "Newest", URL kept clean |
|
||||
| ?filter=duplicates | filter=all&sort=newest | tab "All" selected |
|
||||
|
||||
Scenario: Changing a control writes the query string and reloads the list
|
||||
Given the Default state has loaded
|
||||
When the user types "wall" in the search field
|
||||
Then after a 120 ms debounce the URL gains ?q=wall and GET /v1/media/?q=wall&filter=all&sort=newest&limit=100 is sent
|
||||
When the user selects the "Unused" tab
|
||||
Then the URL reads ?q=wall&filter=unused and the list is requested again
|
||||
When the user picks "Largest" in the sort menu
|
||||
Then the URL gains sort=largest and the list is requested again
|
||||
And selecting "All" removes filter from the URL, "Newest" removes sort, and the grid icon removes view
|
||||
|
||||
Scenario: The disabled Duplicates tab does nothing
|
||||
Given the Default state has loaded
|
||||
When the user clicks or focuses the "Duplicates 2" tab
|
||||
Then the tab does not become selected, the URL does not change and no request is sent
|
||||
|
||||
Scenario: Loading, skeleton cards after 150 ms
|
||||
Given GET /v1/media/ has not yet resolved
|
||||
When the user navigates to /media
|
||||
Then nothing is drawn for the first 150 ms
|
||||
And after 150 ms 10 skeleton cards (6 on mobile) replace the images, the list container is
|
||||
aria-busy="true", and the toolbar stays visible
|
||||
When the request resolves 200 with the 12-image fixture
|
||||
Then the skeleton cards are replaced by the Default state's cards
|
||||
And the same skeleton appears on a retry and on a change of search, tab or sort, in grid and
|
||||
list view alike
|
||||
|
||||
Scenario: Empty library, first run
|
||||
Given GET /v1/media/ resolves 200 with zero items and counts { all: 0, unused: 0, duplicates: 0 } and no q
|
||||
When the user navigates to /media
|
||||
Then the toolbar and the tabs are hidden
|
||||
And one panel shows the images icon, title "No images yet", text "Upload your first image. You can use it in any article.", a primary "Upload an image" button (disabled in this slice), and under it "PNG, JPEG, WEBP, SVG · max 10 MB each"
|
||||
|
||||
Scenario: No results, search and filter
|
||||
Given the Default state has loaded
|
||||
When the user selects "Unused" and types "wall" and GET /v1/media/?q=wall&filter=unused&sort=newest&limit=100 resolves 200 with zero items
|
||||
Then a panel replaces the images with the search icon, title `No images match "wall"`, text "Nothing matches in Unused. Check the spelling, or clear the filters." and an outline "Clear filters" button
|
||||
When the user activates "Clear filters"
|
||||
Then the search field is emptied, the tab returns to "All", ?q and ?filter leave the URL, and the Default cards reappear
|
||||
|
||||
Scenario: Hover, focus and keyboard
|
||||
Given the Default state has loaded on desktop
|
||||
Then every card is one button and one tab stop
|
||||
When the pointer rests on the second card ("kitchen-after.webp")
|
||||
Then its border darkens (border-accented) and it gains the pop shadow
|
||||
When the third card ("kitchen-before.webp") takes keyboard focus
|
||||
Then it shows a 2 px primary ring with a 2 px offset and its accessible name reads "kitchen-before.webp, 1.1 MB, used in 1 article"
|
||||
When the user presses an arrow key on a card
|
||||
Then focus moves to the neighbouring card across the grid (left and right by one, up and down by one row, stopping at the edges)
|
||||
When the user presses Enter on a card
|
||||
Then the detail panel opens for that image
|
||||
When the user presses Space on a card
|
||||
Then nothing happens and no panel opens (Space is selection, which is slice B2)
|
||||
And no checkbox is shown on hover or on focus in this slice
|
||||
|
||||
Scenario: Detail panel, in use
|
||||
Given the Default state has loaded and GET /v1/media/1/usage resolves 200 with [{ id: 10, title: "How we price a bathroom renovation", published: true }, { id: 11, title: "Winter checklist for timber windows", published: false }]
|
||||
When the user activates the "north-wall.jpg" card
|
||||
Then the slideover opens with the title "north-wall.jpg", a 16:10 preview and the actions "Copy URL", "Rename" and "Delete" (Delete in error-coloured text)
|
||||
And the metadata list reads Type "JPEG", Size "3.9 MB", Dimensions "4032 × 3024", Uploaded "12 Sep 2026", Uploaded by "Marta Alves" and URL in mono
|
||||
And the "Used in" section reads "Used in 2 articles" over one bordered row per article: the title as an underlined link to /article/{id}/edit, a badge "Online" (icon globe, inverted) for "How we price a bathroom renovation" or "Draft" (icon pencil-line, outline) for "Winter checklist for timber windows", and an arrow-up-right
|
||||
And the footnote reads "Counted from the text of your articles each time you open this page."
|
||||
When the user closes the panel (x, the back arrow on mobile, or Escape)
|
||||
Then the panel closes and focus returns to the "north-wall.jpg" card
|
||||
|
||||
Scenario: Detail panel, unused
|
||||
Given the Default state has loaded
|
||||
When the user activates the "window-frame.jpg" card
|
||||
Then no usage request is sent
|
||||
And the "Used in" section reads "Not used in any article" over the text "This image is safe to delete. No article shows it."
|
||||
|
||||
Scenario: Detail panel, duplicate
|
||||
Given the Default state has loaded, GET /v1/media/?hash=<sha256 of id 12> resolves 200 with ids 12, 5 and 6, and GET /v1/media/12/usage resolves 200
|
||||
When the user activates the "floorboards.jpg" card
|
||||
Then an info alert reads "Identical to 2 other images" over "floorboards-copy.jpg and IMG_2208.jpg have the same content." with an action "Review duplicates" (icon arrow-right)
|
||||
And the action is rendered disabled in this slice, because the Duplicates view ships in slice B2
|
||||
|
||||
Scenario: The Used in count in list view opens the panel
|
||||
Given the Default state has loaded with ?view=list at md width or more
|
||||
When the user activates the underlined "2" in the "north-wall.jpg" row
|
||||
Then the detail panel opens for "north-wall.jpg" with its "Used in 2 articles" list
|
||||
|
||||
Scenario: Copy URL
|
||||
Given the detail panel is open for "north-wall.jpg"
|
||||
When the user activates "Copy URL"
|
||||
Then "{origin}{url}" (the absolute address of the image) is written to the clipboard
|
||||
And a success toast appears: icon circle-check, id "media-url-copied-1", title "URL copied", text "Paste it wherever you need the image address."
|
||||
|
||||
Scenario: Rename, dialog
|
||||
Given the detail panel is open for "north-wall.jpg"
|
||||
When the user activates "Rename"
|
||||
Then a dialog opens: title "Rename image", description "Only the filename changes. The URL stays the same, so articles that use this image keep working."
|
||||
And the field "Filename" holds "north-wall" with the mono suffix ".jpg" fixed after it and the help "Letters, numbers, dashes and underscores. The .jpg ending stays."
|
||||
And the buttons read "Cancel" then "Rename"
|
||||
|
||||
Scenario Outline: Rename, validation, nothing is sent
|
||||
Given the Rename dialog is open for "north-wall.jpg"
|
||||
When the user enters "<value>" and submits or leaves the field
|
||||
Then the field shows the error "Enter a filename." with a circle-alert icon and the help text is replaced
|
||||
And no PATCH is sent
|
||||
|
||||
Examples:
|
||||
| value |
|
||||
| |
|
||||
| --- |
|
||||
| !!! |
|
||||
|
||||
Scenario: Rename, saving
|
||||
Given the Rename dialog is open for "north-wall.jpg" with "north-wall-01" entered
|
||||
When the user activates "Rename"
|
||||
Then PATCH /v1/media/1 is sent with { "filename": "north-wall-01" }
|
||||
And the field and both buttons are disabled, the confirm button reads "Renaming…" with a loader-circle, and Cancel, Escape and a backdrop click do not close the dialog
|
||||
|
||||
Scenario Outline: Rename, failed, the dialog stays and keeps the text
|
||||
Given a rename is in flight for "north-wall.jpg" with "north-wall-01" entered
|
||||
When PATCH /v1/media/1 <failure>
|
||||
Then the dialog stays open with an error alert "Couldn't rename the image. Your text is still here. Try again in a moment." at the top of its body
|
||||
And "north-wall-01" is still in the field and Cancel and Rename are enabled again
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: Rename, done
|
||||
Given a rename is in flight for "north-wall.jpg" with "north-wall-01" entered
|
||||
When PATCH /v1/media/1 resolves 200 with the item, filename "north-wall-01.jpg"
|
||||
Then the dialog closes, the panel title reads "north-wall-01.jpg", the card or row for id 1 reads "north-wall-01.jpg" in place (the list is not reordered), and the URL row is unchanged
|
||||
And a success toast appears: icon circle-check, id "media-renamed-1", title "Image renamed", text `It's now "north-wall-01.jpg". The URL is the same.`
|
||||
|
||||
Scenario: Delete, unused image
|
||||
Given the detail panel is open for "window-frame.jpg" (used in 0)
|
||||
When the user activates "Delete"
|
||||
Then a dialog opens: title "Delete this image?", description `"window-frame.jpg" will be deleted from your library. No article uses it. This can't be undone.`, no body, "Cancel" and a danger "Delete image"
|
||||
|
||||
Scenario: Delete, image in use
|
||||
Given the detail panel is open for "north-wall.jpg" with its 2 articles loaded from GET /v1/media/1/usage
|
||||
When the user activates "Delete"
|
||||
Then a dialog opens: title "Delete an image that's in use?", description `"north-wall.jpg" is used in 2 articles. If you delete it, they will show a broken image. This can't be undone.`
|
||||
And the body lists the two affected articles, each with its title and an "Online" or "Draft" badge, "Cancel" and a danger "Delete image"
|
||||
|
||||
Scenario: Delete, in progress
|
||||
Given the Delete dialog is open for "window-frame.jpg"
|
||||
When the user activates "Delete image"
|
||||
Then DELETE /v1/media/8 is sent
|
||||
And both buttons are disabled, the confirm button reads "Deleting…" with a loader-circle, aria-busy is set, and Cancel, Escape and a backdrop click do not close it
|
||||
|
||||
Scenario Outline: Delete, failed, the image stays
|
||||
Given a delete is in flight for "window-frame.jpg"
|
||||
When DELETE /v1/media/8 <failure>
|
||||
Then the dialog stays open with an error alert "Couldn't delete the image, so it's still there. Try again in a moment." and both buttons enabled again
|
||||
And the "window-frame.jpg" card is untouched behind the dialog
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: Delete, done
|
||||
Given a delete is in flight for "window-frame.jpg" (id 8, the 8th card)
|
||||
When DELETE /v1/media/8 resolves 204
|
||||
Then the dialog and the panel close and the card leaves the grid
|
||||
And focus moves to the next card ("stone-wall-detail.jpg"); when the deleted card was the last, focus moves to the page heading
|
||||
And a success toast appears: icon circle-check, id "media-deleted-8", title "Image deleted", text `"window-frame.jpg" is gone from your library.`
|
||||
And the list is then requested again in the background, with no skeleton, to refresh the counts and the Duplicate flags
|
||||
|
||||
Scenario Outline: Image already gone, a 404 on any action removes it
|
||||
Given the detail panel or a dialog is open for "window-frame.jpg"
|
||||
When <trigger> resolves 404
|
||||
Then the panel and any dialog close and the image leaves the list
|
||||
And an info toast appears: icon info, id "media-not-found-8", title "That image no longer exists", text "It was deleted somewhere else, so we removed it from the library."
|
||||
And the list is then requested again in the background to refresh the counts
|
||||
|
||||
Examples:
|
||||
| trigger |
|
||||
| DELETE /v1/media/8 |
|
||||
| PATCH /v1/media/8 |
|
||||
| GET /v1/media/8/usage (on open) |
|
||||
|
||||
Scenario Outline: List failed to load
|
||||
Given GET /v1/media/ <failure>
|
||||
When the user navigates to /media
|
||||
Then an error alert replaces the images: icon circle-x, title "Couldn't load your images", text "Check your connection and try again.", and a "Try again" button (icon rotate-cw)
|
||||
And the toolbar stays visible
|
||||
When the user activates "Try again"
|
||||
Then the skeleton cards show and GET /v1/media/ is sent again, and on success the Default state replaces the alert
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: Offline
|
||||
Given the Default state has loaded
|
||||
When navigator.onLine becomes false
|
||||
Then a banner with the wifi-off icon reads "You're offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection."
|
||||
And the loaded cards stay browsable and the detail panel still opens and "Copy URL" still works
|
||||
And "Upload", "Rename" and "Delete" are disabled
|
||||
When navigator.onLine becomes true
|
||||
Then the banner disappears and "Rename" and "Delete" are enabled again
|
||||
|
||||
Scenario Outline: Session errors are global
|
||||
When any /v1/media call resolves <status>
|
||||
Then the user is sent to <destination> by useApi, and no local media copy is shown
|
||||
|
||||
Examples:
|
||||
| status | destination |
|
||||
| 401 | /login?redirect=/media&reason=expired |
|
||||
| 403 | /register/pending (unverified user) |
|
||||
|
||||
Scenario: The sidebar item and the mobile drawer
|
||||
Given the user is signed in and verified
|
||||
When the sidebar renders
|
||||
Then it lists Home, Articles, Media (icon images), Users, Integration, Settings in that order
|
||||
And on /media, "Media" is the active item, on desktop and in the mobile drawer
|
||||
When the user activates "Media"
|
||||
Then the router goes to /media
|
||||
@@ -0,0 +1,160 @@
|
||||
# QA: Media page, part 1 (browse and manage)
|
||||
|
||||
## Scope note (informational)
|
||||
|
||||
SPA only. The media API already exists (slice A), so there is no HTTP-level Go test in this slice
|
||||
and no `test/` file. The Go suite is run only to confirm nothing regressed (`task test`).
|
||||
|
||||
Slice B2 (upload, multi-select, bulk bar, Duplicates content, merge, drag and drop, rate limit) is
|
||||
out of scope: its states are listed under "Pending, slice B2" and are never reported as passing.
|
||||
|
||||
Toast ids: "media-url-copied-{id}", "media-renamed-{id}", "media-deleted-{id}", "media-not-found-{id}".
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi`'s `request` (or the global fetch it wraps) per case for `GET /v1/media/`,
|
||||
`GET /v1/media/{id}/usage`, `GET /v1/media/?hash=`, `PATCH /v1/media/{id}` and
|
||||
`DELETE /v1/media/{id}`, plus `navigator.clipboard.writeText` and `navigator.onLine`. No real
|
||||
backend. Mount MediaPage inside DashboardShell with a verified session and a test router at
|
||||
/media. Sample data is the 12-image fixture in the .feature Background (same ids, filenames,
|
||||
sizes, dimensions, used_in, uploaders and dates as the board's script), with counts { all: 12,
|
||||
unused: 2, duplicates: 2 }. Use fake timers for the 150 ms skeleton delay and the 120 ms search
|
||||
debounce.
|
||||
|
||||
Logic and copy:
|
||||
|
||||
1. **Formatting** (pure functions) - size: 8192 bytes to "8 KB", 880640 to "860 KB", 4089446 to
|
||||
"3.9 MB"; dimensions "4032 × 3024"; null width and height to "Vector"; date "12 Sep 2026"
|
||||
from `created_at`; type from content_type (image/jpeg "JPEG", image/png "PNG", image/webp
|
||||
"WEBP", image/svg+xml "SVG"); accessible name for used_in 2, 1 and 0 ("used in 2 articles",
|
||||
"used in 1 article", "unused").
|
||||
2. **Query string mapping** - Examples-driven from the .feature: each URL produces the exact
|
||||
request query and control state; unknown values and ?filter=duplicates fall back to defaults;
|
||||
changing a control rewrites the URL and drops default values.
|
||||
3. **Default** - 12 cards in fixture order, tabs "All 12" "Unused 2" "Duplicates 2", the
|
||||
Duplicates badge only when duplicates > 0, "12 images" in the Select all row, breadcrumb
|
||||
"Media", no checkbox in the DOM.
|
||||
4. **Badges and accessible names** - the 5 Examples rows (badges and aria-label / accessible name).
|
||||
5. **Grid, few images** - 3 items; tabs "All 3" "Unused 1" "Duplicates" without a badge.
|
||||
6. **List view** - desktop: table with the 7 column headers and 12 rows, the Used in cell
|
||||
underlined count or Unused badge; mobile: card list with "3.9 MB · 4032 × 3024 · JPEG" and
|
||||
"Marta Alves · 12 Sep 2026"; clicking the Used in count opens the panel.
|
||||
7. **Disabled Duplicates tab** - aria-disabled, click and focus change nothing, no request.
|
||||
8. **Loading** - unresolved GET: nothing before 150 ms; after 150 ms the skeleton (10 cards,
|
||||
6 below md) and aria-busy; resolve; cards replace it; same on retry and on search, tab and
|
||||
sort change, in list view too.
|
||||
9. **Empty library** - zero items and counts 0 with no q: toolbar and tabs absent, copy exact,
|
||||
"Upload an image" rendered disabled, the footnote "PNG, JPEG, WEBP, SVG · max 10 MB each".
|
||||
10. **No results** - unused + "wall": title `No images match "wall"`, text "Nothing matches in
|
||||
Unused. Check the spelling, or clear the filters."; "Clear filters" resets q and filter and
|
||||
restores the cards.
|
||||
11. **Keyboard** - each card is a button; Enter opens the panel; Space opens nothing; arrow keys
|
||||
move focus (left, right, up, down by one row, stopping at the edges); Escape closes the panel
|
||||
and focus returns to the card.
|
||||
12. **Detail panel, in use** - usage called once for id 1, title, actions, metadata rows, article
|
||||
rows with links to /article/{id}/edit and Online / Draft badges, footnote text.
|
||||
13. **Detail panel, unused** - no usage request; "Not used in any article" and the safe-to-delete
|
||||
text.
|
||||
14. **Detail panel, duplicate** - hash query used; alert title "Identical to 2 other images" and
|
||||
"floorboards-copy.jpg and IMG_2208.jpg have the same content." (the image itself is excluded);
|
||||
the "Review duplicates" action is disabled.
|
||||
15. **Copy URL** - clipboard called with `{origin}{url}`; toast id "media-url-copied-1", icon
|
||||
circle-check, title "URL copied", text "Paste it wherever you need the image address.".
|
||||
16. **Rename dialog** - title, description, field "north-wall" with ".jpg" suffix, help text,
|
||||
Cancel then Rename.
|
||||
17. **Rename validation** - "", "---", "!!!": error "Enter a filename." on submit and on blur,
|
||||
no PATCH.
|
||||
18. **Rename saving** - PATCH /v1/media/1 body { filename: "north-wall-01" } (no extension); the
|
||||
field and buttons disabled, "Renaming…", dialog not dismissible by Escape or backdrop.
|
||||
19. **Rename failed** - 500 and network rejection: alert copy exact, text kept, buttons enabled.
|
||||
20. **Rename done** - 200: panel title, card and row updated in place, order unchanged; toast id
|
||||
"media-renamed-1", title "Image renamed", text `It's now "north-wall-01.jpg". The URL is the same.`
|
||||
21. **Delete, unused** and **Delete, in use** - dialog titles, descriptions with the exact quotes
|
||||
and counts, the article list with badges only in the in-use variant, "Delete image" danger.
|
||||
22. **Delete in progress** - DELETE /v1/media/8; buttons disabled; "Deleting…"; not dismissible.
|
||||
23. **Delete failed** - 500 and network rejection: alert copy exact, item untouched.
|
||||
24. **Delete done** - 204: dialog and panel closed, card removed, focus on the next card (heading
|
||||
when last), toast id "media-deleted-8", title "Image deleted", text `"window-frame.jpg" is gone from your library.`; a background list refresh
|
||||
without skeleton follows and updates the counts.
|
||||
25. **404 Examples-driven** - DELETE, PATCH, usage: everything closes, the item is removed,
|
||||
toast id "media-not-found-8", icon info, title "That image no longer exists", text "It was
|
||||
deleted somewhere else, so we removed it from the library.".
|
||||
26. **List failed** - 500 and network rejection: alert copy exact, toolbar visible; "Try again"
|
||||
re-requests, shows skeleton, then the list.
|
||||
27. **Offline** - onLine false: banner copy exact, Upload, Rename and Delete disabled, Copy URL
|
||||
and panel still work; back online restores them.
|
||||
28. **401 / 403** - handled by useApi (redirect target strings asserted); no local media copy.
|
||||
29. **Sidebar and drawer** - Media between Articles and Users with the images icon, active on
|
||||
/media, in the desktop sidebar and the mobile drawer; the route has meta.auth "verified" and
|
||||
meta.title "Media" (routerGuard spec extended).
|
||||
30. **Header** - title, subtitle, and the Upload button disabled and inert in this slice (also
|
||||
the mobile pinned 48 px variant).
|
||||
|
||||
## Visual (`task visual:check -- media`, desktop and mobile, light and dark)
|
||||
|
||||
Reference: docs/design/screens/media.md and `source/MediaFrame.dc.html` (group "Media" in the
|
||||
back-office board). Add each setup to `web/visual/states.ts` first, mocking `/v1/media/`,
|
||||
`/v1/media/{id}/usage` and `/v1/media/?hash=` from the board's own script: the 12 images (ids,
|
||||
filenames, sizes, dimensions, used in, uploaders, absolute dates), and the article rows for
|
||||
the panels ("How we price a bathroom renovation" Online, "Winter checklist for timber windows"
|
||||
Draft for north-wall.jpg). None of the B1 states depends on the current date (all dates are
|
||||
absolute), so none is `skip: 'clock-sensitive'`.
|
||||
|
||||
The board draws its thumbnails as placeholder tiles (a striped tone fill with an image
|
||||
icon, the RenewCMS mark on the SVG, a checkerboard behind PNG and SVG), not photographs. The
|
||||
mocked image URLs must render the same way, or the 10 % gate fails for a reason unrelated to
|
||||
the code.
|
||||
|
||||
Harness note: `web/visual/states.ts` gains a `skip: 'pending'` option, added by coder. It prints
|
||||
`PENDING` and counts as neither pass nor fail. `task visual:check -- media` still exits non-zero
|
||||
when a B1 state has no setup, when a `/v1` call is unmocked, when sizes mismatch or when a capture
|
||||
exceeds its budget. Every B2 state carries `skip: 'pending'` and is never reported as passing.
|
||||
|
||||
Setups for this slice (name in screens/media.md, then what must be drawn):
|
||||
|
||||
- [ ] Grid, many images: 12 cards, tabs 12 / 2 / 2, Select all row showing "12 images" only.
|
||||
- [ ] Grid, few images: ids 1, 4, 8; checkerboard behind the SVG.
|
||||
- [ ] List view: table at desktop, card list at mobile.
|
||||
- [ ] Loading: skeleton cards (10 desktop, 6 mobile), toolbar visible.
|
||||
- [ ] Empty library, first run: no toolbar, "No images yet" panel and the footnote.
|
||||
- [ ] No results: unused + "wall".
|
||||
- [ ] Hover, focus and keyboard: hover on id 2, focus ring on id 3 (the checkbox reveal is not
|
||||
drawn in this slice).
|
||||
- [ ] Detail panel: in use (id 1), unused (id 8), duplicate (id 12; the action rendered disabled).
|
||||
- [ ] Copy URL: id 1 panel plus the toast.
|
||||
- [ ] Rename: dialog, saving, validation, failed, done (id 1, "north-wall-01").
|
||||
- [ ] Delete: unused (id 8), in use (id 1), in progress (id 1), failed (id 8), done (grid without
|
||||
id 8 plus toast).
|
||||
- [ ] Image already gone (404): grid without id 8 plus the info toast.
|
||||
- [ ] List failed to load: alert with "Try again".
|
||||
- [ ] Offline: banner and disabled Upload.
|
||||
|
||||
Pending, slice B2 (`skip: 'pending'`, never reported as passing):
|
||||
|
||||
- Selection and bulk bar; Bulk delete: confirm, in progress, partly failed, done
|
||||
- Upload: drag over the page, several files mixed, summary, rejected files (413, 400), too many requests (429)
|
||||
- Duplicates: groups, empty; Merge: confirm, in progress, failed, conflict (409), done
|
||||
|
||||
Manual checks screenshots cannot cover:
|
||||
|
||||
- [ ] Keyboard and focus order: Tab visits the search, tabs, sort, toggle, then one stop per card;
|
||||
arrow keys move across the grid; Enter opens the panel; focus is trapped in the panel and in
|
||||
dialogs, starts on Cancel in dialogs, and returns to the card on close; after a delete focus
|
||||
lands on the next card.
|
||||
- [ ] Hover, motion and reduced motion: card hover shadow, skeleton pulse, panel and dialog
|
||||
transitions stop under reduced motion.
|
||||
- [ ] Screen reader: the card's accessible name reads correctly.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real round trips against a running server** (`task dev`): list with real counts, sorting,
|
||||
search, usage, rename (extension kept, URL unchanged), delete, a concurrent 404. Vitest only
|
||||
mocks `useApi`.
|
||||
- **Real image rendering**: the thumbnails and previews load from the real `url` and the
|
||||
checkerboard shows behind real transparent PNG and SVG files.
|
||||
- **Real clipboard write** in a real browser.
|
||||
- **Toast placement and stacking** on a live app.
|
||||
- **Counts after a delete** against a live server: the background refresh gives the true Unused
|
||||
and Duplicates counts.
|
||||
- **A library above 100 images**: B1 requests `limit=100` and ignores `next_cursor`, so only the
|
||||
first 100 show.
|
||||
Reference in new issue
Block a user