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