Files
RenewCMS/features/media_browse.feature
T

375 lines
25 KiB
Gherkin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 upload, multi-select, the bulk bar, the Duplicates tab content, duplicate merge,
drag-and-drop and the rate-limited state are specified in
features/media_upload_selection_duplicates.feature
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 an explicit
page and size (page=1&size=25 by default, or the stored size), and every request carries
the jwt cookie through useApi
And this slice is amended by features/media_pagination_api.feature and
features/media_pagination_screen.feature: limit=100 and next_cursor are gone, the body is
{ items, total, page, size, counts }, and the list is paginated
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; on mobile it is
the full-width 48 px button pinned to the bottom
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 selects the groups view and ?filter=duplicates is a valid value
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 are specified in
features/media_upload_selection_duplicates.feature
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 }, total 12, page 1, size 25
Scenario: Default, many images
Given GET /v1/media/?filter=all&sort=newest&page=1&size=25 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 reads "Select all" and "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 page=1&size=25
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&page=1&size=25 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: 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, 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&page=1&size=25 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 the card's selection toggles and no panel opens
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 enabled and selects the Duplicates tab
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