feat(SPA): media page T0

This commit is contained in:
fsylvain committed 2026-09-30 19:45:47 +02:00
1 parent 0d99544579
commit 67d456baf6
32 files changed
+4874 -40

No files matched your search

+383
View File
@@ -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
+160
View File
@@ -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.
+18 -13
View File
@@ -9,7 +9,7 @@ const props = withDefaults(
cancelLabel: string
pendingLabel: string
tone: 'danger' | 'neutral'
action: () => Promise<void>
action: () => Promise<void | boolean>
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() {
}"
>
<template #body>
<UAlert
v-if="error"
color="error"
icon="i-lucide-circle-x"
:description="error"
variant="soft"
:close="false"
role="alert"
:ui="{ root: 'gap-2 px-3 py-2.5', description: 'text-sm text-toned' }"
/>
<div class="flex flex-col gap-4">
<UAlert
v-if="error"
color="error"
icon="i-lucide-circle-x"
:description="error"
variant="soft"
:close="false"
role="alert"
:ui="{ root: 'gap-2 px-3 py-2.5', description: 'text-sm text-toned' }"
/>
<slot name="body" :pending="pending" :confirm="onConfirm" />
</div>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
+21 -6
View File
@@ -15,20 +15,30 @@ const props = withDefaults(
defineProps<{
mobileTitle?: string
fullBleed?: boolean
compactPadding?: boolean
renderWhileChecking?: boolean
offlineMessage?: string
offlineIcon?: string
}>(),
{
mobileTitle: undefined,
offlineMessage: undefined,
offlineIcon: undefined,
fullBleed: false,
compactPadding: false,
renderWhileChecking: false,
},
)
const bodyUi = computed(() =>
props.fullBleed
? 'gap-0 p-0 sm:gap-0 sm:p-0 lg:p-0'
: 'gap-0 px-4 pt-5 pb-22 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-22 lg:p-8',
)
const COMPACT_BODY = 'gap-0 px-4 pt-4 pb-22 sm:gap-0 sm:px-4 sm:pt-4 sm:pb-22 lg:p-6'
const DEFAULT_BODY = 'gap-0 px-4 pt-5 pb-22 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-22 lg:p-8'
const bodyUi = computed(() => {
if (props.fullBleed) {
return 'gap-0 p-0 sm:gap-0 sm:p-0 lg:p-0'
}
return props.compactPadding ? COMPACT_BODY : DEFAULT_BODY
})
const route = useRoute()
const router = useRouter()
@@ -39,6 +49,7 @@ const navItems = computed(() =>
[
{ label: 'Home', to: '/home', icon: 'i-lucide-home' },
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
{ label: 'Media', to: '/media', icon: 'i-lucide-images' },
{ label: 'Users', to: '/users', icon: 'i-lucide-users' },
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
{ label: 'Settings', to: '/settings', icon: 'i-lucide-settings' },
@@ -203,7 +214,11 @@ async function onLogout() {
id="dashboard-content"
:class="fullBleed ? 'flex h-full min-h-0 flex-1 flex-col' : ''"
>
<OfflineBanner />
<OfflineBanner
:message="offlineMessage"
:icon="offlineIcon"
:bleed="compactPadding"
/>
<AppBoot v-if="showBoot" />
<ErrorView
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue'
import type { MediaItem, MediaUsageItem } from '@/composables/useMedia'
import { pluralArticles } from '@/lib/mediaFormat'
const props = defineProps<{
item: MediaItem
usage: MediaUsageItem[]
action: () => Promise<void>
}>()
const emit = defineEmits<{ close: [confirmed: boolean] }>()
const inUse = computed(() => props.item.used_in > 0)
const title = computed(() =>
inUse.value ? "Delete an image that's in use?" : 'Delete this image?',
)
const description = computed(() =>
inUse.value
? `"${props.item.filename}" is used in ${pluralArticles(props.item.used_in)}. If you delete it, they will show a broken image. This can't be undone.`
: `"${props.item.filename}" will be deleted from your library. No article uses it. This can't be undone.`,
)
</script>
<template>
<ConfirmDialog
:title="title"
:description="description"
confirm-label="Delete image"
cancel-label="Cancel"
pending-label="Deleting…"
tone="danger"
:action="action"
@close="emit('close', $event)"
>
<template #body>
<ul v-if="inUse && usage.length > 0" class="border border-default">
<li
v-for="(article, index) in usage"
:key="article.id"
data-testid="affected-article"
class="flex min-h-9 items-center gap-2 px-3"
:class="{ 'border-t border-muted': index > 0 }"
>
<span class="min-w-0 flex-1 truncate text-highlighted">{{
article.title
}}</span>
<MediaArticleStatusBadge :published="article.published" />
</li>
</ul>
</template>
</ConfirmDialog>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import MediaBadge from '@/components/MediaBadge.vue'
defineProps<{
published: boolean
size?: 'sm' | 'md'
}>()
</script>
<template>
<MediaBadge
:kind="published ? 'online' : 'draft'"
:label="published ? 'Online' : 'Draft'"
:size="size"
/>
</template>
+47
View File
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
kind: 'unused' | 'duplicate' | 'used' | 'online' | 'draft'
label: string
size?: 'sm' | 'md'
}>(),
{ size: 'sm' },
)
const OUTLINE = 'border border-accented bg-transparent text-toned'
const badges = {
unused: {
icon: 'i-lucide-unlink',
class: 'border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning',
},
duplicate: {
icon: 'i-lucide-copy',
class: 'border border-(--rc-info-soft) bg-(--rc-info-soft) text-info',
},
used: { icon: undefined, class: OUTLINE },
online: {
icon: 'i-lucide-globe',
class: 'border border-inverted bg-inverted text-inverted',
},
draft: { icon: 'i-lucide-pencil-line', class: OUTLINE },
} as const
const badge = computed(() => badges[props.kind])
const sizeClass = computed(() => (props.size === 'md' ? 'h-5.5 px-1.5' : 'h-5 px-1.5'))
</script>
<template>
<UBadge
color="neutral"
variant="soft"
:icon="badge.icon"
:class="[badge.class, sizeClass]"
:data-kind="kind"
:ui="{ base: 'gap-1 py-0 text-xs whitespace-nowrap', leadingIcon: 'size-3' }"
>
{{ label }}
</UBadge>
</template>
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
import MediaBadge from '@/components/MediaBadge.vue'
defineProps<{
item: { duplicate: boolean; used_in: number }
}>()
</script>
<template>
<MediaBadge v-if="item.duplicate" kind="duplicate" label="Duplicate" />
<MediaBadge v-if="item.used_in > 0" kind="used" :label="`Used in ${item.used_in}`" />
<MediaBadge v-else kind="unused" label="Unused" />
</template>
+50
View File
@@ -0,0 +1,50 @@
<script setup lang="ts">
import MediaCardBadges from '@/components/MediaCardBadges.vue'
import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem } from '@/composables/useMedia'
import { formatDimensions, formatMediaDate, formatSize, formatType } from '@/lib/mediaFormat'
defineProps<{
items: MediaItem[]
}>()
const emit = defineEmits<{
open: [id: number, opener: HTMLElement]
}>()
</script>
<template>
<ul class="-mx-4 border-y border-default bg-default">
<li
v-for="item in items"
:key="item.id"
data-testid="media-card-row"
class="border-b border-default last:border-b-0"
>
<button
type="button"
data-media-card
:data-media-id="item.id"
class="flex min-h-20 w-full items-center py-2 pr-3 pl-11 text-start text-muted focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
@click="emit('open', item.id, $event.currentTarget as HTMLElement)"
>
<MediaThumb :item="item" size="md" class="size-14 border border-default" />
<span class="flex min-w-0 flex-1 flex-col gap-0.75 pl-3">
<span class="truncate text-[0.9375rem] font-medium text-highlighted">{{
item.filename
}}</span>
<span class="text-xs/5"
>{{ formatSize(item.size) }} ·
{{ formatDimensions(item.width, item.height) }} ·
{{ formatType(item.content_type) }}</span
>
<span class="flex flex-wrap items-center gap-1 text-xs/5">
<MediaCardBadges :item="item" />
<span>{{ item.uploaded_by }} · {{ formatMediaDate(item.created_at) }}</span>
</span>
</span>
<UIcon name="i-lucide-chevron-right" class="ml-2 size-4 shrink-0" />
</button>
</li>
</ul>
</template>
+214
View File
@@ -0,0 +1,214 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue'
import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem, MediaUsageItem } from '@/composables/useMedia'
import {
formatDimensions,
formatMediaDate,
formatSize,
formatType,
pluralArticles,
} from '@/lib/mediaFormat'
const DESKTOP_QUERY = '(min-width: 48rem)'
const ACTION_BUTTON =
'h-11 justify-center border border-accented bg-default px-3 text-sm ring-0 max-md:flex-1 md:h-8'
const props = defineProps<{
item: MediaItem | null
usage: MediaUsageItem[]
copies: MediaItem[]
offline: boolean
}>()
const emit = defineEmits<{
'copy-url': []
rename: []
delete: []
}>()
const open = defineModel<boolean>('open', { default: false })
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const closeButton = computed(() => ({
color: 'neutral' as const,
variant: 'ghost' as const,
icon: isDesktop.value ? 'i-lucide-x' : 'i-lucide-arrow-left',
class: 'text-muted',
}))
const metadata = computed(() => {
const item = props.item
if (!item) {
return []
}
return [
{ label: 'Type', value: formatType(item.content_type) },
{ label: 'Size', value: formatSize(item.size) },
{ label: 'Dimensions', value: formatDimensions(item.width, item.height) },
{ label: 'Uploaded', value: formatMediaDate(item.created_at) },
{ label: 'Uploaded by', value: item.uploaded_by },
]
})
const displayUrl = computed(() => `${window.location.host}${props.item?.url ?? ''}`)
function joinNames(names: string[]) {
if (names.length <= 1) {
return names.join('')
}
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
}
const duplicateAlert = computed(() => {
if (!props.item?.duplicate || props.copies.length === 0) {
return null
}
const names = props.copies.map((copy) => copy.filename)
return {
title: `Identical to ${props.copies.length} other ${props.copies.length === 1 ? 'image' : 'images'}`,
description: `${joinNames(names)} ${names.length === 1 ? 'has' : 'have'} the same content.`,
}
})
</script>
<template>
<USlideover
v-model:open="open"
side="right"
:title="item?.filename ?? ''"
description="Image details"
:close="closeButton"
:ui="{
content: 'w-full max-w-none md:max-w-115',
header: 'h-14 min-h-14 border-b border-default px-5 py-0 sm:px-5 max-md:px-1.5 max-md:sm:px-1.5',
wrapper: 'min-w-0 flex-1 max-md:ps-11 md:pe-9',
title: 'truncate text-lg font-semibold text-highlighted',
description: 'sr-only',
close: 'top-1.75 size-11 justify-center p-0 max-md:start-1.5 md:top-3.5 md:end-5 md:size-7',
body: 'p-4 md:p-5',
}"
>
<template #body>
<div v-if="item" class="flex flex-col gap-5">
<MediaThumb
:item="item"
size="xl"
class="aspect-4/3 w-full border border-default md:aspect-16/10"
/>
<div class="flex gap-2 max-md:flex-wrap">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-link"
:class="ACTION_BUTTON"
@click="emit('copy-url')"
>
Copy URL
</UButton>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-pencil"
:disabled="offline"
:class="ACTION_BUTTON"
@click="emit('rename')"
>
Rename
</UButton>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-trash-2"
:disabled="offline"
:class="ACTION_BUTTON"
class="text-error"
@click="emit('delete')"
>
Delete
</UButton>
</div>
<UAlert
v-if="duplicateAlert"
data-testid="duplicate-alert"
color="info"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-copy"
:title="duplicateAlert.title"
:description="duplicateAlert.description"
:ui="{ actions: 'mt-2 w-full', description: 'text-wrap' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-arrow-right"
disabled
block
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
>
Review duplicates
</UButton>
</template>
</UAlert>
<dl class="grid grid-cols-[110px_minmax(0,1fr)] gap-x-3 gap-y-2 text-toned">
<template v-for="row in metadata" :key="row.label">
<dt class="text-muted">{{ row.label }}</dt>
<dd class="m-0 text-highlighted">{{ row.value }}</dd>
</template>
<dt class="text-muted">URL</dt>
<dd class="m-0 truncate font-mono text-xs/5 text-highlighted">
{{ displayUrl }}
</dd>
</dl>
<section class="flex flex-col gap-2">
<h3 class="text-base font-semibold text-highlighted">
{{
item.used_in > 0
? `Used in ${pluralArticles(item.used_in)}`
: 'Not used in any article'
}}
</h3>
<template v-if="item.used_in > 0">
<ul v-if="usage.length > 0" class="border border-default">
<li
v-for="(article, index) in usage"
:key="article.id"
data-testid="usage-row"
class="flex min-h-12 items-center gap-2 px-3 md:min-h-10"
:class="{ 'border-t border-muted': index > 0 }"
>
<RouterLink
:to="`/article/${article.id}/edit`"
class="min-w-0 flex-1 truncate font-medium text-highlighted underline underline-offset-3"
>
{{ article.title }}
</RouterLink>
<MediaArticleStatusBadge :published="article.published" size="md" />
<UIcon
name="i-lucide-arrow-up-right"
class="size-3.5 shrink-0 text-muted"
/>
</li>
</ul>
<p class="text-xs text-muted">
Counted from the text of your articles each time you open this page.
</p>
</template>
<p v-else class="text-muted">
This image is safe to delete. No article shows it.
</p>
</section>
</div>
</template>
</USlideover>
</template>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed } from 'vue'
const QUERY_MAX_LENGTH = 40
const props = defineProps<{
variant: 'first-run' | 'no-results'
query?: string
filterLabel?: string
}>()
const emit = defineEmits<{ action: [] }>()
const truncatedQuery = computed(() => (props.query ?? '').slice(0, QUERY_MAX_LENGTH))
const current = computed(() => {
if (props.variant === 'first-run') {
return {
icon: 'i-lucide-images',
title: 'No images yet',
description: 'Upload your first image. You can use it in any article.',
action: 'Upload an image',
}
}
return {
icon: 'i-lucide-search',
title: truncatedQuery.value
? `No images match "${truncatedQuery.value}"`
: 'No images match this filter',
description: `Nothing matches in ${props.filterLabel ?? 'All'}. Check the spelling, or clear the filters.`,
action: 'Clear filters',
}
})
</script>
<template>
<UEmpty
variant="naked"
:icon="current.icon"
:title="current.title"
:description="current.description"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-14',
header: 'gap-3',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-[0.9375rem] font-semibold',
description: 'max-w-80 text-muted text-pretty',
}"
>
<template #actions>
<UButton
v-if="variant === 'first-run'"
icon="i-lucide-upload"
disabled
class="h-11 px-4 md:h-8"
>
{{ current.action }}
</UButton>
<UButton
v-else
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-4 text-highlighted ring-0 md:h-8"
@click="emit('action')"
>
{{ current.action }}
</UButton>
</template>
</UEmpty>
</template>
+92
View File
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { ref } from 'vue'
import MediaCardBadges from '@/components/MediaCardBadges.vue'
import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem } from '@/composables/useMedia'
import { accessibleName, formatDimensions, formatSize } from '@/lib/mediaFormat'
const props = defineProps<{
items: MediaItem[]
columns: number
}>()
const emit = defineEmits<{
open: [id: number, opener: HTMLElement]
}>()
const grid = ref<HTMLElement | null>(null)
function cards() {
return Array.from(grid.value?.querySelectorAll<HTMLElement>('[data-media-card]') ?? [])
}
function targetIndex(key: string, index: number) {
const steps: Record<string, number> = {
ArrowLeft: -1,
ArrowRight: 1,
ArrowUp: -props.columns,
ArrowDown: props.columns,
}
const step = steps[key]
if (step === undefined) {
return null
}
const next = index + step
return next >= 0 && next < props.items.length ? next : index
}
function onKeydown(event: KeyboardEvent, item: MediaItem, index: number) {
if (event.key === ' ') {
event.preventDefault()
return
}
if (event.key === 'Enter') {
event.preventDefault()
emit('open', item.id, event.currentTarget as HTMLElement)
return
}
const target = targetIndex(event.key, index)
if (target === null) {
return
}
event.preventDefault()
cards()[target]?.focus()
}
function onKeyup(event: KeyboardEvent) {
if (event.key === ' ') {
event.preventDefault()
}
}
</script>
<template>
<div ref="grid" class="grid grid-cols-2 gap-2.5 md:grid-cols-5 md:gap-4">
<button
v-for="(item, index) in items"
:key="item.id"
type="button"
data-media-card
:data-media-id="item.id"
:aria-label="accessibleName(item)"
class="flex min-w-0 flex-col border border-default bg-default text-start text-default transition-shadow hover:border-accented hover:shadow-(--shadow-popover) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
@click="emit('open', item.id, $event.currentTarget as HTMLElement)"
@keydown="onKeydown($event, item, index)"
@keyup="onKeyup"
>
<MediaThumb :item="item" class="aspect-4/3 w-full border-b border-default" />
<span class="flex min-w-0 flex-col gap-1.5 px-3 py-2.5">
<span class="flex min-w-0 flex-col gap-0.5">
<span class="truncate font-medium text-highlighted">{{ item.filename }}</span>
<span class="truncate text-xs/5 text-muted"
>{{ formatSize(item.size) }} ·
{{ formatDimensions(item.width, item.height) }}</span
>
</span>
<span class="flex flex-wrap gap-1">
<MediaCardBadges :item="item" />
</span>
</span>
</button>
</div>
</template>
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
defineProps<{
count: number
}>()
</script>
<template>
<div
aria-busy="true"
data-testid="media-skeleton"
class="grid grid-cols-2 gap-2.5 md:grid-cols-5 md:gap-4"
>
<span role="status" class="sr-only">Loading images</span>
<div
v-for="n in count"
:key="n"
data-testid="media-skeleton-card"
class="border border-default bg-default"
>
<USkeleton class="aspect-4/3 w-full rounded-none bg-accented" />
<div class="flex flex-col gap-2 px-3 py-2.5">
<USkeleton class="h-3.25 w-[70%] rounded-none bg-accented" />
<USkeleton class="h-2.75 w-[45%] rounded-none bg-accented" />
<USkeleton class="h-5 w-15 rounded-none bg-accented" />
</div>
</div>
</div>
</template>
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
import MediaBadge from '@/components/MediaBadge.vue'
import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem } from '@/composables/useMedia'
import {
formatDimensions,
formatMediaDate,
formatSize,
formatType,
pluralArticles,
} from '@/lib/mediaFormat'
const ROW_GRID =
'grid grid-cols-[36px_minmax(0,1.7fr)_110px_76px_64px_120px_130px_100px] items-center pr-3'
defineProps<{
items: MediaItem[]
}>()
const emit = defineEmits<{
open: [id: number, opener: HTMLElement]
}>()
function open(event: Event, id: number) {
emit('open', id, event.currentTarget as HTMLElement)
}
</script>
<template>
<div class="border border-default bg-default">
<div :class="ROW_GRID" class="h-9 bg-elevated font-medium text-muted">
<span></span>
<span>Filename</span>
<span>Dimensions</span>
<span>Size</span>
<span>Type</span>
<span>Used in</span>
<span>Uploaded by</span>
<span class="flex items-center gap-1 text-highlighted">
Uploaded
<UIcon name="i-lucide-arrow-down" class="size-3 shrink-0" />
</span>
</div>
<div
v-for="item in items"
:key="item.id"
data-testid="media-row"
:data-media-id="item.id"
:class="ROW_GRID"
class="relative h-14 border-t border-default bg-default text-muted hover:bg-elevated"
>
<span></span>
<span class="flex min-w-0 items-center gap-3 pr-3">
<MediaThumb :item="item" size="sm" class="size-10 border border-default" />
<button
type="button"
data-media-card
:data-media-id="item.id"
class="truncate font-medium text-highlighted after:absolute after:inset-0 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
@click="open($event, item.id)"
>
{{ item.filename }}
</button>
<MediaBadge v-if="item.duplicate" kind="duplicate" label="Duplicate" />
</span>
<span>{{ formatDimensions(item.width, item.height) }}</span>
<span>{{ formatSize(item.size) }}</span>
<span>{{ formatType(item.content_type) }}</span>
<span>
<button
v-if="item.used_in > 0"
type="button"
data-testid="media-used-in"
class="relative z-10 text-highlighted underline underline-offset-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
@click="open($event, item.id)"
>
{{ pluralArticles(item.used_in) }}
</button>
<MediaBadge v-else kind="unused" label="Unused" />
</span>
<span class="truncate pr-2">{{ item.uploaded_by }}</span>
<span>{{ formatMediaDate(item.created_at) }}</span>
</div>
</div>
</template>
+87
View File
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import AppLogo from '@/components/AppLogo.vue'
const TONE_COUNT = 4
const HEX_RADIX = 16
const ALPHA_TYPES = ['image/png', 'image/svg+xml']
const SVG_TYPE = 'image/svg+xml'
const props = withDefaults(
defineProps<{
item: { url: string; content_type: string; sha256: string }
size?: 'sm' | 'md' | 'lg' | 'xl'
}>(),
{ size: 'lg' },
)
const loaded = ref(false)
const failed = ref(false)
const hasAlpha = computed(() => ALPHA_TYPES.includes(props.item.content_type))
const isSvg = computed(() => props.item.content_type === SVG_TYPE)
const tone = computed(() => {
const digit = Number.parseInt(props.item.sha256.charAt(0), HEX_RADIX)
return Number.isNaN(digit) ? 0 : digit % TONE_COUNT
})
const iconClass = computed(
() => ({ sm: 'size-4', md: 'size-5', lg: 'size-6', xl: 'size-10' })[props.size],
)
const logoSize = computed(() => ({ sm: 16, md: 20, lg: 28, xl: 40 })[props.size])
</script>
<template>
<div
data-testid="media-thumb"
class="relative flex shrink-0 items-center justify-center overflow-hidden text-muted"
:class="hasAlpha ? 'media-thumb-checker' : ['media-thumb-tone', `media-thumb-tone-${tone}`]"
>
<span v-if="!loaded" aria-hidden="true" class="absolute flex">
<AppLogo v-if="isSvg" :size="logoSize" aria-hidden="true" />
<UIcon v-else name="i-lucide-image" class="opacity-55" :class="iconClass" />
</span>
<img
v-if="!failed"
:src="item.url"
alt=""
loading="lazy"
class="absolute inset-0 size-full object-cover"
@load="loaded = true"
@error="failed = true"
/>
</div>
</template>
<style scoped>
.media-thumb-checker {
background: conic-gradient(
var(--ui-bg-accented) 25%,
var(--ui-bg) 0 50%,
var(--ui-bg-accented) 0 75%,
var(--ui-bg) 0
)
0 0 / 16px 16px;
}
.media-thumb-tone {
background-image: repeating-linear-gradient(
135deg,
color-mix(in srgb, var(--ui-text-muted) 12%, transparent) 0 1px,
transparent 1px 12px
);
}
.media-thumb-tone-0 {
background-color: var(--ui-color-secondary-200);
}
.dark .media-thumb-tone-0 {
background-color: var(--ui-color-secondary-300);
}
.media-thumb-tone-1 {
background-color: var(--ui-bg-accented);
}
.media-thumb-tone-2 {
background-color: color-mix(in srgb, var(--ui-border-accented) 35%, var(--ui-bg));
}
.media-thumb-tone-3 {
background-color: var(--ui-bg-elevated);
}
</style>
+187
View File
@@ -0,0 +1,187 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import type { MediaCounts } from '@/composables/useMedia'
import type { MediaControls, MediaFilter, MediaSort, MediaView } from '@/lib/mediaQuery'
const SEARCH_DEBOUNCE_MS = 120
const TAB_TRIGGER =
'h-9 px-0 py-0 data-[state=inactive]:text-toned in-[[data-slot=list]:not(:has([data-slot=indicator]))]:data-[state=active]:after:bottom-0 in-[[data-slot=list]:not(:has([data-slot=indicator]))]:data-[state=active]:after:h-0.5 max-md:h-11 max-md:text-[0.9375rem]'
const SORT_LABELS: Record<MediaSort, string> = {
newest: 'Newest',
oldest: 'Oldest',
name: 'Name',
largest: 'Largest',
}
const VIEW_OPTIONS: { value: MediaView; icon: string; label: string }[] = [
{ value: 'grid', icon: 'i-lucide-layout-grid', label: 'Grid view' },
{ value: 'list', icon: 'i-lucide-list', label: 'List view' },
]
const props = defineProps<{
controls: MediaControls
counts: MediaCounts | null
}>()
const emit = defineEmits<{
'update:q': [value: string]
'update:filter': [value: MediaFilter]
'update:sort': [value: MediaSort]
'update:view': [value: MediaView]
}>()
const searchText = ref(props.controls.q)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function clearDebounce() {
if (debounceTimer !== null) {
clearTimeout(debounceTimer)
debounceTimer = null
}
}
watch(
() => props.controls.q,
(value) => {
if (value !== searchText.value.trim()) {
clearDebounce()
searchText.value = value
}
},
)
watch(searchText, (value) => {
clearDebounce()
debounceTimer = setTimeout(() => {
debounceTimer = null
const trimmed = value.trim()
if (trimmed !== props.controls.q) {
emit('update:q', trimmed)
}
}, SEARCH_DEBOUNCE_MS)
})
function clearSearch() {
searchText.value = ''
}
onBeforeUnmount(clearDebounce)
const tabItems = computed(() => [
{ label: 'All', value: 'all', count: props.counts?.all },
{ label: 'Unused', value: 'unused', count: props.counts?.unused },
{ label: 'Duplicates', value: 'duplicates', count: props.counts?.duplicates, disabled: true },
])
const tab = computed({
get: () => props.controls.filter,
set: (value: string) => {
if (value === 'all' || value === 'unused') {
emit('update:filter', value)
}
},
})
const sortItems = computed(() =>
(Object.keys(SORT_LABELS) as MediaSort[]).map((value) => ({
label: SORT_LABELS[value],
type: 'checkbox' as const,
checked: props.controls.sort === value,
onUpdateChecked: () => emit('update:sort', value),
})),
)
</script>
<template>
<div
class="flex flex-col gap-3 md:flex-row md:items-end md:gap-3 md:border-b md:border-default"
>
<UTabs
v-model="tab"
:items="tabItems"
variant="link"
color="neutral"
:content="false"
class="order-2 md:order-none md:-mb-px md:flex-1"
:ui="{
list: 'gap-5 border-b-0 p-0 max-md:border-b',
trigger: TAB_TRIGGER,
}"
>
<template #default="{ item }">
<span class="flex items-center gap-1.5">
<span>{{ item.label }}</span>
<span
v-if="item.value === 'duplicates' && item.count"
data-testid="duplicates-count"
class="inline-flex h-4.5 min-w-4.5 items-center justify-center bg-(--rc-info-soft) px-1.25 font-mono text-[0.6875rem] font-medium text-info"
>{{ item.count }}</span
>
<span
v-else-if="item.value !== 'duplicates' && item.count !== undefined"
class="font-mono text-[0.6875rem] font-medium text-muted"
>{{ item.count }}</span
>
</span>
</template>
</UTabs>
<UInput
v-model="searchText"
type="text"
icon="i-lucide-search"
placeholder="Search by filename"
aria-label="Search by filename"
class="order-1 w-full md:order-none md:mb-1.5 md:w-60"
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm', leadingIcon: 'size-3.5' }"
>
<template v-if="searchText" #trailing>
<UButton
color="neutral"
variant="link"
size="xs"
icon="i-lucide-x"
aria-label="Clear search"
class="text-muted"
@click="clearSearch"
/>
</template>
</UInput>
<div class="order-3 flex gap-2 md:order-none md:mb-1.5">
<UDropdownMenu :items="sortItems" :content="{ align: 'start' }">
<UButton
color="neutral"
variant="outline"
trailing-icon="i-lucide-chevron-down"
aria-label="Sort"
class="h-11 flex-1 justify-between border border-accented bg-default px-2.5 text-sm font-normal text-highlighted ring-0 md:h-8 md:w-32 md:flex-none"
:ui="{ trailingIcon: 'size-3.5 text-muted' }"
>
{{ SORT_LABELS[controls.sort] }}
</UButton>
</UDropdownMenu>
<div role="group" aria-label="View" class="flex shrink-0 border border-accented">
<UButton
v-for="option in VIEW_OPTIONS"
:key="option.value"
color="neutral"
variant="ghost"
:icon="option.icon"
:aria-label="option.label"
:aria-pressed="controls.view === option.value"
class="h-10.5 w-11 justify-center md:h-7.5 md:w-8"
:class="
controls.view === option.value
? 'bg-secondary-200 text-primary-900 hover:bg-secondary-200 dark:bg-secondary-300 dark:hover:bg-secondary-300'
: 'bg-default text-toned'
"
@click="emit('update:view', option.value)"
/>
</div>
</div>
</div>
</template>
+23 -1
View File
@@ -1,6 +1,26 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
const DEFAULT_MESSAGE =
"You're offline. You can keep writing, but saving and publishing will wait until you're back online."
const BLEED_UI = {
root: 'w-auto -mx-4 -mt-4 mb-4 lg:-mx-6 lg:-mt-6 lg:mb-6',
container: 'h-auto min-h-10 max-w-none justify-start gap-2.5 px-4 py-2.5 sm:px-4 lg:px-4',
left: 'hidden lg:hidden',
icon: 'size-4',
title: 'whitespace-normal font-normal text-wrap',
}
withDefaults(
defineProps<{
message?: string
icon?: string
bleed?: boolean
}>(),
{ message: DEFAULT_MESSAGE, icon: undefined, bleed: false },
)
const isOffline = ref(!navigator.onLine)
function updateOnlineStatus() {
@@ -22,6 +42,8 @@ onUnmounted(() => {
<UBanner
v-if="isOffline"
color="neutral"
title="You're offline. You can keep writing, but saving and publishing will wait until you're back online."
:icon="icon"
:title="message"
:ui="bleed ? BLEED_UI : undefined"
/>
</template>
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import type { MediaItem } from '@/composables/useMedia'
import { isValidFilenameStem, splitFilename } from '@/lib/mediaFormat'
const INVALID_FILENAME_MESSAGE = 'Enter a filename.'
const props = defineProps<{
item: MediaItem
action: (stem: string) => Promise<void>
}>()
const emit = defineEmits<{ close: [confirmed: boolean] }>()
const parts = splitFilename(props.item.filename)
const stem = ref(parts.stem)
const touched = ref(false)
const error = computed(() =>
touched.value && !isValidFilenameStem(stem.value) ? INVALID_FILENAME_MESSAGE : undefined,
)
const help = computed(
() => `Letters, numbers, dashes and underscores. The ${parts.extension} ending stays.`,
)
async function submit() {
touched.value = true
if (!isValidFilenameStem(stem.value)) {
return false
}
await props.action(stem.value.trim())
}
</script>
<template>
<ConfirmDialog
title="Rename image"
description="Only the filename changes. The URL stays the same, so articles that use this image keep working."
confirm-label="Rename"
cancel-label="Cancel"
pending-label="Renaming…"
tone="neutral"
:action="submit"
@close="emit('close', $event)"
>
<template #body="{ pending, confirm }">
<form class="flex flex-col" novalidate @submit.prevent="confirm">
<UFormField
label="Filename"
name="filename"
:error="error"
:help="help"
:ui="{ error: 'mt-1.5 text-xs/5', help: 'mt-1.5 text-xs/5' }"
>
<template v-if="error" #error="{ error: message }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ message }}
</span>
</template>
<UInput
v-model="stem"
autocomplete="off"
:disabled="pending"
class="w-full"
:highlight="!!error"
:ui="{ base: 'h-11 text-lg md:h-10 md:text-base' }"
@blur="touched = true"
>
<template #trailing>
<span class="font-mono text-base text-muted">{{
parts.extension
}}</span>
</template>
</UInput>
</UFormField>
</form>
</template>
</ConfirmDialog>
</template>
@@ -58,14 +58,21 @@ afterEach(async () => {
document.body.innerHTML = ''
})
describe('DashboardShell renders the five navigation destinations', () => {
it('lists Home, Articles, Users, Integration and Settings in order, with only Articles carrying aria-current', async () => {
describe('DashboardShell renders the six navigation destinations', () => {
it('lists Home, Articles, Media, Users, Integration and Settings in order, with only Articles carrying aria-current', async () => {
const wrapper = await mountShell('/article')
const hrefs = wrapper
.findAll('nav[aria-label="Main"] a')
.map((link) => link.attributes('href'))
expect(hrefs).toEqual(['/home', '/article', '/users', '/integration', '/settings'])
expect(hrefs).toEqual([
'/home',
'/article',
'/media',
'/users',
'/integration',
'/settings',
])
const home = wrapper.find('a[href="/home"]')
const articles = wrapper.find('a[href="/article"]')
@@ -78,7 +85,7 @@ describe('DashboardShell renders the five navigation destinations', () => {
expect(integration.attributes('aria-current')).toBeUndefined()
})
it('lists the same five destinations in the mobile drawer and Settings current', async () => {
it('lists the same six destinations in the mobile drawer and Settings current', async () => {
await mountShell('/settings', { width: MOBILE_WIDTH })
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
@@ -91,12 +98,36 @@ describe('DashboardShell renders the five navigation destinations', () => {
expect(links.map((link) => link.getAttribute('href'))).toEqual([
'/home',
'/article',
'/media',
'/users',
'/integration',
'/settings',
])
expect(links[4].querySelector('[class*="lucide"], svg')).not.toBeNull()
expect(links[4].getAttribute('aria-current')).toBe('page')
expect(links[5].querySelector('[class*="lucide"], svg')).not.toBeNull()
expect(links[5].getAttribute('aria-current')).toBe('page')
})
it('marks Media with aria-current on /media, in the sidebar and in the mobile drawer', async () => {
const wrapper = await mountShell('/media')
const media = wrapper.find('a[href="/media"]')
expect(media.text()).toContain('Media')
expect(media.attributes('aria-current')).toBe('page')
expect(media.find('svg, [class*="lucide"]').exists()).toBe(true)
expect(wrapper.find('a[href="/article"]').attributes('aria-current')).toBeUndefined()
expect(wrapper.find('a[href="/users"]').attributes('aria-current')).toBeUndefined()
wrapper.unmount()
mountedShell = null
document.body.innerHTML = ''
await mountShell('/media', { width: MOBILE_WIDTH })
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
toggle.click()
await flushPromises()
const drawerMedia = document.body.querySelector(
'[role="dialog"] nav[aria-label="Main"] a[href="/media"]',
)
expect(drawerMedia?.getAttribute('aria-current')).toBe('page')
})
it('marks Users with aria-current on /users', async () => {
@@ -232,11 +263,11 @@ describe('The account chip shows the display name and no item is current on /pro
expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu')
})
it('marks none of the five navigation items current on /profile', async () => {
it('marks none of the six navigation items current on /profile', async () => {
const wrapper = await mountShell('/profile')
const links = wrapper.findAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(5)
expect(links).toHaveLength(6)
links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined())
})
})
@@ -0,0 +1,259 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { classifyMediaError, useMedia, type MediaItem } from '../useMedia'
import { DEFAULT_CONTROLS } from '@/lib/mediaQuery'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
function item(id: number, overrides: Partial<MediaItem> = {}): MediaItem {
return {
id,
filename: `image-${id}.jpg`,
url: `/media/image-${id}.jpg`,
size: 2097152,
content_type: 'image/jpeg',
width: 3600,
height: 2400,
sha256: `hash-${id}`,
uploaded_by: 'jonas',
created_at: '2026-09-12T12:00:00Z',
used_in: 0,
duplicate: false,
...overrides,
}
}
function listBody(items: MediaItem[]) {
return {
items,
next_cursor: null,
counts: { all: items.length, unused: 1, duplicates: 0 },
}
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
let request: ReturnType<typeof vi.fn>
beforeEach(() => {
request = vi.fn()
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request })
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('useMedia list state', () => {
it('requests the list with the controls and limit=100 and stores items and counts', async () => {
request.mockResolvedValue(listBody([item(1), item(2)]))
const media = useMedia()
await media.load({ ...DEFAULT_CONTROLS, q: 'wall', filter: 'unused' })
expect(request).toHaveBeenCalledWith(
'/v1/media/?q=wall&filter=unused&sort=newest&limit=100',
)
expect(media.items.value.map((entry) => entry.id)).toEqual([1, 2])
expect(media.counts.value).toEqual({ all: 2, unused: 1, duplicates: 0 })
expect(media.status.value).toBe('ready')
})
it('shows the skeleton only after 150 ms of loading', async () => {
const pending = deferred<unknown>()
request.mockReturnValue(pending.promise)
const media = useMedia()
const loading = media.load(DEFAULT_CONTROLS)
expect(media.status.value).toBe('loading')
expect(media.showSkeleton.value).toBe(false)
vi.advanceTimersByTime(149)
expect(media.showSkeleton.value).toBe(false)
vi.advanceTimersByTime(1)
expect(media.showSkeleton.value).toBe(true)
pending.resolve(listBody([item(1)]))
await loading
expect(media.showSkeleton.value).toBe(false)
expect(media.status.value).toBe('ready')
})
it.each([
['a 500 response', () => new ApiError(500)],
['a network failure', () => new NetworkError()],
])('moves to error on %s and recovers on the next load', async (_name, failure) => {
request.mockRejectedValueOnce(failure())
const media = useMedia()
await media.load(DEFAULT_CONTROLS)
expect(media.status.value).toBe('error')
request.mockResolvedValueOnce(listBody([item(1)]))
await media.load(DEFAULT_CONTROLS)
expect(media.status.value).toBe('ready')
expect(media.items.value).toHaveLength(1)
})
it.each([401, 403])(
'marks a %i as handled by useApi, not as a local failure',
async (status) => {
request.mockRejectedValueOnce(new ApiError(status))
const media = useMedia()
await media.load(DEFAULT_CONTROLS)
expect(media.status.value).toBe('handled')
},
)
it('a silent reload keeps the status and ignores failures', async () => {
request.mockResolvedValueOnce(listBody([item(1)]))
const media = useMedia()
await media.load(DEFAULT_CONTROLS)
const pending = deferred<unknown>()
request.mockReturnValueOnce(pending.promise)
const silent = media.load(DEFAULT_CONTROLS, { silent: true })
expect(media.status.value).toBe('ready')
vi.advanceTimersByTime(500)
expect(media.showSkeleton.value).toBe(false)
pending.reject(new ApiError(500))
await silent
expect(media.status.value).toBe('ready')
expect(media.items.value).toHaveLength(1)
})
it('a silent reload refreshes items and counts', async () => {
request.mockResolvedValueOnce(listBody([item(1), item(2)]))
const media = useMedia()
await media.load(DEFAULT_CONTROLS)
request.mockResolvedValueOnce({
items: [item(1)],
next_cursor: null,
counts: { all: 1, unused: 0, duplicates: 0 },
})
await media.load(DEFAULT_CONTROLS, { silent: true })
expect(media.items.value).toHaveLength(1)
expect(media.counts.value.all).toBe(1)
})
it('ignores a response that a newer load has replaced', async () => {
const first = deferred<unknown>()
const second = deferred<unknown>()
request.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
const media = useMedia()
const older = media.load(DEFAULT_CONTROLS)
const newer = media.load({ ...DEFAULT_CONTROLS, q: 'wall' })
second.resolve(listBody([item(2)]))
await newer
first.resolve(listBody([item(1)]))
await older
expect(media.items.value.map((entry) => entry.id)).toEqual([2])
})
})
describe('useMedia requests', () => {
it('fetches the usage of an image', async () => {
const usage = [{ id: 10, title: 'How we price a bathroom renovation', published: true }]
request.mockResolvedValue(usage)
const media = useMedia()
await expect(media.fetchUsage(1)).resolves.toEqual(usage)
expect(request).toHaveBeenCalledWith('/v1/media/1/usage')
})
it('finds the other copies by hash and excludes the image itself', async () => {
request.mockResolvedValue(listBody([item(12), item(5), item(6)]))
const media = useMedia()
const others = await media.findCopies(item(12, { sha256: 'abc123' }))
expect(request).toHaveBeenCalledWith('/v1/media/?hash=abc123')
expect(others.map((entry) => entry.id)).toEqual([5, 6])
})
it('renames with the filename only and replaces the item in place', async () => {
request.mockResolvedValueOnce(listBody([item(1), item(2), item(3)]))
const media = useMedia()
await media.load(DEFAULT_CONTROLS)
request.mockResolvedValueOnce(item(2, { filename: 'kept-name.jpg' }))
const renamed = await media.renameItem(2, 'kept-name')
expect(request).toHaveBeenLastCalledWith('/v1/media/2', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: 'kept-name' }),
})
expect(renamed.filename).toBe('kept-name.jpg')
expect(media.items.value.map((entry) => entry.filename)).toEqual([
'image-1.jpg',
'kept-name.jpg',
'image-3.jpg',
])
})
it('deletes and removes the item locally', async () => {
request.mockResolvedValueOnce(listBody([item(1), item(2)]))
const media = useMedia()
await media.load(DEFAULT_CONTROLS)
request.mockResolvedValueOnce(undefined)
await media.deleteItem(1)
expect(request).toHaveBeenLastCalledWith('/v1/media/1', { method: 'DELETE' })
expect(media.items.value.map((entry) => entry.id)).toEqual([2])
})
it('removeLocal drops an item without a request', async () => {
request.mockResolvedValueOnce(listBody([item(1), item(2)]))
const media = useMedia()
await media.load(DEFAULT_CONTROLS)
request.mockClear()
media.removeLocal(2)
expect(media.items.value.map((entry) => entry.id)).toEqual([1])
expect(request).not.toHaveBeenCalled()
})
it('copies the absolute address of the image', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
const media = useMedia()
await media.copyUrl(item(1, { url: '/media/north-wall.jpg' }))
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/media/north-wall.jpg`)
})
})
describe('classifyMediaError', () => {
it.each([
[new ApiError(401), 'handled'],
[new ApiError(403), 'handled'],
[new ApiError(404), 'not-found'],
[new ApiError(400), 'failed'],
[new ApiError(500), 'failed'],
[new NetworkError(), 'failed'],
[new Error('x'), 'failed'],
])('classifies %s', (error, expected) => {
expect(classifyMediaError(error)).toBe(expected)
})
})
+164
View File
@@ -0,0 +1,164 @@
import { ref } from 'vue'
import { tryOnBeforeUnmount } from '@vueuse/core'
import { ApiError, useApi } from '@/composables/useApi'
import { toRequestQuery, type MediaControls } from '@/lib/mediaQuery'
export interface MediaItem {
id: number
filename: string
url: string
size: number
content_type: string
width: number | null
height: number | null
sha256: string
uploaded_by: string
created_at: string
used_in: number
duplicate: boolean
}
export interface MediaCounts {
all: number
unused: number
duplicates: number
}
export interface MediaUsageItem {
id: number
title: string
published: boolean
}
interface MediaListResponse {
items: MediaItem[]
next_cursor: string | null
counts: MediaCounts
}
export type MediaLoadStatus = 'loading' | 'ready' | 'error' | 'handled'
export type MediaFailure = 'handled' | 'not-found' | 'failed'
export const MEDIA_SKELETON_DELAY_MS = 150
const AUTH_HANDLED_STATUSES = [401, 403]
const NOT_FOUND_STATUS = 404
export function classifyMediaError(error: unknown): MediaFailure {
if (error instanceof ApiError && AUTH_HANDLED_STATUSES.includes(error.status)) {
return 'handled'
}
if (error instanceof ApiError && error.status === NOT_FOUND_STATUS) {
return 'not-found'
}
return 'failed'
}
export function useMedia() {
const { request } = useApi()
const items = ref<MediaItem[]>([])
const counts = ref<MediaCounts>({ all: 0, unused: 0, duplicates: 0 })
const status = ref<MediaLoadStatus>('loading')
const showSkeleton = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let latestLoad = 0
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
function startLoading() {
status.value = 'loading'
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, MEDIA_SKELETON_DELAY_MS)
}
async function load(controls: MediaControls, options: { silent?: boolean } = {}) {
const silent = options.silent === true
if (!silent) {
latestLoad += 1
startLoading()
}
const ticket = latestLoad
try {
const response = await request<MediaListResponse>(
`/v1/media/?${toRequestQuery(controls)}`,
)
if (ticket !== latestLoad) {
return
}
items.value = response.items
counts.value = response.counts
status.value = 'ready'
} catch (error) {
if (silent || ticket !== latestLoad) {
return
}
status.value = classifyMediaError(error) === 'handled' ? 'handled' : 'error'
} finally {
if (!silent && ticket === latestLoad) {
clearSkeletonTimer()
showSkeleton.value = false
}
}
}
async function fetchUsage(id: number) {
return request<MediaUsageItem[]>(`/v1/media/${id}/usage`)
}
async function findCopies(item: MediaItem) {
const response = await request<MediaListResponse>(`/v1/media/?hash=${item.sha256}`)
return response.items.filter((copy) => copy.id !== item.id)
}
function replaceLocal(updated: MediaItem) {
items.value = items.value.map((entry) => (entry.id === updated.id ? updated : entry))
}
function removeLocal(id: number) {
items.value = items.value.filter((entry) => entry.id !== id)
}
async function renameItem(id: number, filename: string) {
const updated = await request<MediaItem>(`/v1/media/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename }),
})
replaceLocal(updated)
return updated
}
async function deleteItem(id: number) {
await request<void>(`/v1/media/${id}`, { method: 'DELETE' })
removeLocal(id)
}
function copyUrl(item: MediaItem) {
return navigator.clipboard.writeText(`${window.location.origin}${item.url}`)
}
tryOnBeforeUnmount(clearSkeletonTimer)
return {
items,
counts,
status,
showSkeleton,
load,
fetchUsage,
findCopies,
renameItem,
deleteItem,
removeLocal,
copyUrl,
}
}
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import {
accessibleName,
formatDimensions,
formatSize,
formatType,
isValidFilenameStem,
splitFilename,
usedInLabel,
} from '../mediaFormat'
const base = {
id: 1,
filename: 'north-wall.jpg',
url: '/media/north-wall.jpg',
size: 4089446,
content_type: 'image/jpeg',
width: 4032,
height: 3024,
sha256: 'a',
uploaded_by: 'Marta Alves',
created_at: '2026-09-12T12:00:00Z',
used_in: 2,
duplicate: false,
}
describe('formatSize', () => {
it.each([
[8192, '8 KB'],
[880640, '860 KB'],
[4089446, '3.9 MB'],
[1048576, '1.0 MB'],
[1048575, '1024 KB'],
])('reads %i bytes as %s', (bytes, expected) => {
expect(formatSize(bytes)).toBe(expected)
})
})
describe('formatDimensions', () => {
it('reads width and height with a multiplication sign', () => {
expect(formatDimensions(4032, 3024)).toBe('4032 × 3024')
})
it('reads Vector when width and height are null', () => {
expect(formatDimensions(null, null)).toBe('Vector')
})
})
describe('formatType', () => {
it.each([
['image/jpeg', 'JPEG'],
['image/png', 'PNG'],
['image/webp', 'WEBP'],
['image/svg+xml', 'SVG'],
])('reads %s as %s', (contentType, expected) => {
expect(formatType(contentType)).toBe(expected)
})
})
describe('usedInLabel and accessibleName', () => {
it('pluralises the article count', () => {
expect(usedInLabel(1)).toBe('used in 1 article')
expect(usedInLabel(2)).toBe('used in 2 articles')
expect(usedInLabel(0)).toBe('unused')
})
it('builds the card name from filename, size and usage', () => {
expect(accessibleName(base)).toBe('north-wall.jpg, 3.9 MB, used in 2 articles')
expect(accessibleName({ ...base, used_in: 1 })).toBe(
'north-wall.jpg, 3.9 MB, used in 1 article',
)
expect(accessibleName({ ...base, used_in: 0 })).toBe('north-wall.jpg, 3.9 MB, unused')
})
})
describe('splitFilename', () => {
it('separates the stem from the extension', () => {
expect(splitFilename('north-wall.jpg')).toEqual({ stem: 'north-wall', extension: '.jpg' })
expect(splitFilename('a.b.webp')).toEqual({ stem: 'a.b', extension: '.webp' })
expect(splitFilename('noextension')).toEqual({ stem: 'noextension', extension: '' })
})
})
describe('isValidFilenameStem', () => {
it.each(['north-wall', 'north-wall-01', 'Été_2026', ' padded '])('accepts %j', (value) => {
expect(isValidFilenameStem(value)).toBe(true)
})
it.each(['', ' ', '---', '!!!', 'a b', 'a.b', '___'])('rejects %j', (value) => {
expect(isValidFilenameStem(value)).toBe(false)
})
it('rejects more than 100 characters', () => {
expect(isValidFilenameStem('a'.repeat(100))).toBe(true)
expect(isValidFilenameStem('a'.repeat(101))).toBe(false)
})
})
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_CONTROLS, parseMediaControls, toRequestQuery, toRouteQuery } from '../mediaQuery'
describe('parseMediaControls', () => {
it('uses the defaults for an empty query', () => {
expect(parseMediaControls({})).toEqual(DEFAULT_CONTROLS)
})
it.each([
[{ filter: 'unused' }, { filter: 'unused' }],
[{ sort: 'largest' }, { sort: 'largest' }],
[{ sort: 'name' }, { sort: 'name' }],
[{ sort: 'oldest' }, { sort: 'oldest' }],
[{ q: 'wall' }, { q: 'wall' }],
[{ view: 'list' }, { view: 'list' }],
[{ filter: 'bogus', sort: 'zzz' }, {}],
[{ filter: 'duplicates' }, {}],
[{ view: 'grid' }, {}],
[{ sort: 'newest' }, {}],
])('maps %j', (query, expected) => {
expect(parseMediaControls(query)).toEqual({ ...DEFAULT_CONTROLS, ...expected })
})
it('takes the first value of a repeated parameter and trims the search', () => {
expect(parseMediaControls({ q: [' wall ', 'x'] }).q).toBe('wall')
})
})
describe('toRouteQuery', () => {
it('drops every default value', () => {
expect(toRouteQuery(DEFAULT_CONTROLS)).toEqual({})
})
it('keeps only the non-default values', () => {
expect(
toRouteQuery({ q: 'wall', filter: 'unused', sort: 'largest', view: 'list' }),
).toEqual({ q: 'wall', filter: 'unused', sort: 'largest', view: 'list' })
})
})
describe('toRequestQuery', () => {
it.each([
[{}, 'filter=all&sort=newest&limit=100'],
[{ filter: 'unused' }, 'filter=unused&sort=newest&limit=100'],
[{ sort: 'largest' }, 'filter=all&sort=largest&limit=100'],
[{ q: 'wall' }, 'q=wall&filter=all&sort=newest&limit=100'],
[{ q: 'a b&c', filter: 'unused' }, 'q=a%20b%26c&filter=unused&sort=newest&limit=100'],
])('builds the request for %j', (overrides, expected) => {
expect(toRequestQuery({ ...DEFAULT_CONTROLS, ...overrides })).toBe(expected)
})
})
+71
View File
@@ -0,0 +1,71 @@
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
const BYTES_PER_KB = 1024
const BYTES_PER_MB = BYTES_PER_KB * 1024
const FILENAME_MAX_LENGTH = 100
const FILENAME_PATTERN = /^[\p{L}\p{N}_-]+$/u
const LETTER_OR_NUMBER = /[\p{L}\p{N}]/u
const VECTOR_DIMENSIONS = 'Vector'
const TYPE_LABELS: Record<string, string> = {
'image/jpeg': 'JPEG',
'image/png': 'PNG',
'image/webp': 'WEBP',
'image/svg+xml': 'SVG',
}
export interface MediaNameSource {
filename: string
size: number
used_in: number
}
export function formatSize(bytes: number) {
if (bytes < BYTES_PER_MB) {
return `${Math.round(bytes / BYTES_PER_KB)} KB`
}
return `${(bytes / BYTES_PER_MB).toFixed(1)} MB`
}
export function formatDimensions(width: number | null, height: number | null) {
if (width === null || height === null) {
return VECTOR_DIMENSIONS
}
return `${width} × ${height}`
}
export function formatType(contentType: string) {
return TYPE_LABELS[contentType] ?? (contentType.split('/')[1] ?? contentType).toUpperCase()
}
export const formatMediaDate = formatAbsoluteDateFromIso
export function pluralArticles(count: number) {
return `${count} ${count === 1 ? 'article' : 'articles'}`
}
export function usedInLabel(count: number) {
return count > 0 ? `used in ${pluralArticles(count)}` : 'unused'
}
export function accessibleName(item: MediaNameSource) {
return `${item.filename}, ${formatSize(item.size)}, ${usedInLabel(item.used_in)}`
}
export function splitFilename(filename: string) {
const dot = filename.lastIndexOf('.')
if (dot <= 0) {
return { stem: filename, extension: '' }
}
return { stem: filename.slice(0, dot), extension: filename.slice(dot) }
}
export function isValidFilenameStem(value: string) {
const stem = value.trim()
return (
stem.length > 0 &&
stem.length <= FILENAME_MAX_LENGTH &&
FILENAME_PATTERN.test(stem) &&
LETTER_OR_NUMBER.test(stem)
)
}
+69
View File
@@ -0,0 +1,69 @@
import type { LocationQuery } from 'vue-router'
export type MediaFilter = 'all' | 'unused'
export type MediaSort = 'newest' | 'oldest' | 'name' | 'largest'
export type MediaView = 'grid' | 'list'
export interface MediaControls {
q: string
filter: MediaFilter
sort: MediaSort
view: MediaView
}
export const MEDIA_PAGE_LIMIT = 100
export const DEFAULT_CONTROLS: MediaControls = {
q: '',
filter: 'all',
sort: 'newest',
view: 'grid',
}
const FILTERS: MediaFilter[] = ['all', 'unused']
const SORTS: MediaSort[] = ['newest', 'oldest', 'name', 'largest']
const VIEWS: MediaView[] = ['grid', 'list']
function firstValue(value: LocationQuery[string] | undefined) {
return (Array.isArray(value) ? value[0] : value) ?? ''
}
function pick<T extends string>(allowed: T[], value: string, fallback: T) {
return (allowed as string[]).includes(value) ? (value as T) : fallback
}
export function parseMediaControls(query: LocationQuery): MediaControls {
return {
q: firstValue(query.q).trim(),
filter: pick(FILTERS, firstValue(query.filter), DEFAULT_CONTROLS.filter),
sort: pick(SORTS, firstValue(query.sort), DEFAULT_CONTROLS.sort),
view: pick(VIEWS, firstValue(query.view), DEFAULT_CONTROLS.view),
}
}
export function toRouteQuery(controls: MediaControls) {
const query: Record<string, string> = {}
if (controls.q) {
query.q = controls.q
}
if (controls.filter !== DEFAULT_CONTROLS.filter) {
query.filter = controls.filter
}
if (controls.sort !== DEFAULT_CONTROLS.sort) {
query.sort = controls.sort
}
if (controls.view !== DEFAULT_CONTROLS.view) {
query.view = controls.view
}
return query
}
export function toRequestQuery(controls: MediaControls) {
const parts = [
...(controls.q ? [`q=${encodeURIComponent(controls.q)}`] : []),
`filter=${controls.filter}`,
`sort=${controls.sort}`,
`limit=${MEDIA_PAGE_LIMIT}`,
]
return parts.join('&')
}
@@ -248,4 +248,38 @@ describe('Nuxt UI shell foundation and router guards', () => {
expect(activeLink.exists()).toBe(true)
},
)
it('The /media route is verified, titled Media and renders MediaView inside DashboardShell', async () => {
const route = router.getRoutes().find((candidate) => candidate.path === '/media')
expect(route?.meta).toEqual({ auth: 'verified', title: 'Media' })
const getMe = vi.fn().mockResolvedValue({ verified: true })
const request = vi.fn().mockResolvedValue({
items: [],
next_cursor: null,
counts: { all: 0, unused: 0, duplicates: 0 },
})
vi.mocked(useApi).mockReturnValue({ getMe, request })
setUpSession()
await router.push('/media')
const app = mountApp()
await flushPromises()
expect(app.find('nav[aria-label="breadcrumb"]').text()).toBe('Media')
expect(app.find('a[href="/media"][aria-current="page"]').exists()).toBe(true)
expect(request).toHaveBeenCalledWith('/v1/media/?filter=all&sort=newest&limit=100')
})
it('An unverified session on /media is sent to /register/pending', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: false })
vi.mocked(useApi).mockReturnValue({ getMe, request: vi.fn() })
setUpSession()
await router.push('/media')
mountApp()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/register/pending')
})
})
+5
View File
@@ -71,6 +71,11 @@ const router = createRouter({
component: () => import('@/views/EditorView.vue'),
meta: { auth: 'verified', title: 'Editor' },
},
{
path: '/media',
component: () => import('@/views/MediaView.vue'),
meta: { auth: 'verified', title: 'Media' },
},
{
path: '/users',
component: () => import('@/views/UsersView.vue'),
+473
View File
@@ -0,0 +1,473 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMediaQuery, useOnline } from '@vueuse/core'
import { useToast } from '@nuxt/ui/composables'
import DashboardShell from '@/components/DashboardShell.vue'
import DeleteMediaDialog from '@/components/DeleteMediaDialog.vue'
import MediaCardList from '@/components/MediaCardList.vue'
import MediaDetailPanel from '@/components/MediaDetailPanel.vue'
import MediaEmptyState from '@/components/MediaEmptyState.vue'
import MediaGrid from '@/components/MediaGrid.vue'
import MediaSkeletonGrid from '@/components/MediaSkeletonGrid.vue'
import MediaTable from '@/components/MediaTable.vue'
import MediaToolbar from '@/components/MediaToolbar.vue'
import RenameMediaDialog from '@/components/RenameMediaDialog.vue'
import {
classifyMediaError,
useMedia,
type MediaItem,
type MediaUsageItem,
} from '@/composables/useMedia'
import {
parseMediaControls,
toRequestQuery,
toRouteQuery,
type MediaControls,
} from '@/lib/mediaQuery'
const DESKTOP_QUERY = '(min-width: 48rem)'
const TOAST_DURATION_MS = 5000
const DESKTOP_SKELETON_CARDS = 10
const MOBILE_SKELETON_CARDS = 6
const DESKTOP_COLUMNS = 5
const MOBILE_COLUMNS = 2
const RENAME_FAILED_COPY =
"Couldn't rename the image. Your text is still here. Try again in a moment."
const DELETE_FAILED_COPY = "Couldn't delete the image, so it's still there. Try again in a moment."
const OFFLINE_COPY =
"You're offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection."
const route = useRoute()
const router = useRouter()
const toast = useToast()
const media = useMedia()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const online = useOnline()
const controls = computed(() => parseMediaControls(route.query))
const requestQuery = computed(() => toRequestQuery(controls.value))
const offline = computed(() => !online.value)
const hasLoaded = ref(false)
const heading = ref<HTMLElement | null>(null)
const activeId = ref<number | null>(null)
const panelOpen = ref(false)
const renameOpen = ref(false)
const deleteOpen = ref(false)
const usage = ref<MediaUsageItem[]>([])
const copies = ref<MediaItem[]>([])
let opener: HTMLElement | null = null
let skipFocusRestore = false
const activeItem = computed(
() => media.items.value.find((item) => item.id === activeId.value) ?? null,
)
const closingItem = ref<MediaItem | null>(null)
const panelItem = computed(() => activeItem.value ?? closingItem.value)
const counts = computed(() => (hasLoaded.value ? media.counts.value : null))
const loadFailed = computed(() => media.status.value === 'error')
const loading = computed(() => media.status.value === 'loading')
const ready = computed(() => media.status.value === 'ready')
const emptyLibrary = computed(
() =>
ready.value &&
media.items.value.length === 0 &&
media.counts.value.all === 0 &&
controls.value.q === '',
)
const noResults = computed(
() => ready.value && media.items.value.length === 0 && !emptyLibrary.value,
)
const showList = computed(() => ready.value && media.items.value.length > 0)
const showFab = computed(
() =>
!isDesktop.value &&
!panelOpen.value &&
!renameOpen.value &&
!deleteOpen.value &&
!loadFailed.value &&
!emptyLibrary.value,
)
const imageCount = computed(() => {
const total = media.items.value.length
return `${total} ${total === 1 ? 'image' : 'images'}`
})
function canonicalizeUrl() {
const canonical = toRouteQuery(controls.value)
const current = Object.fromEntries(
Object.entries(route.query).map(([key, value]) => [
key,
Array.isArray(value) ? (value[0] ?? '') : (value ?? ''),
]),
)
if (JSON.stringify(canonical) !== JSON.stringify(current)) {
router.replace({ query: canonical })
}
}
function updateControls(patch: Partial<MediaControls>) {
router.replace({ query: toRouteQuery({ ...controls.value, ...patch }) })
}
function clearFilters() {
updateControls({ q: '', filter: 'all' })
}
canonicalizeUrl()
watch(activeItem, (item) => {
if (item) {
closingItem.value = item
}
})
watch(
requestQuery,
async () => {
await media.load(controls.value)
hasLoaded.value = true
},
{ immediate: true },
)
function refreshInBackground() {
media.load(controls.value, { silent: true })
}
function focusCard(id: number) {
document.querySelector<HTMLElement>(`[data-media-card][data-media-id="${id}"]`)?.focus()
}
function focusAfterRemoval(index: number) {
nextTick(() => {
setTimeout(() => {
const cards = document.querySelectorAll<HTMLElement>('[data-media-card]')
const target = cards[index]
if (target) {
target.focus()
return
}
heading.value?.focus()
}, 0)
})
}
function restoreFocusToOpener(id: number | null) {
nextTick(() => {
setTimeout(() => {
if (opener?.isConnected) {
opener.focus()
} else if (id !== null) {
focusCard(id)
}
opener = null
}, 0)
})
}
async function loadUsage(item: MediaItem) {
try {
const rows = await media.fetchUsage(item.id)
if (activeId.value === item.id) {
usage.value = rows
}
} catch (error) {
if (activeId.value === item.id && classifyMediaError(error) === 'not-found') {
handleGone(item)
}
}
}
async function loadCopies(item: MediaItem) {
try {
const others = await media.findCopies(item)
if (activeId.value === item.id) {
copies.value = others
}
} catch {
copies.value = []
}
}
function openPanel(id: number, element: HTMLElement) {
const item = media.items.value.find((entry) => entry.id === id)
if (!item) {
return
}
opener = element
activeId.value = id
usage.value = []
copies.value = []
skipFocusRestore = false
panelOpen.value = true
if (item.used_in > 0) {
loadUsage(item)
}
if (item.duplicate) {
loadCopies(item)
}
}
function onPanelOpenChange(isOpen: boolean) {
panelOpen.value = isOpen
if (isOpen) {
return
}
const id = activeId.value
if (!skipFocusRestore) {
restoreFocusToOpener(id)
}
skipFocusRestore = false
}
function closeEverything() {
renameOpen.value = false
deleteOpen.value = false
skipFocusRestore = true
panelOpen.value = false
}
function removeAndRefocus(item: MediaItem) {
const index = media.items.value.findIndex((entry) => entry.id === item.id)
closeEverything()
media.removeLocal(item.id)
focusAfterRemoval(index)
refreshInBackground()
}
function handleGone(item: MediaItem) {
removeAndRefocus(item)
toast.add({
id: `media-not-found-${item.id}`,
color: 'info',
icon: 'i-lucide-info',
title: 'That image no longer exists',
description: 'It was deleted somewhere else, so we removed it from the library.',
})
}
async function copyUrl() {
const item = activeItem.value
if (!item) {
return
}
try {
await media.copyUrl(item)
} catch {
return
}
toast.add({
id: `media-url-copied-${item.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'URL copied',
description: 'Paste it wherever you need the image address.',
duration: TOAST_DURATION_MS,
})
}
async function renameAction(stem: string) {
const item = activeItem.value
if (!item) {
return
}
try {
const renamed = await media.renameItem(item.id, stem)
toast.add({
id: `media-renamed-${item.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image renamed',
description: `It's now "${renamed.filename}". The URL is the same.`,
duration: TOAST_DURATION_MS,
})
} catch (error) {
const failure = classifyMediaError(error)
if (failure === 'handled') {
return
}
if (failure === 'not-found') {
handleGone(item)
return
}
throw new Error(RENAME_FAILED_COPY)
}
}
async function deleteAction() {
const item = activeItem.value
if (!item) {
return
}
const index = media.items.value.findIndex((entry) => entry.id === item.id)
try {
await media.deleteItem(item.id)
} catch (error) {
const failure = classifyMediaError(error)
if (failure === 'handled') {
return
}
if (failure === 'not-found') {
handleGone(item)
return
}
throw new Error(DELETE_FAILED_COPY)
}
closeEverything()
focusAfterRemoval(index)
toast.add({
id: `media-deleted-${item.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image deleted',
description: `"${item.filename}" is gone from your library.`,
duration: TOAST_DURATION_MS,
})
refreshInBackground()
}
</script>
<template>
<DashboardShell
compact-padding
:offline-message="OFFLINE_COPY"
offline-icon="i-lucide-wifi-off"
>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
<h1
ref="heading"
data-testid="media-heading"
tabindex="-1"
class="text-xl font-semibold text-highlighted"
>
Media
</h1>
<p class="text-muted">
Every image you've uploaded, ready to use in any article.
</p>
</div>
<UButton v-if="isDesktop" icon="i-lucide-upload" disabled class="h-8 px-3">
Upload
</UButton>
</div>
<template v-if="emptyLibrary">
<MediaEmptyState variant="first-run" />
<p class="self-center text-xs text-muted">PNG, JPEG, WEBP, SVG · max 10 MB each</p>
</template>
<template v-else>
<UAlert
v-if="loadFailed"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
title="Couldn't load your images"
description="Check your connection and try again."
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@click="media.load(controls)"
>
Try again
</UButton>
</template>
</UAlert>
<MediaToolbar
:controls="controls"
:counts="counts"
@update:q="updateControls({ q: $event })"
@update:filter="updateControls({ filter: $event })"
@update:sort="updateControls({ sort: $event })"
@update:view="updateControls({ view: $event })"
/>
<MediaSkeletonGrid
v-if="loading && media.showSkeleton.value"
:count="isDesktop ? DESKTOP_SKELETON_CARDS : MOBILE_SKELETON_CARDS"
/>
<MediaEmptyState
v-else-if="noResults"
variant="no-results"
:query="controls.q"
:filter-label="controls.filter === 'unused' ? 'Unused' : 'All'"
@action="clearFilters"
/>
<template v-else-if="showList">
<template v-if="controls.view === 'list'">
<MediaTable v-if="isDesktop" :items="media.items.value" @open="openPanel" />
<MediaCardList v-else :items="media.items.value" @open="openPanel" />
</template>
<template v-else>
<div class="flex items-center gap-2.5 text-muted">
<span
aria-hidden="true"
class="-ml-2.75 size-11 shrink-0 md:ml-0 md:size-4.5"
/>
<span aria-hidden="true" class="w-13.75 shrink-0" />
<span data-testid="image-count">{{ imageCount }}</span>
</div>
<MediaGrid
:items="media.items.value"
:columns="isDesktop ? DESKTOP_COLUMNS : MOBILE_COLUMNS"
@open="openPanel"
/>
</template>
</template>
</template>
</div>
<UButton
v-if="showFab"
icon="i-lucide-upload"
disabled
class="fixed inset-x-4 bottom-4 h-12 justify-center text-[0.9375rem] shadow-(--shadow-popover)"
:ui="{ base: 'gap-2', leadingIcon: 'size-4.5' }"
>
Upload
</UButton>
<MediaDetailPanel
:open="panelOpen"
:item="panelItem"
:usage="usage"
:copies="copies"
:offline="offline"
@update:open="onPanelOpenChange"
@copy-url="copyUrl"
@rename="renameOpen = true"
@delete="deleteOpen = true"
/>
<RenameMediaDialog
v-if="renameOpen && activeItem"
:item="activeItem"
:action="renameAction"
@close="renameOpen = false"
/>
<DeleteMediaDialog
v-if="deleteOpen && activeItem"
:item="activeItem"
:usage="usage"
:action="deleteAction"
@close="deleteOpen = false"
/>
</DashboardShell>
</template>
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -279,11 +279,11 @@ describe('Default: the usual first visit with no display name', () => {
expect(request).not.toHaveBeenCalled()
})
it('marks none of the five navigation items as the current page', async () => {
it('marks none of the six navigation items as the current page', async () => {
await mountApp()
const links = document.body.querySelectorAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(5)
expect(links).toHaveLength(6)
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
})
+29 -9
View File
@@ -82,6 +82,12 @@ function parseArgs(argv: string[]) {
return { screen, state: stateWords.join(' ') || undefined, viewports, themes }
}
function tableStates(markdown: string) {
return [...markdown.matchAll(/^\| ([^|]+?) \|/gm)]
.map((match) => match[1]!.trim())
.filter((cell) => cell !== 'State' && !/^-+$/.test(cell))
}
async function readScreen(name: string): Promise<Screen> {
const markdown = await readFile(join(designRoot, 'screens', `${name}.md`), 'utf8')
const reference = /\*\*Visual reference:\*\* `source\/([^`]+)`, group "([^"]+)"/.exec(markdown)
@@ -90,7 +96,10 @@ async function readScreen(name: string): Promise<Screen> {
}
const statesStart = markdown.search(/^## States$/m)
const statesMarkdown = statesStart === -1 ? markdown : markdown.slice(statesStart)
const states = [...statesMarkdown.matchAll(/^### (.+)$/gm)].map((match) => match[1]!.trim())
const headingStates = [...statesMarkdown.matchAll(/^### (.+)$/gm)].map((match) =>
match[1]!.trim(),
)
const states = headingStates.length ? headingStates : tableStates(statesMarkdown)
return { name, board: reference[1]!, group: reference[2]!, states }
}
@@ -296,14 +305,14 @@ async function captureDesign(
await frame.screenshot({ path, animations: 'disabled' })
}
function fulfilment(mock: ApiMock) {
const text = typeof mock.body === 'string'
function fulfilment(mock: ApiMock, url: URL, call: number) {
const body = mock.bodyFor ? mock.bodyFor(url, call) : mock.body
const text = typeof body === 'string'
return {
status: mock.status,
headers: mock.headers,
contentType: text ? 'text/plain; charset=utf-8' : 'application/json',
body:
mock.body === undefined ? '' : text ? (mock.body as string) : JSON.stringify(mock.body),
body: body === undefined ? '' : text ? (body as string) : JSON.stringify(body),
}
}
@@ -324,9 +333,13 @@ async function captureApp(
})
await serveIconsLocally(context)
const unmocked = new Set<string>()
const calls = new Map<string, number>()
await context.route('**/v1/**', async (route) => {
const request = route.request()
const key = `${request.method()} ${new URL(request.url()).pathname}`
const url = new URL(request.url())
const key = `${request.method()} ${url.pathname}`
const call = (calls.get(key) ?? 0) + 1
calls.set(key, call)
const mock = setup.api?.[key]
if (!mock) {
unmocked.add(key)
@@ -336,7 +349,7 @@ async function captureApp(
if (mock.hang) {
return
}
await route.fulfill(fulfilment(mock))
await route.fulfill(fulfilment(mock, url, call))
})
if (setup.origin) {
await serveDistLocally(context, setup.origin)
@@ -440,6 +453,7 @@ async function main() {
const lines: string[] = []
let failures = 0
let skipped = 0
let pending = 0
try {
for (const viewport of args.viewports) {
@@ -447,6 +461,11 @@ async function main() {
const board = await openBoard(browser, design.origin, screen, theme)
for (const state of states) {
const skip = setups[state]?.skip
if (skip === 'pending') {
pending++
lines.push(`PENDING ${state} · ${viewport} · ${theme}: slice B2`)
continue
}
if (skip) {
skipped++
lines.push(`SKIP ${state} · ${viewport} · ${theme}: ${skip}`)
@@ -508,8 +527,9 @@ async function main() {
design.close()
}
const checked = lines.length - skipped
const skippedNote = skipped ? `, ${skipped} skipped` : ''
const checked = lines.length - skipped - pending
const skippedNote =
(skipped ? `, ${skipped} skipped` : '') + (pending ? `, ${pending} pending` : '')
const verdict = failures
? `FAIL: ${failures} of ${checked} captures failed (budget ${maxDiffPercent}% per capture${skippedNote})`
: `PASS: all ${checked} captures within budget (${maxDiffPercent}% unless an approved exception is listed${skippedNote})`
+430 -1
View File
@@ -3,6 +3,7 @@ import type { Page } from 'playwright'
export interface ApiMock {
status: number
body?: unknown
bodyFor?: (url: URL, call: number) => unknown
headers?: Record<string, string>
hang?: boolean
}
@@ -15,7 +16,7 @@ export interface DiffException {
export interface VisualState {
exception?: DiffException
skip?: 'clock-sensitive'
skip?: 'clock-sensitive' | 'pending'
path: string
origin?: string
now?: string
@@ -805,6 +806,433 @@ const usersWithDisplayNames = usersFixture.map((item) =>
: item,
)
interface MediaSample {
id: number
filename: string
size: number
width: number | null
height: number | null
contentType: string
usedIn: number
uploadedBy: string
createdAt: string
duplicate: boolean
tone: number
}
const mediaSamples: MediaSample[] = [
{
id: 1,
filename: 'north-wall.jpg',
size: 4089446,
width: 4032,
height: 3024,
contentType: 'image/jpeg',
usedIn: 2,
uploadedBy: 'Marta Alves',
createdAt: '2026-09-12T12:00:00Z',
duplicate: false,
tone: 0,
},
{
id: 2,
filename: 'kitchen-after.webp',
size: 1258291,
width: 2400,
height: 1600,
contentType: 'image/webp',
usedIn: 3,
uploadedBy: 'Inès Duarte',
createdAt: '2026-09-08T12:00:00Z',
duplicate: false,
tone: 1,
},
{
id: 3,
filename: 'kitchen-before.webp',
size: 1153434,
width: 2400,
height: 1600,
contentType: 'image/webp',
usedIn: 1,
uploadedBy: 'Inès Duarte',
createdAt: '2026-09-08T11:00:00Z',
duplicate: false,
tone: 2,
},
{
id: 4,
filename: 'logo-renew.svg',
size: 8192,
width: null,
height: null,
contentType: 'image/svg+xml',
usedIn: 5,
uploadedBy: 'jonas',
createdAt: '2026-09-05T12:00:00Z',
duplicate: false,
tone: 3,
},
{
id: 5,
filename: 'floorboards-copy.jpg',
size: 2516582,
width: 3600,
height: 2400,
contentType: 'image/jpeg',
usedIn: 3,
uploadedBy: 'Marta Alves',
createdAt: '2026-09-02T12:00:00Z',
duplicate: true,
tone: 0,
},
{
id: 6,
filename: 'IMG_2208.jpg',
size: 2516582,
width: 3600,
height: 2400,
contentType: 'image/jpeg',
usedIn: 2,
uploadedBy: 'jonas',
createdAt: '2026-08-30T12:00:00Z',
duplicate: true,
tone: 0,
},
{
id: 7,
filename: 'IMG_4471.jpg',
size: 3250585,
width: 3600,
height: 2400,
contentType: 'image/jpeg',
usedIn: 2,
uploadedBy: 'Marta Alves',
createdAt: '2026-08-29T12:00:00Z',
duplicate: true,
tone: 2,
},
{
id: 8,
filename: 'window-frame.jpg',
size: 1887437,
width: 3000,
height: 2000,
contentType: 'image/jpeg',
usedIn: 0,
uploadedBy: 'Inès Duarte',
createdAt: '2026-08-28T12:00:00Z',
duplicate: false,
tone: 3,
},
{
id: 9,
filename: 'stone-wall-detail.jpg',
size: 3250585,
width: 3600,
height: 2400,
contentType: 'image/jpeg',
usedIn: 1,
uploadedBy: 'jonas',
createdAt: '2026-08-21T12:00:00Z',
duplicate: true,
tone: 2,
},
{
id: 10,
filename: 'bathroom-tiles.png',
size: 880640,
width: 1800,
height: 1200,
contentType: 'image/png',
usedIn: 0,
uploadedBy: 'Marta Alves',
createdAt: '2026-08-04T12:00:00Z',
duplicate: false,
tone: 1,
},
{
id: 11,
filename: 'lime-plaster.jpg',
size: 2202009,
width: 3200,
height: 2133,
contentType: 'image/jpeg',
usedIn: 2,
uploadedBy: 'Inès Duarte',
createdAt: '2026-07-19T12:00:00Z',
duplicate: false,
tone: 1,
},
{
id: 12,
filename: 'floorboards.jpg',
size: 2516582,
width: 3600,
height: 2400,
contentType: 'image/jpeg',
usedIn: 4,
uploadedBy: 'Inès Duarte',
createdAt: '2026-06-14T12:00:00Z',
duplicate: true,
tone: 0,
},
]
const mediaOrigin = 'https://cms.renew.example'
const mediaSession: ApiMock = {
status: 200,
body: { ...(verifiedSession.body as object), display_name: 'Marta Alves' },
}
const mediaFullCounts = { all: 12, unused: 2, duplicates: 2 }
const mediaNorthWallUsage = [
{ id: 10, title: 'How we price a bathroom renovation', published: true },
{ id: 11, title: 'Winter checklist for timber windows', published: false },
]
const mediaFloorboardsUsage = [
{ id: 10, title: 'How we price a bathroom renovation', published: true },
{ id: 11, title: 'Winter checklist for timber windows', published: false },
{ id: 12, title: 'Before and after: the Rue Verte kitchen', published: true },
{ id: 13, title: 'Why we reuse original floorboards', published: true },
]
const mediaDuplicateIds = [12, 5, 6]
function mediaItem(sample: MediaSample, filename = sample.filename) {
return {
id: sample.id,
filename,
url: `/media/${filename}`,
size: sample.size,
content_type: sample.contentType,
width: sample.width,
height: sample.height,
sha256: `${sample.tone}${String(sample.id).padStart(2, '0').repeat(31)}a`,
uploaded_by: sample.uploadedBy,
created_at: sample.createdAt,
used_in: sample.usedIn,
duplicate: sample.duplicate,
}
}
function mediaItems(ids?: number[], without: number[] = []) {
return mediaSamples
.filter((sample) => (!ids || ids.includes(sample.id)) && !without.includes(sample.id))
.map((sample) => mediaItem(sample))
}
function mediaList(items: unknown[], counts = mediaFullCounts) {
return { items, next_cursor: null, counts }
}
function mediaApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return {
'GET /v1/auth/me': mediaSession,
'GET /v1/media/': {
status: 200,
bodyFor: (url) =>
url.searchParams.has('hash')
? mediaList(mediaItems(mediaDuplicateIds))
: mediaList(mediaItems()),
},
'GET /v1/media/1/usage': { status: 200, body: mediaNorthWallUsage },
'GET /v1/media/12/usage': { status: 200, body: mediaFloorboardsUsage },
...overrides,
}
}
function mediaApiAfterDelete(id: number, overrides: Record<string, ApiMock> = {}) {
return mediaApi({
'GET /v1/media/': {
status: 200,
bodyFor: (_url, call) =>
call === 1
? mediaList(mediaItems())
: mediaList(mediaItems(undefined, [id]), { all: 11, unused: 1, duplicates: 2 }),
},
...overrides,
})
}
function mediaState(
api: Record<string, ApiMock>,
steps?: (page: Page) => Promise<void>,
path = '/media',
): VisualState {
return { path, origin: mediaOrigin, api, steps }
}
const mediaPending: VisualState = { path: '/media', skip: 'pending' }
function mediaCard(page: Page, id: number) {
return page.locator(`[data-media-card][data-media-id="${id}"]`)
}
async function openMediaPanel(page: Page, id: number) {
await mediaCard(page, id).dispatchEvent('click')
await page.getByRole('dialog').waitFor()
await page.mouse.move(0, 0)
}
async function openMediaDialog(page: Page, id: number, action: 'Rename' | 'Delete') {
await openMediaPanel(page, id)
await page.getByRole('dialog').getByRole('button', { name: action, exact: true }).click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
function mediaRenameWith(value: string, submit: boolean) {
return async (page: Page) => {
await openMediaDialog(page, 1, 'Rename')
const field = page.getByRole('alertdialog').locator('input')
await field.fill(value)
if (submit) {
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Rename', exact: true })
.click()
} else {
await field.focus()
}
await page.mouse.move(0, 0)
}
}
function mediaConfirmDelete(id: number) {
return async (page: Page) => {
await openMediaDialog(page, id, 'Delete')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete image', exact: true })
.click()
await page.mouse.move(0, 0)
}
}
async function goOffline(page: Page) {
await page.evaluate(() => {
Object.defineProperty(navigator, 'onLine', { get: () => false, configurable: true })
window.dispatchEvent(new Event('offline'))
})
await page.getByText("You're offline.").first().waitFor()
}
const mediaStates: Record<string, VisualState> = {
'Grid, many images': mediaState(mediaApi()),
'Grid, few images': mediaState(
mediaApi({
'GET /v1/media/': {
status: 200,
body: mediaList(mediaItems([1, 4, 8]), { all: 3, unused: 1, duplicates: 0 }),
},
}),
),
'List view': mediaState(mediaApi(), undefined, '/media?view=list'),
Loading: mediaState(mediaApi({ 'GET /v1/media/': { status: 200, hang: true } })),
'Empty library, first run': mediaState(
mediaApi({
'GET /v1/media/': {
status: 200,
body: mediaList([], { all: 0, unused: 0, duplicates: 0 }),
},
}),
),
'No results': mediaState(
mediaApi({ 'GET /v1/media/': { status: 200, body: mediaList([]) } }),
undefined,
'/media?q=wall&filter=unused',
),
'Hover, focus and keyboard': mediaState(mediaApi(), async (page) => {
await mediaCard(page, 3).focus()
await mediaCard(page, 2).hover()
}),
'Selection and bulk bar': mediaPending,
'Bulk delete: confirm': mediaPending,
'Bulk delete: in progress': mediaPending,
'Bulk delete: partly failed': mediaPending,
'Bulk delete: done': mediaPending,
'Detail panel: in use': mediaState(mediaApi(), (page) => openMediaPanel(page, 1)),
'Detail panel: unused': mediaState(mediaApi(), (page) => openMediaPanel(page, 8)),
'Detail panel: duplicate': mediaState(mediaApi(), (page) => openMediaPanel(page, 12)),
'Copy URL': mediaState(mediaApi(), async (page) => {
await openMediaPanel(page, 1)
await page
.getByRole('dialog')
.getByRole('button', { name: 'Copy URL', exact: true })
.click()
await page.getByText('URL copied', { exact: true }).first().waitFor()
await page.mouse.move(0, 0)
}),
'Rename: dialog': mediaState(mediaApi(), mediaRenameWith('north-wall', false)),
'Rename: saving': mediaState(
mediaApi({ 'PATCH /v1/media/1': { status: 200, hang: true } }),
mediaRenameWith('north-wall-01', true),
),
'Rename: validation': mediaState(mediaApi(), async (page) => {
await mediaRenameWith('!!!', false)(page)
await page.getByRole('alertdialog').locator('input').blur()
await page.mouse.move(0, 0)
}),
'Rename: failed': mediaState(
mediaApi({ 'PATCH /v1/media/1': { status: 500 } }),
mediaRenameWith('north-wall-01', true),
),
'Rename: done': mediaState(
mediaApi({
'PATCH /v1/media/1': {
status: 200,
body: mediaItem(mediaSamples[0]!, 'north-wall-01.jpg'),
},
}),
async (page) => {
await mediaRenameWith('north-wall-01', true)(page)
await page.getByText('Image renamed', { exact: true }).first().waitFor()
await page.mouse.move(0, 0)
},
),
'Delete: unused image': mediaState(mediaApi(), (page) => openMediaDialog(page, 8, 'Delete')),
'Delete: image in use': mediaState(mediaApi(), (page) => openMediaDialog(page, 1, 'Delete')),
'Delete: in progress': mediaState(
mediaApi({ 'DELETE /v1/media/1': { status: 204, hang: true } }),
mediaConfirmDelete(1),
),
'Delete: failed': mediaState(
mediaApi({ 'DELETE /v1/media/8': { status: 500 } }),
mediaConfirmDelete(8),
),
'Delete: done': mediaState(
mediaApiAfterDelete(8, { 'DELETE /v1/media/8': { status: 204 } }),
async (page) => {
await mediaConfirmDelete(8)(page)
await page.getByText('Image deleted', { exact: true }).first().waitFor()
await scrollToTop(page)
await page.mouse.move(0, 0)
},
),
'Image already gone (404)': mediaState(
mediaApiAfterDelete(8, { 'DELETE /v1/media/8': { status: 404 } }),
async (page) => {
await mediaConfirmDelete(8)(page)
await page.getByText('That image no longer exists', { exact: true }).first().waitFor()
await scrollToTop(page)
await page.mouse.move(0, 0)
},
),
'Upload: drag over the page': mediaPending,
'Upload: several files, mixed': mediaPending,
'Upload: summary': mediaPending,
'Upload: rejected files (413, 400)': mediaPending,
'Upload: too many requests (429)': mediaPending,
'Duplicates: groups': mediaPending,
'Duplicates: empty': mediaPending,
'Merge: confirm': mediaPending,
'Merge: in progress': mediaPending,
'Merge: failed': mediaPending,
'Merge: conflict (409)': mediaPending,
'Merge: done': mediaPending,
'List failed to load': mediaState(mediaApi({ 'GET /v1/media/': { status: 500 } })),
Offline: mediaState(mediaApi(), goOffline),
}
export const visualStates: Record<string, Record<string, VisualState>> = {
profile: {
'Account menu': {
@@ -1735,4 +2163,5 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
steps: submitAcceptInvite('lea', 'limewash2026'),
},
},
media: mediaStates,
}