From 67d456baf6490bfe4bb08ad8dc46caa941833d99 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Wed, 30 Sep 2026 19:45:47 +0200 Subject: [PATCH] feat(SPA): media page T0 --- features/media_browse.feature | 383 +++++ features/media_browse.qa.md | 160 ++ web/src/components/ConfirmDialog.vue | 31 +- web/src/components/DashboardShell.vue | 27 +- web/src/components/DeleteMediaDialog.vue | 55 + .../components/MediaArticleStatusBadge.vue | 16 + web/src/components/MediaBadge.vue | 47 + web/src/components/MediaCardBadges.vue | 13 + web/src/components/MediaCardList.vue | 50 + web/src/components/MediaDetailPanel.vue | 214 +++ web/src/components/MediaEmptyState.vue | 70 + web/src/components/MediaGrid.vue | 92 + web/src/components/MediaSkeletonGrid.vue | 28 + web/src/components/MediaTable.vue | 85 + web/src/components/MediaThumb.vue | 87 + web/src/components/MediaToolbar.vue | 187 ++ web/src/components/OfflineBanner.vue | 24 +- web/src/components/RenameMediaDialog.vue | 84 + .../__tests__/DashboardShell.spec.ts | 47 +- .../composables/__tests__/useMedia.spec.ts | 259 +++ web/src/composables/useMedia.ts | 164 ++ web/src/lib/__tests__/mediaFormat.spec.ts | 97 ++ web/src/lib/__tests__/mediaQuery.spec.ts | 51 + web/src/lib/mediaFormat.ts | 71 + web/src/lib/mediaQuery.ts | 69 + web/src/router/__tests__/routerGuard.spec.ts | 34 + web/src/router/index.ts | 5 + web/src/views/MediaView.vue | 473 +++++ web/src/views/__tests__/MediaView.spec.ts | 1518 +++++++++++++++++ web/src/views/__tests__/ProfileView.spec.ts | 4 +- web/visual/check.ts | 38 +- web/visual/states.ts | 431 ++++- 32 files changed, 4874 insertions(+), 40 deletions(-) create mode 100644 features/media_browse.feature create mode 100644 features/media_browse.qa.md create mode 100644 web/src/components/DeleteMediaDialog.vue create mode 100644 web/src/components/MediaArticleStatusBadge.vue create mode 100644 web/src/components/MediaBadge.vue create mode 100644 web/src/components/MediaCardBadges.vue create mode 100644 web/src/components/MediaCardList.vue create mode 100644 web/src/components/MediaDetailPanel.vue create mode 100644 web/src/components/MediaEmptyState.vue create mode 100644 web/src/components/MediaGrid.vue create mode 100644 web/src/components/MediaSkeletonGrid.vue create mode 100644 web/src/components/MediaTable.vue create mode 100644 web/src/components/MediaThumb.vue create mode 100644 web/src/components/MediaToolbar.vue create mode 100644 web/src/components/RenameMediaDialog.vue create mode 100644 web/src/composables/__tests__/useMedia.spec.ts create mode 100644 web/src/composables/useMedia.ts create mode 100644 web/src/lib/__tests__/mediaFormat.spec.ts create mode 100644 web/src/lib/__tests__/mediaQuery.spec.ts create mode 100644 web/src/lib/mediaFormat.ts create mode 100644 web/src/lib/mediaQuery.ts create mode 100644 web/src/views/MediaView.vue create mode 100644 web/src/views/__tests__/MediaView.spec.ts diff --git a/features/media_browse.feature b/features/media_browse.feature new file mode 100644 index 0000000..8a26256 --- /dev/null +++ b/features/media_browse.feature @@ -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 "" 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 limit=100 + 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&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= 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 "" 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 diff --git a/features/media_browse.qa.md b/features/media_browse.qa.md new file mode 100644 index 0000000..bc83cb0 --- /dev/null +++ b/features/media_browse.qa.md @@ -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. diff --git a/web/src/components/ConfirmDialog.vue b/web/src/components/ConfirmDialog.vue index eb8f9d3..ed6f5a9 100644 --- a/web/src/components/ConfirmDialog.vue +++ b/web/src/components/ConfirmDialog.vue @@ -9,7 +9,7 @@ const props = withDefaults( cancelLabel: string pendingLabel: string tone: 'danger' | 'neutral' - action: () => Promise + action: () => Promise dismissResolvesTo?: 'cancel' | 'confirm' }>(), { @@ -55,8 +55,10 @@ async function onConfirm() { pending.value = true error.value = '' try { - await props.action() - settle(true) + const outcome = await props.action() + if (outcome !== false) { + settle(true) + } } catch (err) { error.value = err instanceof Error ? err.message : 'Something went wrong. Try again.' } finally { @@ -94,16 +96,19 @@ function onCancel() { }" >