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 "" has the accessible name "" and the 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 Then GET /v1/media/ is sent with "" and page=1&size=25 And the control shows "" 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= 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 "" 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 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 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 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/ 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 Then the user is sent to 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