feat: media page T1

This commit is contained in:
fsylvain committed 2026-10-01 01:36:35 +02:00
1 parent 67d456baf6
commit 008b0dfbb9
46 files changed
+5722 -184

No files matched your search

+5 -4
View File
@@ -93,9 +93,10 @@ class Component extends DCLogic {
I('chevron-right', 16, 'margin-left:8px'))
}))
const tabs = [['all', 'All', '12'], ['unused', 'Unused', '2'], ['dups', 'Duplicates', '2']]
const dg = s.dupEmpty ? 0 : s.dupGroups ?? 2
const tabs = [['all', 'All', String(imgs.length)], ['unused', 'Unused', String(imgs.filter(x => !x.used).length)], ['dups', 'Duplicates', String(dg)]]
const tab = s.tab || 'all'
const TabRow = () => D('display:flex;gap:20px;flex:1;' + (m ? 'border-bottom:1px solid var(--ui-border)' : ''), ...tabs.map(([k, t, n]) => S('height:' + (m ? 44 : 36) + 'px;display:flex;align-items:center;gap:6px;border-bottom:2px solid ' + (tab === k ? 'var(--ui-primary)' : 'transparent') + ';margin-bottom:-1px;font-weight:' + (tab === k ? 500 : 400) + ';color:' + (tab === k ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)') + ';font-size:' + (m ? 15 : 13) + 'px', t, k === 'dups' ? S('height:18px;min-width:18px;padding:0 5px;display:inline-flex;align-items:center;justify-content:center;background:var(--rc-info-soft);color:var(--ui-info);font:500 11px \'IBM Plex Mono\',monospace', n) : S('font:500 11px \'IBM Plex Mono\',monospace;color:var(--ui-text-muted)', n))))
const TabRow = () => D('display:flex;gap:20px;flex:1;' + (m ? 'border-bottom:1px solid var(--ui-border)' : ''), ...tabs.map(([k, t, n]) => S('height:' + (m ? 44 : 36) + 'px;display:flex;align-items:center;gap:6px;border-bottom:2px solid ' + (tab === k ? 'var(--ui-primary)' : 'transparent') + ';margin-bottom:-1px;font-weight:' + (tab === k ? 500 : 400) + ';color:' + (tab === k ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)') + ';font-size:' + (m ? 15 : 13) + 'px', t, k === 'dups' ? (dg > 0 && S('height:18px;min-width:18px;padding:0 5px;display:inline-flex;align-items:center;justify-content:center;background:var(--rc-info-soft);color:var(--ui-info);font:500 11px \'IBM Plex Mono\',monospace', n)) : S('font:500 11px \'IBM Plex Mono\',monospace;color:var(--ui-text-muted)', n))))
const Search = () => D('height:' + (m ? 44 : 32) + 'px;' + (m ? '' : 'width:240px;margin-bottom:0;') + 'padding:0 4px 0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:' + (s.q ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + (m ? ';font-size:16px' : ''), I('search', 14), S('flex:1', s.q || 'Search by filename'), s.q && S('width:' + (m ? 36 : 24) + 'px;height:' + (m ? 36 : 24) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x', 14)))
const Sort = () => D('height:' + (m ? 44 : 32) + 'px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);' + (m ? 'flex:1;' : 'width:128px;'), S('flex:1', s.sort || 'Newest'), I('chevron-down', 14, 'color:var(--ui-text-muted)'))
const view = s.view || 'grid'
@@ -122,7 +123,7 @@ class Component extends DCLogic {
{ n: 'floorboards.jpg', sub: '3 identical images · 2.4 MB each · 3600 × 2400', c: [12, 5, 6], keep: 12 },
{ n: 'IMG_4471.jpg', sub: '2 identical images · 3.1 MB each · 3600 × 2400', c: [7, 9], keep: 7 }
]
return D('display:flex;flex-direction:column;gap:16px', ...G.map((g, gi) => D('background:var(--ui-bg);border:1px solid var(--ui-border)', D('padding:12px 14px;border-bottom:1px solid var(--ui-border);display:flex;flex-direction:column;gap:2px', S('font-weight:600;color:var(--ui-text-highlighted)', 'Group ' + (gi + 1)), S('color:var(--ui-text-muted)', g.sub)),
return D('display:flex;flex-direction:column;gap:16px', ...G.slice(2 - dg).map((g, gi) => D('background:var(--ui-bg);border:1px solid var(--ui-border)', D('padding:12px 14px;border-bottom:1px solid var(--ui-border);display:flex;flex-direction:column;gap:2px', S('font-weight:600;color:var(--ui-text-highlighted)', 'Group ' + (gi + 1)), S('color:var(--ui-text-muted)', g.sub)),
D('display:grid;grid-template-columns:' + (m ? 'minmax(0,1fr)' : 'repeat(' + g.c.length + ',minmax(0,1fr))') + ';gap:' + (m ? 0 : 16) + 'px;padding:' + (m ? 0 : 16) + 'px', ...g.c.map(id => {
const im = byId(id), k = id === g.keep
const others = g.c.filter(x => x !== id).length
@@ -186,7 +187,7 @@ class Component extends DCLogic {
if (k === 'rename') return { title: 'Rename image', desc: 'Only the filename changes. The URL stays the same, so articles that use this image keep working.', body: [Field('Filename', md.err ? '' : md.val || 'north-wall', { val: !md.err, suffix: '.jpg', focus: !md.loading, err: md.err, dis: md.loading, help: md.err ? '' : 'Letters, numbers, dashes and underscores. The .jpg ending stays.' })], cancel: 'Cancel', confirm: md.loading ? 'Renaming…' : 'Rename' }
if (k === 'del') return { title: 'Delete this image?', desc: '"window-frame.jpg" will be deleted from your library. No article uses it. This can\'t be undone.', cancel: 'Cancel', confirm: md.loading ? 'Deleting…' : 'Delete image', tone: 'danger' }
if (k === 'delUsed') return { title: 'Delete an image that\'s in use?', desc: '"north-wall.jpg" is used in 2 articles. If you delete it, they will show a broken image. This can\'t be undone.', body: [ArtBox(usedList(2))], cancel: 'Cancel', confirm: md.loading ? 'Deleting…' : 'Delete image', tone: 'danger' }
if (k === 'bulk') return { title: 'Delete 3 images?', desc: '2 of them are used in 4 articles, which will show a broken image. The other one isn\'t used anywhere. This can\'t be undone.', body: [D('display:flex;flex-direction:column;border:1px solid var(--ui-border)', ...[['north-wall.jpg', 'Used in 2'], ['kitchen-after.webp', 'Used in 2 of the 3'], ['window-frame.jpg', 'Unused']].map(([n, u], i) => D('display:flex;align-items:center;gap:8px;min-height:36px;padding:0 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), S('flex:1;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', n), Bd(u === 'Unused' ? 'Unused' : u.replace(' of the 3', ''), u === 'Unused' ? 'unused' : 'used'))))], cancel: 'Cancel', confirm: md.loading ? 'Deleting…' : 'Delete 3 images', tone: 'danger' }
if (k === 'bulk') return { title: 'Delete 3 images?', desc: '2 of them are used in 5 articles, which will show a broken image. The other one isn\'t used anywhere. This can\'t be undone.', body: [D('display:flex;flex-direction:column;border:1px solid var(--ui-border)', ...[['north-wall.jpg', 'Used in 2'], ['kitchen-after.webp', 'Used in 3'], ['window-frame.jpg', 'Unused']].map(([n, u], i) => D('display:flex;align-items:center;gap:8px;min-height:36px;padding:0 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), S('flex:1;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', n), Bd(u === 'Unused' ? 'Unused' : u, u === 'Unused' ? 'unused' : 'used'))))], cancel: 'Cancel', confirm: md.loading ? 'Deleting…' : 'Delete 3 images', tone: 'danger' }
if (k === 'merge') return { title: 'Merge into floorboards.jpg?', desc: 'Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 articles will be updated. This can\'t be undone.', body: [D('display:flex;gap:12px;align-items:center;border:1px solid var(--ui-border);padding:10px 12px', Th(byId(12), 'width:56px;height:42px;flex-shrink:0;border:1px solid var(--ui-border)', 16), D('display:flex;flex-direction:column;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', 'floorboards.jpg'), S('font-size:12px;color:var(--ui-text-muted)', 'Kept · used in 4 articles now')))], cancel: 'Cancel', confirm: md.loading ? 'Merging…' : 'Merge 2 copies', tone: 'danger' }
if (k === 'remove') return { title: 'Remove this image from the article?', desc: 'It\'s removed from this article only. The file stays in your library and in any other article that uses it.', body: [D('display:flex;gap:12px;align-items:center;border:1px solid var(--ui-border);padding:10px 12px', Th(byId(1), 'width:56px;height:42px;flex-shrink:0;border:1px solid var(--ui-border)', 16), D('display:flex;flex-direction:column;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', 'north-wall.jpg'), S('font-size:12px;color:var(--ui-text-muted)', 'Stays in the library · 3.9 MB')))], cancel: 'Cancel', confirm: 'Remove image' }
}
@@ -186,8 +186,8 @@ class Component extends DCLogic {
Mp({ sel: [1, 2, 8], name: 'Selection and bulk bar', trigger: 'Checkbox on any item, or Space. Selected items take the sand fill with a primary outline, and Select all shows a partial state. The bar is sticky at the bottom (above the bottom edge on mobile, replacing the Upload button). Announced: "3 selected". Escape or Clear empties it.' }),
Mp({ sel: [1, 2, 8], modal: { k: 'bulk' }, name: 'Bulk delete: confirm', trigger: 'Delete in the bulk bar. The dialog says how many of the selection are in use and lists them. The confirm button carries the count.' }),
Mp({ sel: [1, 2, 8], modal: { k: 'bulk', loading: true }, name: 'Bulk delete: in progress', trigger: 'Requests in flight. The dialog can\'t be dismissed.' }),
Mp({ ids: [2, 3, 4, 5, 6, 7, 9, 10, 11, 12], toasts: [TT('error', 'circle-x', 'Couldn\'t delete 1 image', '2 images were deleted. window-frame.jpg is still in your library. Try again.', 'Retry')], name: 'Bulk delete: partly failed', trigger: 'Some requests failed. Deleted images leave the grid, failed ones stay selected, and the toast stays until retried or dismissed.' }),
Mp({ ids: [2, 3, 4, 5, 6, 7, 9, 10, 11, 12], toasts: [TT('success', 'circle-check', '3 images deleted', 'They\'re gone from your library.')], name: 'Bulk delete: done', trigger: '204 for all. Focus moves to the next item.' }),
Mp({ ids: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12], sel: [8], toasts: [TT('error', 'circle-x', 'Couldn\'t delete 1 image', '2 images were deleted. window-frame.jpg is still in your library. Try again.', 'Retry')], name: 'Bulk delete: partly failed', trigger: 'Some requests failed. Deleted images leave the grid, failed ones stay selected, and the toast stays until retried or dismissed.' }),
Mp({ ids: [3, 4, 5, 6, 7, 9, 10, 11, 12], toasts: [TT('success', 'circle-check', '3 images deleted', 'They\'re gone from your library.')], name: 'Bulk delete: done', trigger: '204 for all. Focus moves to the next item.' }),
Mp({ panel: { id: 1 }, name: 'Detail panel: in use', trigger: 'Click or Enter on an item. Right-hand slideover on desktop, full screen on mobile with a back arrow. Actions: Copy URL, Rename, Delete. "Used in 2 articles" lists title, Online or Draft, and links to the editor. Focus returns to the item on close.' }),
Mp({ panel: { id: 8 }, name: 'Detail panel: unused', trigger: 'Used in nothing. The section reads "Not used in any article" and says the image is safe to delete.' }),
Mp({ panel: { id: 12 }, name: 'Detail panel: duplicate', trigger: 'The image has identical copies. An info alert links to the duplicates view.' }),
@@ -214,7 +214,7 @@ class Component extends DCLogic {
Mp({ tab: 'dups', modal: { k: 'merge', loading: true }, name: 'Merge: in progress', trigger: 'Request in flight. The dialog can\'t be dismissed.' }),
Mp({ tab: 'dups', modal: { k: 'merge', notice: 'Couldn\'t merge the copies. Nothing was changed. Try again in a moment.' }, name: 'Merge: failed', trigger: '500 or network failure. Nothing is switched or deleted.' }),
Mp({ tab: 'dups', modal: { k: 'merge', notice: 'One of these images changed, so nothing was merged. The list is up to date now. Check it and try again.' }, name: 'Merge: conflict (409)', trigger: 'A copy was deleted, or a new copy appeared, since the page loaded.' }),
Mp({ tab: 'dups', dupEmpty: true, toasts: [TT('success', 'circle-check', 'Copies merged', '2 copies merged. 5 articles updated.')], name: 'Merge: done', trigger: '200: the group leaves the list. When it was the last one, "No duplicates" shows.' }),
Mp({ tab: 'dups', dupGroups: 1, ids: [1, 2, 3, 4, 7, 8, 9, 10, 11, 12], toasts: [TT('success', 'circle-check', 'Copies merged', '2 copies merged. 5 articles updated.')], name: 'Merge: done', trigger: '200: the group leaves the list. When it was the last one, "No duplicates" shows.' }),
Mp({ error: true, name: 'List failed to load', trigger: 'A 500 or network failure on the list request. The alert replaces the images; Try again repeats the request.' }),
Mp({ offline: true, name: 'Offline', trigger: 'navigator.onLine is false. Loaded images stay browsable. Upload, Rename, Delete and Merge are disabled, and the banner says why.' })
], tableTitle: 'Status to copy', table: [
+12 -25
View File
@@ -14,11 +14,9 @@ Feature: Media page, part 1: browse and manage
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 upload, multi-select, the bulk bar, the Duplicates tab content, duplicate merge,
drag-and-drop and the rate-limited state are specified in
features/media_upload_selection_duplicates.feature
And useMedia is a composable over useApi. It exposes the list state (items, counts, loading,
error), the request functions for list, usage, rename and delete, and the copy helper, and
it maps ApiError and NetworkError to the states below
@@ -30,9 +28,8 @@ Feature: Media page, part 1: browse and manage
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
use in any article." and a primary "Upload" button (icon upload) on the right; on mobile it is
the full-width 48 px button pinned to the bottom
And the toolbar holds the tabs "All", "Unused" and "Duplicates" with a count each, then the
search field (placeholder "Search by filename"), the sort menu (options "Newest", "Oldest",
"Name", "Largest", sent as sort=newest, oldest, name, largest) and the grid/list toggle
@@ -40,10 +37,7 @@ Feature: Media page, part 1: browse and manage
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 Duplicates tab selects the groups view and ?filter=duplicates is a valid value
And the grid is 5 columns on desktop and 2 on mobile, with a 16 px gap (10 px on mobile),
and each card is one button holding MediaThumb (4:3, a checkerboard behind PNG and SVG),
the filename (medium weight, ellipsis), "size · dimensions" and the MediaBadges; an SVG's
@@ -65,9 +59,8 @@ Feature: Media page, part 1: browse and manage
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
the "Select all" row above the grid are specified in
features/media_upload_selection_duplicates.feature
And the detail panel is a USlideover: 460 px on the right on desktop (a close button x),
full screen on mobile with an arrow-left back button; its title is the filename; the body
holds the 16:10 preview (4:3 on mobile), the actions "Copy URL" (icon link), "Rename"
@@ -107,7 +100,7 @@ Feature: Media page, part 1: browse and manage
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 "Select all" row above the grid reads "Select all" and "12 images"
And the toolbar shows "Search by filename", the sort "Newest" and the grid toggle selected
Scenario Outline: Each card's badges and accessible name follow the image
@@ -175,11 +168,6 @@ Feature: Media page, part 1: browse and manage
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
@@ -195,7 +183,7 @@ Feature: Media page, part 1: browse and manage
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"
And one panel shows the images icon, title "No images yet", text "Upload your first image. You can use it in any article.", a primary "Upload an image" button, and under it "PNG, JPEG, WEBP, SVG · max 10 MB each"
Scenario: No results, search and filter
Given the Default state has loaded
@@ -216,8 +204,7 @@ Feature: Media page, part 1: browse and manage
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
Then the card's selection toggles and no panel opens
Scenario: Detail panel, in use
Given the Default state has loaded and GET /v1/media/1/usage resolves 200 with [{ id: 10, title: "How we price a bathroom renovation", published: true }, { id: 11, title: "Winter checklist for timber windows", published: false }]
@@ -239,7 +226,7 @@ Feature: Media page, part 1: browse and manage
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
And the action is enabled and selects the Duplicates tab
Scenario: The Used in count in list view opens the panel
Given the Default state has loaded with ?view=list at md width or more
+13 -22
View File
@@ -6,7 +6,7 @@ SPA only. The media API already exists (slice A), so there is no HTTP-level Go t
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.
specified in features/media_upload_selection_duplicates.feature and its .qa.md.
Toast ids: "media-url-copied-{id}", "media-renamed-{id}", "media-deleted-{id}", "media-not-found-{id}".
@@ -29,26 +29,26 @@ Logic and copy:
"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;
request query and control state; unknown values 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.
Duplicates badge only when duplicates > 0, "Select all" and "12 images" in the Select all row,
breadcrumb "Media".
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.
7. **Duplicates tab** - covered by the B2 spec.
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".
"Upload an image" enabled, 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
11. **Keyboard** - each card is a button; Enter opens the panel; Space toggles selection and 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
@@ -57,7 +57,7 @@ Logic and copy:
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.
the "Review duplicates" action is enabled and selects the Duplicates tab.
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,
@@ -87,8 +87,7 @@ Logic and 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).
30. **Header** - title, subtitle, and the Upload button (also the mobile pinned 48 px variant).
## Visual (`task visual:check -- media`, desktop and mobile, light and dark)
@@ -105,10 +104,7 @@ icon, the RenewCMS mark on the SVG, a checkerboard behind PNG and SVG), not phot
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.
Harness note: the B2 states now have setups and no longer carry `skip: 'pending'`.
Setups for this slice (name in screens/media.md, then what must be drawn):
@@ -118,9 +114,8 @@ Setups for this slice (name in screens/media.md, then what must be drawn):
- [ ] 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).
- [ ] Hover, focus and keyboard: hover on id 2, focus ring on id 3 (the checkbox reveal is covered by B2).
- [ ] Detail panel: in use (id 1), unused (id 8), duplicate (id 12).
- [ ] 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
@@ -129,11 +124,7 @@ Setups for this slice (name in screens/media.md, then what must be drawn):
- [ ] 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
The B2 states are listed in features/media_upload_selection_duplicates.qa.md.
Manual checks screenshots cannot cover:
@@ -0,0 +1,452 @@
Feature: Media page, part 2: upload, selection and duplicates
As a verified RenewCMS owner
I want to upload images to the library from /media, select several images to delete them
together, and merge identical copies
So that I can keep the library complete and free of duplicates without leaving the page
Background:
Given /media is the verified route from slice B1, rendered as MediaPage inside
DashboardShell, per docs/design/routes.md and docs/design/screens/media.md
And this slice is SPA only: the API from slice A is used unchanged, and no Go code, use case
or repository changes
And this slice supersedes these B1 statements: the Upload button, the mobile pinned button and
"Upload an image" are enabled; the Duplicates tab is enabled and ?filter=duplicates is a
valid value; "Review duplicates" is enabled; Space on a card toggles selection; the checkbox,
the "Select all" text and the partial state exist; the B2 states no longer carry
skip 'pending' in web/visual/states.ts
And the calls used are POST /v1/media/ (multipart field "file", sent with XMLHttpRequest so
that upload progress is reported, with the jwt cookie), GET /v1/media/?hash=<sha256 hex>,
DELETE /v1/media/{id}, POST /v1/media/merge { keep_id, merge_ids } and the list call from
B1 with filter=duplicates; every other /v1 call goes through useApi
And 401 and 403 on any of these calls, including the XHR upload, send the user to
/login?redirect=/media&reason=expired and /register/pending as useApi does, with no local
copy
And the browser-side checks reuse validateImageFile from web/src/lib/imageUpload.ts: size is
checked first (10 MB = 10485760 bytes is allowed, one byte more is too large), then the
file's MIME type and its extension must both be allowed (PNG, JPEG, WEBP, SVG; .png, .jpg,
.jpeg, .webp, .svg); a rejected file is never hashed and never sent
And hashing lives in a shared module under web/src/lib that depends on nothing in the Media
page, so that the editor's media picker slice reuses it: it reads the file, computes the
SHA-256 with crypto.subtle and resolves the lowercase 64-character hex, or resolves null when
crypto.subtle is unavailable
And at most 2 uploads are in flight at once; files are processed in the order they were
chosen or dropped, and hashing runs inside the file's concurrency slot while its row reads
"Waiting"
And uploads from this page are not cropped or resized, and each file is sent as chosen
And toast ids are "media-bulk-deleted", "media-bulk-delete-failed", "media-merged-{keep_id}"
and "media-not-found-{id}", and success toasts last 5 s; the bulk-delete failure toast has
no duration and stays until retried or dismissed
And the 12-image fixture, its counts { all: 12, unused: 2, duplicates: 2 } and its sample
data are the ones in features/media_browse.feature; the duplicate groups are {12, 5, 6}
(floorboards.jpg, floorboards-copy.jpg, IMG_2208.jpg, 3600 × 2400, 2.4 MB each) and
{7, 9} (IMG_4471.jpg, stone-wall-detail.jpg, 3600 × 2400, 3.1 MB each)
And where a board frame contradicts the design prose or the fixture, this spec follows the
prose and the fixture, and the design is corrected by the user, with no visual exception
Rule: Upload entry points
Scenario Outline: The Upload controls open the file chooser
Given the Default state has loaded <context>
When the user activates "<control>"
Then the file chooser opens, accepts several files and offers image/png, image/jpeg, image/webp and image/svg+xml
Examples:
| context | control |
| at md width or more | Upload |
| below md width | Upload |
| and the library has no image | Upload an image |
Scenario: Dropping files anywhere on the page
Given the Default state has loaded at md width or more
When the user drags files over the page
Then a dashed primary-bordered area covers the content, with the upload icon, the title "Drop images to upload" and the text "PNG, JPEG, WEBP, SVG · max 10 MB each"
And the mobile pinned Upload button is hidden while the area shows
When the user drags the files away from the page
Then the area disappears and nothing is uploaded
When the user drops the files on the page
Then the area disappears and the upload of each file starts straight away, with no crop step
Scenario: Dragging something that is not a file does nothing
When the user drags selected text over the page
Then no drop area shows
Rule: Browser checks, nothing is sent
Scenario Outline: A file that fails the checks becomes a rejected row
Given the user chooses the file "<file>" of <size> with MIME type "<mime>"
Then one row is added with the file-x icon, the title "<title>" and the text "Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image."
And no hash lookup and no POST /v1/media/ is sent for it
Examples:
| file | size | mime | title |
| scan.tif | 14.2 MB | image/tiff | scan.tif is 14.2 MB |
| notes.pdf | 210 KB | application/pdf | notes.pdf isn't an image |
| photo.png | 10.1 MB | image/png | photo.png is 10.1 MB |
| fake.jpg | 40 KB | text/plain | fake.jpg isn't an image |
| logo.gif | 40 KB | image/gif | logo.gif isn't an image |
| pic.jpg | 40 KB | image/png | pic.jpg isn't an image |
Scenario: Every rejected file leaves the panel with "Nothing was uploaded"
Given the user chooses scan.tif (14.2 MB), notes.pdf (210 KB) and broken.png (3.0 MB, rejected by the server with 400)
Then the panel header reads "Nothing was uploaded" with "3 files were rejected" and a "Dismiss" button
And the rows read "scan.tif is 14.2 MB", "notes.pdf isn't an image" and "broken.png couldn't be read" with "The server couldn't read this image. Save it again as a PNG or JPEG, then try again."
And no summary line shows
Scenario: Rejected and failed rows without an added image
Given the user chooses one rejected file and one file whose upload fails with 500
Then the panel header reads "Nothing was uploaded" with "2 files need attention"
And no summary line shows
Rule: The upload panel and its rows
Scenario: Files upload two at a time with progress
Given the user chooses 6 valid images and the duplicate lookup finds nothing
Then the panel shows above the images, below the toolbar, with the title "Uploading 6 images", "0 of 6 done" and a "Cancel all" button
And rows 1 and 2 read "Uploading", and rows 3 to 6 read "Waiting" (each row: 40 px thumbnail, filename, size)
When the first upload reports 1.1 MB sent of 2.6 MB
Then its row reads "Uploading 1.1 of 2.6 MB" over a 4 px progress bar filled to 42 % and shows a cancel x
When the first upload resolves 201
Then its row reads "Added to your library" with a circle-check icon, the header reads "1 of 6 done", and row 3 starts uploading
And at no time are more than 2 requests to POST /v1/media/ in flight
Scenario: The panel header counts settled rows
Given a panel with rows Added, Duplicate, Rejected, Failed, Uploading and Waiting
Then the header reads "4 of 6 done"
Scenario: Singular forms
Given the user chooses 1 valid image
Then the panel title reads "Uploading 1 image"
Scenario: New files added while the panel is open
Given the panel is showing rows
When the user chooses or drops more files
Then their rows are appended and the header counts cover all rows
Scenario: A new upload refreshes the list in the background
Given an upload resolves 201
Then the list is requested again with no skeleton, so the new image shows and the counts update
Scenario: A row whose content already exists
Given the user chooses "floorboards-2.jpg" (2.4 MB, valid)
When the SHA-256 of the file is computed
Then GET /v1/media/?hash=<that hex> is sent before any POST
And when it resolves 200 with the image "floorboards.jpg" (id 12, uploaded 14 Jun 2026 by Inès Duarte)
Then the row shows "Already in your library", the text `Same as "floorboards.jpg", uploaded 14 Jun 2026 by Inès Duarte.`, and the buttons "Use existing" and "Upload anyway"
And nothing is sent with POST /v1/media/ for it, and its concurrency slot is released
Scenario: Several existing matches name the oldest one
Given the lookup matches several images
Then the row names the oldest match
Scenario: Use existing selects the existing image
Given the row for "floorboards-2.jpg" is showing "Already in your library" for id 12
When the user activates "Use existing"
Then the row is dropped from the panel
And the image "floorboards.jpg" is selected, the bulk bar reads "1 selected", and the page announces "floorboards.jpg selected, 1 selected"
Scenario: Use existing when the image is not in the loaded list
Given the existing image is not in the loaded list because of the search, the tab or the 100 limit
When the user activates "Use existing"
Then the search and the tab reset to All and the image is selected
And if it is still not loaded the row is dropped without selecting
Scenario: Upload anyway stores a second copy
Given the row for "floorboards-2.jpg" is showing "Already in your library"
When the user activates "Upload anyway"
Then the file is sent with POST /v1/media/ and the row follows the normal upload states
And when it resolves 201 the list is reloaded and the Duplicates tab count reflects the new copy
Scenario Outline: The duplicate check is skipped when it cannot run
Given the user chooses a valid image and <condition>
Then the file is sent with POST /v1/media/ without waiting for the answer to a lookup
And any duplicate then shows up under Duplicates
Examples:
| condition |
| crypto.subtle is undefined (an insecure context) |
| GET /v1/media/?hash= fails with 500 or never reaches the server |
Scenario Outline: Server answers for an upload that passed the checks
Given a valid image "<file>" of <size> is uploading
When POST /v1/media/ resolves <status>
Then the row reads <row>
Examples:
| file | size | status | row |
| roof.png | 1.4 MB | 201 | circle-check "Added to your library" |
| scan.png | 9.9 MB | 413 | file-x "scan.png is 9.9 MB" over "Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image." |
| notes.png | 210 KB | 415 | file-x "notes.png isn't an image" over the same text |
| broken.png | 3.0 MB | 400 | file-x "broken.png couldn't be read" over "The server couldn't read this image. Save it again as a PNG or JPEG, then try again." |
| roof.png | 1.4 MB | 500 | circle-x "Upload failed" over "The server didn't accept the file. Nothing was added to your library." and a "Try again" button (icon rotate-cw) |
| roof.png | 1.4 MB | no answer (network) | the same Failed row |
Scenario: Try again retries one file
Given a row is "Upload failed"
When the user activates "Try again"
Then only that file is sent again with POST /v1/media/ (no new duplicate lookup), and the row follows the normal upload states
Scenario: Cancelling one upload or all of them
Given several files are uploading or waiting
When the user activates the cancel x on an uploading row
Then that request is aborted and the row is removed, and the next waiting file starts
When the user activates "Cancel all"
Then every request in flight is aborted and every waiting row is removed, Added, rejected and failed rows stay, and when no row is left the panel closes
Scenario: The panel settles with a summary
Given 5 files settled as 2 Added, 1 Already in your library (still undecided), 1 rejected and 1 failed
Then the header reads "Upload finished" with "5 images" and a "Dismiss" button in place of "Cancel all"
And a summary line reads "2 uploaded · 1 already in your library · 2 need attention"
And the page announces the summary when the last row settles
When the user activates "Dismiss"
Then the panel and its rows disappear, including rejected and failed rows, which stay until then
Scenario Outline: The summary line counts each outcome and omits empty parts
Given the panel has settled with <uploaded> added, <dup> already in the library, <attention> rejected or failed
Then the summary line reads "<line>"
Examples:
| uploaded | dup | attention | line |
| 2 | 1 | 2 | 2 uploaded · 1 already in your library · 2 need attention |
| 3 | 0 | 0 | 3 uploaded |
| 0 | 1 | 0 | 1 already in your library |
| 1 | 0 | 1 | 1 uploaded · 1 need attention |
Rule: Too many uploads (429)
Scenario: The server rate limits an upload
Given a file is uploading and files are waiting
When POST /v1/media/ resolves 429 with the header Retry-After: 42
Then an info alert with the timer icon reads "Too many uploads" over "For your security, wait a moment before uploading again."
And the Upload button reads "Try again in 0:42" with the timer icon and is disabled, and so is the mobile pinned button
And Added rows stay in the panel
And the rate-limited file goes back to Waiting and the queue pauses
And the countdown is driven by useRetryAfter and ticks once per second
When the countdown reaches 0:00
Then the alert disappears, the Upload button reads "Upload" and is enabled again
And the paused file and the waiting files resume by themselves
Scenario: A missing Retry-After counts as 60 seconds
When POST /v1/media/ resolves 429 without a readable Retry-After header
Then the countdown starts at 1:00
Scenario: Dropping or choosing files while rate limited
Given the countdown is running
When the user drops files on the page
Then nothing is uploaded and the drop area does not show
Rule: Offline
Scenario: Upload, bulk delete and merge need a connection
Given the Default state has loaded and navigator.onLine becomes false
Then the offline banner from B1 shows
And "Upload" (header, mobile pinned button and "Upload an image"), "Delete" in the bulk bar and every "Merge into this one" are disabled, and dropping files does nothing
And browsing, selecting and "Clear" still work
When navigator.onLine becomes true
Then they are enabled again
Rule: Selection
Scenario: Selecting images
Given the Default state has loaded at md width or more
Then no checkbox is shown on the cards
When the pointer rests on a card, or a card takes keyboard focus
Then its checkbox shows at the top left (36 px)
When the user ticks the checkbox of "north-wall.jpg" (or presses Space on the card)
Then the card takes the sand fill (--rc-sand) with a primary outline, and every card now shows its checkbox
And the page announces "north-wall.jpg selected, 1 selected"
And the keyboard path is Space on the card: the checkbox itself is not an extra tab stop
And Enter still opens the detail panel, and ticking a checkbox does not
Scenario: Unselecting announces the new count
Given 3 images are selected
When the user unselects "north-wall.jpg"
Then the page announces "north-wall.jpg unselected, 2 selected"
And when the last one is unselected it announces "Selection cleared"
Scenario: Selection on mobile
Given the Default state has loaded below md width
Then every card shows a 44 px checkbox, and so does every row of the card list in list view
And tapping the checkbox toggles the selection, and tapping the card opens the detail panel
Scenario: Select all and the partial state
Given the Default state has loaded
Then the row above the grid reads "Select all" and "12 images", with an unchecked checkbox
When 3 images are selected
Then that checkbox shows the partial state
When the user activates it while it is partial
Then all 12 loaded images are selected and it shows checked
When the user activates it while it is checked
Then the selection is emptied
And in list view at md width or more the table header checkbox does the same; the mobile card list has no "Select all" row and is selected through its 44 px row checkboxes
Scenario: Escape and Clear empty the selection
Given 3 images are selected and the detail panel is closed
When the user presses Escape, or activates "Clear" in the bulk bar
Then the selection is emptied and the bulk bar disappears
Given the detail panel is open over a selection
When the user presses Escape
Then only the panel closes and the selection stays
Scenario: The selection follows the query
Given 3 images are selected
When the user changes the search text, the tab or the sort
Then the selection is cleared
When the user switches between grid and list
Then the selection is kept
Scenario: The bulk bar
Given the images "north-wall.jpg", "kitchen-after.webp" and "window-frame.jpg" are selected
Then a sticky bar sits at the bottom: 400 px wide and 24 px above the bottom edge, centred over the content area on desktop; full width with 16 px side margins and 16 px above the bottom edge on mobile
And it has the inverted fill, the text "3 selected", a danger "Delete" button (icon trash-2) and an inverted "Clear" button
And on mobile it replaces the pinned Upload button
And toasts sit above it on mobile
Rule: Bulk delete
Scenario: Bulk delete, confirm
Given "north-wall.jpg" (used in 2), "kitchen-after.webp" (used in 3) and "window-frame.jpg" (unused) are selected
When the user activates "Delete"
Then a dialog opens titled "Delete 3 images?" with the description "2 of them are used in 5 articles, which will show a broken image. The other one isn't used anywhere. This can't be undone."
And the body has one bordered row per selected image: the filename and the badge "Used in 2", "Used in 3" or "Unused"
And the buttons read "Cancel" first, then a danger "Delete 3 images"
And no usage request is sent
Scenario: Bulk delete, singular
Given one image is selected
When the user activates "Delete"
Then the dialog is titled "Delete 1 image?" and the confirm button reads "Delete 1 image"
Scenario: Bulk delete, in progress
Given the bulk delete dialog is open for 3 images
When the user activates "Delete 3 images"
Then one DELETE /v1/media/{id} is sent for each of the 3 images
And both buttons are disabled, the confirm button reads "Deleting…" with a loader-circle, and Cancel, Escape and a backdrop click do not close the dialog
Scenario: Bulk delete, done
Given a bulk delete of ids 1, 2 and 8 is in flight
When every DELETE resolves 204
Then the dialog closes, the 3 cards leave the grid and the selection is empty
And focus moves to the card now at the position of the first deleted card, or to the page heading when no card remains
And a success toast appears: icon circle-check, id "media-bulk-deleted", title "3 images deleted", text "They're gone from your library."
And the list is then requested again in the background, with no skeleton
Scenario: Bulk delete, partly failed
Given a bulk delete of ids 1, 2 and 8 is in flight
When DELETE /v1/media/1 and DELETE /v1/media/2 resolve 204 and DELETE /v1/media/8 resolves 500
Then the dialog closes, the cards for ids 1 and 2 leave the grid, and "window-frame.jpg" stays in the grid and stays selected, so the bulk bar reads "1 selected"
And an error toast appears and stays until retried or dismissed: icon circle-x, id "media-bulk-delete-failed", title "Couldn't delete 1 image", text "2 images were deleted. window-frame.jpg is still in your library. Try again." and a "Retry" button (icon rotate-cw)
When the user activates "Retry"
Then DELETE /v1/media/8 is sent again with no dialog, and on 204 the card leaves and the toast is replaced by a success toast
Scenario: Bulk delete, several failures
Given a bulk delete of 4 images in which two fail
Then the toast title reads "Couldn't delete 2 images" and the text names both files, "a.jpg and b.jpg are still in your library. Try again."
Scenario: Bulk delete, an image that no longer exists
Given a bulk delete of 3 images is in flight
When one DELETE resolves 404 and the others 204
Then the 404 image leaves the list and counts as deleted, and the success toast reads "3 images deleted"
Scenario: Bulk delete, every request fails
Given a bulk delete of 3 images is in flight
When every DELETE resolves 500 or never arrives
Then the dialog stays open with an error alert "Couldn't delete the images, so they're still there. Try again in a moment." and both buttons enabled
Rule: Duplicates
Scenario: The Duplicates tab is enabled
Given the Default state has loaded
When the user selects the "Duplicates 2" tab
Then the URL gains ?filter=duplicates and the list is requested with filter=duplicates and limit=100
And the tab is selected, and "Duplicates" carries the info-tinted count of groups
And opening /media?filter=duplicates lands on the same view, and "Review duplicates" in the detail panel of "floorboards.jpg" is enabled and selects this tab
And search applies, the sort menu and the view toggle show as on the other tabs, sort is sent with the request, and the view toggle has no effect on the groups view while the grid or list preference stays in the URL
Scenario: Duplicates, groups
Given GET /v1/media/?filter=duplicates&sort=newest&limit=100 resolves 200 with ids 5, 6, 7, 9 and 12
When the user opens the Duplicates tab
Then the images are grouped by sha256 into 2 bordered cards stacked with a 16 px gap, the group with the oldest suggested copy first
And group 1 has the header "Group 1" over "3 identical images · 2.4 MB each · 3600 × 2400", and group 2 has "Group 2" over "2 identical images · 3.1 MB each · 3600 × 2400"
And group 1 has one column per copy, in the order "floorboards.jpg", "floorboards-copy.jpg", "IMG_2208.jpg" (most used, then oldest, then lowest id), and group 2 has "IMG_4471.jpg" then "stone-wall-detail.jpg"; below md the columns stack
And each copy shows its thumbnail, filename, "Uploaded 14 Jun 2026 by Inès Duarte", an underlined "Used in 4 articles" that opens its detail panel, and a "Merge into this one" button (icon merge)
And "floorboards.jpg" and "IMG_4471.jpg" carry the sand fill, a primary outline, the "Suggested" tag and a primary button, and the other copies are outlined with outline buttons
And each group's footer reads "Suggested: the most used copy, then the oldest. Choosing another copy switches articles to it."
Scenario: Group header details
Given a group of SVG images whose uploader is unknown
Then the header reads "2 identical images · 8 KB each · Vector" and each copy reads "Uploaded 14 Jun 2026" without "by"
Scenario Outline: The suggested copy
Given a group of copies <copies>
Then the suggested copy is <suggested>
Examples:
| copies | suggested |
| used in 4 (14 Jun), 3 (2 Sep) and 2 (30 Aug) | the copy used in 4 |
| used in 2 (29 Aug) and 2 (21 Aug) | the copy of 21 Aug (same use, oldest) |
| used in 0 (3 Sep) and 0 (1 Sep) | the copy of 1 Sep |
Scenario: Duplicates, empty
Given GET /v1/media/?filter=duplicates resolves 200 with no item and counts.duplicates 0
Then the Duplicates tab has no count badge
And a panel shows the copy icon, "No duplicates", "Every image in your library is different." and a "Back to all images" button
When the user activates "Back to all images"
Then the tab returns to All and ?filter leaves the URL
Rule: Merge
Scenario: Merge, confirm
Given the Duplicates tab shows group 1
When the user activates "Merge into this one" on "floorboards.jpg"
Then a dialog opens titled "Merge into floorboards.jpg?" with the description "Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 articles will be updated. This can't be undone."
And the body shows the kept image: its thumbnail, "floorboards.jpg" and "Kept · used in 4 articles now"
And the buttons read "Cancel" first, then a danger "Merge 2 copies"
Scenario: Choosing another copy keeps that one
When the user activates "Merge into this one" on "IMG_2208.jpg" in group 1
Then the dialog is titled "Merge into IMG_2208.jpg?" and its article count is the sum of the used_in of "floorboards.jpg" and "floorboards-copy.jpg"
Scenario: Merge, singular
Given a group of 2 copies
Then the description reads "Articles that use the other copy will be switched to …" and the confirm button reads "Merge 1 copy"
Scenario: Merge, in progress
Given the Merge dialog is open for group 1 with "floorboards.jpg" kept
When the user activates "Merge 2 copies"
Then POST /v1/media/merge is sent with { "keep_id": 12, "merge_ids": [5, 6] }
And both buttons are disabled, the confirm button reads "Merging…" with a loader-circle, and Cancel, Escape and a backdrop click do not close the dialog
Scenario: Merge, done
Given a merge for group 1 is in flight
When POST /v1/media/merge resolves 200 with { "articles_updated": 5, "merged": 2 }
Then the dialog closes and group 1 leaves the list
And a success toast appears: icon circle-check, id "media-merged-12", title "Copies merged", text "2 copies merged. 5 articles updated."
And the page announces the same text, and the list is requested again in the background so the counts and the Duplicate flags update
And when it was the last group, the "No duplicates" panel shows and the tab loses its count badge
Scenario Outline: Merge, errors keep the dialog open or remove the image
Given a merge for group 1 is in flight
When POST /v1/media/merge <failure>
Then <result>
Examples:
| failure | result |
| resolves with a 500 response | the dialog stays open with the error alert "Couldn't merge the copies. Nothing was changed. Try again in a moment." and both buttons enabled again |
| never reaches the server | the same alert |
| resolves with a 409 response | the dialog stays open with "One of these images changed, so nothing was merged. The list is up to date now. Check it and try again.", and the list is reloaded |
| resolves with a 404 response | the dialog closes, the info toast "That image no longer exists" (id "media-not-found-12") shows, and the list is reloaded |
Rule: Sessions
Scenario Outline: Session errors are global for the new calls
When <call> resolves <status>
Then the user is sent to <destination> and no local copy shows
Examples:
| call | status | destination |
| POST /v1/media/ (XHR) | 401 | /login?redirect=/media&reason=expired |
| POST /v1/media/ (XHR) | 403 | /register/pending |
| POST /v1/media/merge | 401 | /login?redirect=/media&reason=expired |
| DELETE /v1/media/{id} | 403 | /register/pending |
| GET /v1/media/?hash= | 401 | /login?redirect=/media&reason=expired |
@@ -0,0 +1,58 @@
# QA: Media page, part 2 (upload, selection, duplicates)
## Scope note
SPA only. No Go test and no `test/` file; run the Go suite once (`task test`) to confirm nothing regressed. The server never returns 429 for POST /v1/media/ today (no rate limiter is mounted on the media router), so every 429 case is proven with a mock only.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount MediaPage in DashboardShell with a verified session and a test router at /media, as in the B1 spec. Mock useApi's `request` for GET/DELETE/merge and the list; replace `XMLHttpRequest` with a controllable stub (per instance: `open`, `send`, `abort`, `upload.onprogress` with `loaded`/`total`, `status`, `responseText`, `getResponseHeader('Retry-After')`, `load`, `error`, `abort` events). Stub `crypto.subtle.digest` to a known hex per file with `vi.stubGlobal`, and to `undefined` for the unavailable case. Use fake timers for the 150 ms skeleton, the 120 ms search debounce and the 1 s Retry-After tick. Sample data is the 12-image fixture of media_browse.feature. Files are built with `new File([...], name, { type })` and a `size` override for over-limit sizes.
1. **Hashing lib** (pure): digest of a known byte array gives the expected lowercase 64-char hex; `crypto.subtle` undefined gives null; nothing in the lib imports from the Media page.
2. **Browser checks**: the six rows of the .feature, plus 10485760 bytes accepted and 10485761 rejected; size beats type; a rejected file triggers no hash call, no GET and no XHR; titles and text exact.
3. **Entry points**: Upload (desktop header), the mobile pinned button and "Upload an image" each click the hidden `input[type=file]` (multiple, exact accept list).
4. **Drop overlay**: dragenter with Files shows the copy exactly; dragleave hides; drop starts uploads; a drag without Files shows nothing; mobile pinned button hidden while shown.
5. **Queue**: 6 valid files give at most 2 XHRs open at once; order kept; header "Uploading 6 images" and "N of 6 done" counts settled rows; "Uploading 1 image" singular; progress 1.1 of 2.6 MB reads "Uploading 1.1 of 2.6 MB" with a 42 % bar; 201 gives "Added to your library"; a silent list reload follows; files added while open are appended.
6. **Duplicate check**: GET `?hash=` precedes POST; a match gives the Duplicate row with exact text (oldest match named); slot released; "Use existing" drops the row and selects the id (bulk bar "1 selected", live region text), including the reset-to-All path; "Upload anyway" sends POST; crypto undefined and lookup failure skip straight to POST.
7. **Server statuses**: 201, 413, 415, 400, 500, network error: row copy and icon exact; "Try again" re-sends only that file with no new GET `?hash=`.
8. **Cancel**: row x aborts that XHR and removes the row, the next file starts; "Cancel all" aborts all and removes waiting rows; panel closes when empty.
9. **Summary**: Examples-driven summary lines; "Upload finished" / "{n} images" and "Dismiss"; "Nothing was uploaded" with "3 files were rejected" and with "2 files need attention", no summary; live-region text; Dismiss removes every row.
10. **429**: `Retry-After: 42` gives the alert copy and "Try again in 0:42" (disabled, timer icon; mobile pinned button too); the 429'd file returns to Waiting; advancing 42 s re-enables, removes the alert and resumes the queue; Added rows kept; missing header gives 1:00; drops ignored while counting.
11. **Offline**: Upload, "Upload an image", bulk "Delete" and "Merge into this one" disabled; drops ignored; Clear and selection still work; back online re-enables.
12. **Selection**: checkbox visibility (hover, focus, any selection, always on mobile); tick and Space toggle; Enter opens the panel and ticking does not; live-region texts for select, unselect and "Selection cleared"; the checkbox is not a tab stop; select-all states (unchecked, partial, checked) in the grid row and the table header (no row in the mobile card list); Escape closes the panel first and clears the selection next; Clear; selection cleared on q/tab/sort change, kept on view change.
13. **Bulk bar**: copy, buttons, desktop and mobile placement classes, replaces the mobile pinned button.
14. **Bulk delete**: dialog copy exact (title, description with the summed count, rows "Used in 2", "Used in 3", "Unused"), singular form; no usage request; in progress (one DELETE per id, buttons disabled, not dismissible); done (cards leave, selection empty, focus target, toast id and text, silent reload); partly failed (two removed, failed one stays selected, bar "1 selected", toast with Retry and no auto-dismiss, Retry re-sends only the failed DELETE); several failures title; 404 counted as deleted; all failed gives the inline alert.
15. **Duplicates tab**: enabled, URL and request (`filter=duplicates`, limit=100), deep link, "Review duplicates" enabled and switching tab; sort and view toggle shown (sort sent with the request, toggle keeps the preference in the URL); grouping by sha256, group order, headers (including the SVG/unknown uploader case), column order, "Suggested" tag and footer, "Uploaded 14 Jun 2026 by Inès Duarte", "Used in 4 articles" opens the panel; suggested-copy Examples; empty state and "Back to all images"; badge absent at 0.
16. **Merge**: dialog copy exact for floorboards.jpg and another kept copy, singular form; POST body `{ keep_id: 12, merge_ids: [5, 6] }`; in progress not dismissible; 200 gives group removed, toast id "media-merged-12" and text, live-region text, silent reload, "No duplicates" when last; 500 and network give the alert; 409 gives the alert and a reload; 404 gives the info toast and closes.
17. **Global errors**: 401 and 403 from XHR upload, merge, delete and hash lookup call the same redirect handler as useApi; no local copy.
18. **B1 regression**: media_browse specs updated for the superseded statements and the rest still passes.
## Visual (`task visual:check -- media`, desktop and mobile, light and dark)
Reference: docs/design/screens/media.md and `source/MediaFrame.dc.html`. The 20 B2 states lose `skip: 'pending'` and get a setup in `web/visual/states.ts`. None depends on the current date (dates are absolute), so none is `skip: 'clock-sensitive'`. Where a board frame contradicts the prose or fixture (bulk delete done / partly failed, "4 articles", tab badge at zero, "2 of 6 done", merge done with one group left), the user corrects the board; no exception is added.
Harness requirements:
- Upload states use an init-script XHR stub that holds a request at 1.1 of 2.6 MB (42 %); files come from `setInputFiles`; the duplicate state mocks `GET /v1/media/?hash=` by the hash of the file's bytes.
- The drag state dispatches a synthetic `dragenter` with `dataTransfer.types` = `['Files']`.
- The 429 state freezes timers with Playwright `page.clock` so the button reads "0:42".
Setups:
- [ ] Selection and bulk bar (ids 1, 2, 8; partial select-all)
- [ ] 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: Space toggles selection, Enter opens the panel, Escape order (panel first, then selection); focus after a bulk delete; dialog focus starts on Cancel.
- [ ] Drag and drop with real files from the OS, including a folder and a non-image drop.
- [ ] Progress bar smoothness, hover and focus rings, reduced motion.
- [ ] Screen reader announcements for selection, upload completion and merge.
## Manual verification
- Real upload round trip against a running server (`task dev`): progress on a large file, 413 at 10 MB + 1 byte, 415, 400 for a corrupt PNG, uploading the same bytes twice (duplicate row, "Upload anyway", Duplicates tab).
- Real SHA-256 over a 10 MB file, and the skip path on an insecure origin (plain http on a LAN address).
- Real merge: article bodies rewritten, counts after the refresh.
- Real 429 cannot be checked: the media router has no limiter.
- Real bulk delete of 20+ images with one deleted from another tab first (404 handling).
+9 -1
View File
@@ -1,9 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterView } from 'vue-router'
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
const BULK_BAR_TOAST_OFFSET = 'max-sm:bottom-20'
const toaster = computed(() => ({
ui: { viewport: toastsAboveBulkBar.value ? BULK_BAR_TOAST_OFFSET : '' },
}))
</script>
<template>
<UApp class="isolate">
<UApp class="isolate" :toaster="toaster">
<RouterView />
</UApp>
</template>
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MediaBadge from '@/components/MediaBadge.vue'
import type { MediaItem } from '@/composables/useMedia'
import { pluralArticles } from '@/lib/mediaFormat'
const BROKEN_IMAGE = 'which will show a broken image.'
const FINAL_WARNING = "This can't be undone."
const props = defineProps<{
items: MediaItem[]
action: () => Promise<void | boolean>
}>()
const emit = defineEmits<{ close: [confirmed: boolean] }>()
const total = computed(() => props.items.length)
const imageLabel = computed(() => `${total.value} ${total.value === 1 ? 'image' : 'images'}`)
const inUse = computed(() => props.items.filter((item) => item.used_in > 0))
const articleCount = computed(() => inUse.value.reduce((sum, item) => sum + item.used_in, 0))
const description = computed(() => {
const usedCount = inUse.value.length
const unusedCount = total.value - usedCount
const articles = pluralArticles(articleCount.value)
if (usedCount === 0) {
return total.value === 1
? `It isn't used in any article. ${FINAL_WARNING}`
: `None of them are used in any article. ${FINAL_WARNING}`
}
if (unusedCount === 0) {
const subject = total.value === 1 ? "It's" : `All ${total.value} are`
return `${subject} used in ${articles}, ${BROKEN_IMAGE} ${FINAL_WARNING}`
}
const rest =
unusedCount === 1
? "The other one isn't used anywhere."
: `The other ${unusedCount} aren't used anywhere.`
return `${usedCount} of them are used in ${articles}, ${BROKEN_IMAGE} ${rest} ${FINAL_WARNING}`
})
</script>
<template>
<ConfirmDialog
:title="`Delete ${imageLabel}?`"
:description="description"
:confirm-label="`Delete ${imageLabel}`"
cancel-label="Cancel"
pending-label="Deleting…"
tone="danger"
:action="action"
@close="emit('close', $event)"
>
<template #body>
<ul class="border border-default">
<li
v-for="(item, index) in items"
:key="item.id"
data-testid="bulk-delete-row"
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">{{
item.filename
}}</span>
<MediaBadge
v-if="item.used_in > 0"
kind="used"
:label="`Used in ${item.used_in}`"
/>
<MediaBadge v-else kind="unused" label="Unused" />
</li>
</ul>
</template>
</ConfirmDialog>
</template>
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem } from '@/composables/useMedia'
import type { DuplicateGroup } from '@/lib/mediaDuplicates'
import { formatMediaDate, pluralArticles } from '@/lib/mediaFormat'
const FOOTER =
'Suggested: the most used copy, then the oldest. Choosing another copy switches articles to it.'
defineProps<{
groups: DuplicateGroup[]
offline: boolean
}>()
const emit = defineEmits<{
merge: [group: DuplicateGroup, keep: MediaItem]
open: [id: number, opener: HTMLElement]
}>()
function uploaded(copy: MediaItem) {
const by = copy.uploaded_by ? ` by ${copy.uploaded_by}` : ''
return `Uploaded ${formatMediaDate(copy.created_at)}${by}`
}
</script>
<template>
<div class="flex flex-col gap-4">
<section
v-for="(group, index) in groups"
:key="group.hash"
data-testid="duplicate-group"
class="border border-default bg-default"
>
<header class="flex flex-col gap-0.5 border-b border-default px-3.5 py-3">
<h3 class="font-semibold text-highlighted">Group {{ index + 1 }}</h3>
<span class="text-muted">{{ group.header }}</span>
</header>
<div
class="grid md:grid-cols-(--columns) md:gap-4 md:p-4"
:style="{ '--columns': `repeat(${group.copies.length}, minmax(0, 1fr))` }"
>
<div
v-for="copy in group.copies"
:key="copy.id"
data-testid="duplicate-copy"
:data-suggested="copy.id === group.suggested.id || undefined"
class="flex flex-col gap-3 max-md:border-b max-md:border-muted max-md:p-3.5 max-md:last:border-b-0 md:border md:p-3"
:class="
copy.id === group.suggested.id
? 'bg-secondary-200 text-primary-900 md:border-primary dark:bg-secondary-300'
: 'bg-default text-default md:border-default'
"
>
<div class="flex gap-3 max-md:items-start md:flex-col">
<MediaThumb
:item="copy"
class="border border-default max-md:h-18 max-md:w-24 md:aspect-4/3 md:w-full"
/>
<div class="flex min-w-0 flex-col gap-1">
<div class="flex flex-wrap items-center gap-1.5">
<span
class="truncate font-medium"
:class="
copy.id === group.suggested.id
? 'text-primary-900'
: 'text-highlighted'
"
>{{ copy.filename }}</span
>
<span
v-if="copy.id === group.suggested.id"
class="inline-flex h-5 items-center bg-primary px-1.5 text-xs font-medium text-inverted"
>Suggested</span
>
</div>
<span
class="text-xs/5"
:class="
copy.id === group.suggested.id
? 'text-primary-600'
: 'text-muted'
"
>{{ uploaded(copy) }}</span
>
<button
type="button"
data-testid="duplicate-used-in"
class="self-start font-medium underline underline-offset-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
@click="emit('open', copy.id, $event.currentTarget as HTMLElement)"
>
Used in {{ pluralArticles(copy.used_in) }}
</button>
</div>
</div>
<UButton
icon="i-lucide-merge"
:color="copy.id === group.suggested.id ? 'primary' : 'neutral'"
:variant="copy.id === group.suggested.id ? 'solid' : 'outline'"
:disabled="offline"
class="h-11 justify-center px-3 md:h-8"
:class="
copy.id === group.suggested.id
? ''
: 'border border-accented bg-default text-highlighted ring-0'
"
@click="emit('merge', group, copy)"
>
Merge into this one
</UButton>
</div>
</div>
<footer class="border-t border-default px-3.5 py-2.5 text-xs/5 text-muted">
{{ FOOTER }}
</footer>
</section>
</div>
</template>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useElementBounding, useMediaQuery } from '@vueuse/core'
const DESKTOP_QUERY = '(min-width: 48rem)'
const CONTENT_SELECTOR = '#dashboard-content'
defineProps<{
count: number
offline: boolean
obscured: boolean
}>()
const emit = defineEmits<{
delete: []
clear: []
}>()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const content = useElementBounding(() => document.querySelector<HTMLElement>(CONTENT_SELECTOR))
const centre = computed(() => `${content.left.value + content.width.value / 2}px`)
</script>
<template>
<div
role="region"
aria-label="Selection"
data-testid="media-bulk-bar"
class="fixed bottom-4 z-(--z-banner) flex items-center gap-2 bg-inverted py-2 pr-2 pl-4 text-inverted shadow-(--shadow-popover) max-md:inset-x-4 md:bottom-6 md:left-(--bulk-centre) md:w-100 md:-translate-x-1/2"
:class="{ invisible: obscured }"
:inert="obscured || undefined"
:style="isDesktop ? { '--bulk-centre': centre } : undefined"
>
<span class="flex-1 font-medium">{{ count }} selected</span>
<UButton
color="error"
icon="i-lucide-trash-2"
:disabled="offline"
data-testid="bulk-delete"
class="h-11 px-3 md:h-8"
@click="emit('delete')"
>
Delete
</UButton>
<UButton
color="neutral"
variant="outline"
data-testid="bulk-clear"
class="h-11 border border-(--ui-text-inverted) bg-transparent px-3 text-inverted ring-0 hover:bg-transparent md:h-8"
@click="emit('clear')"
>
Clear
</UButton>
</div>
</template>
+30 -5
View File
@@ -1,15 +1,18 @@
<script setup lang="ts">
import MediaCardBadges from '@/components/MediaCardBadges.vue'
import MediaCheckbox from '@/components/MediaCheckbox.vue'
import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem } from '@/composables/useMedia'
import { formatDimensions, formatMediaDate, formatSize, formatType } from '@/lib/mediaFormat'
defineProps<{
items: MediaItem[]
selected: number[]
}>()
const emit = defineEmits<{
open: [id: number, opener: HTMLElement]
toggle: [id: number]
}>()
</script>
@@ -19,20 +22,31 @@ const emit = defineEmits<{
v-for="item in items"
:key="item.id"
data-testid="media-card-row"
class="border-b border-default last:border-b-0"
:data-selected="selected.includes(item.id) || undefined"
class="relative border-b border-default last:border-b-0"
:class="
selected.includes(item.id)
? 'bg-secondary-200 text-primary-900 dark:bg-secondary-300'
: 'bg-default'
"
>
<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"
class="flex min-h-20 w-full items-center py-2 pr-3 pl-11 text-start focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
:class="selected.includes(item.id) ? 'text-primary-600' : 'text-muted'"
@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="truncate text-[0.9375rem] font-medium"
:class="
selected.includes(item.id) ? 'text-primary-900' : 'text-highlighted'
"
>{{ item.filename }}</span
>
<span class="text-xs/5"
>{{ formatSize(item.size) }} ·
{{ formatDimensions(item.width, item.height) }} ·
@@ -45,6 +59,17 @@ const emit = defineEmits<{
</span>
<UIcon name="i-lucide-chevron-right" class="ml-2 size-4 shrink-0" />
</button>
<div
class="absolute inset-y-0 left-0 flex w-11 items-center justify-center"
@click.self="emit('toggle', item.id)"
>
<MediaCheckbox
:state="selected.includes(item.id) ? 'checked' : 'unchecked'"
:label="`Select ${item.filename}`"
:tabbable="false"
@toggle="emit('toggle', item.id)"
/>
</div>
</li>
</ul>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
state: 'checked' | 'unchecked' | 'partial'
label: string
size?: 'sm' | 'md'
tabbable?: boolean
}>(),
{ size: 'md', tabbable: true },
)
const emit = defineEmits<{ toggle: [] }>()
const modelValue = computed(() => {
if (props.state === 'partial') {
return 'indeterminate' as const
}
return props.state === 'checked'
})
const boxClass = computed(() =>
props.size === 'sm' ? 'size-4.5 bg-default' : 'size-5 bg-default max-md:size-5.5',
)
</script>
<template>
<UCheckbox
:model-value="modelValue"
:aria-label="label"
:tabindex="tabbable ? undefined : -1"
color="primary"
:ui="{
root: 'items-center',
container: 'h-auto items-center',
base: boxClass,
icon: 'size-3.5',
}"
@update:model-value="emit('toggle')"
/>
</template>
+3 -8
View File
@@ -9,6 +9,7 @@ import {
formatMediaDate,
formatSize,
formatType,
joinNames,
pluralArticles,
} from '@/lib/mediaFormat'
@@ -27,6 +28,7 @@ const emit = defineEmits<{
'copy-url': []
rename: []
delete: []
'review-duplicates': []
}>()
const open = defineModel<boolean>('open', { default: false })
@@ -55,13 +57,6 @@ const metadata = computed(() => {
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
@@ -149,10 +144,10 @@ const duplicateAlert = computed(() => {
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' }"
@click="emit('review-duplicates')"
>
Review duplicates
</UButton>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { DROP_TEXT, DROP_TITLE } from '@/lib/mediaUploadRows'
</script>
<template>
<div
data-testid="media-drop-area"
aria-hidden="true"
class="pointer-events-none fixed inset-2 top-16 z-(--z-popover) flex flex-col items-center justify-center gap-2 border-2 border-dashed border-primary bg-secondary-200 text-center text-primary-900 md:inset-3 md:top-15 dark:bg-secondary-300"
>
<UIcon name="i-lucide-upload" class="size-7" />
<span class="text-lg font-semibold">{{ DROP_TITLE }}</span>
<span class="text-primary-600">{{ DROP_TEXT }}</span>
</div>
</template>
+13 -2
View File
@@ -4,9 +4,11 @@ import { computed } from 'vue'
const QUERY_MAX_LENGTH = 40
const props = defineProps<{
variant: 'first-run' | 'no-results'
variant: 'first-run' | 'no-results' | 'no-duplicates'
query?: string
filterLabel?: string
offline?: boolean
limited?: boolean
}>()
const emit = defineEmits<{ action: [] }>()
@@ -22,6 +24,14 @@ const current = computed(() => {
action: 'Upload an image',
}
}
if (props.variant === 'no-duplicates') {
return {
icon: 'i-lucide-copy',
title: 'No duplicates',
description: 'Every image in your library is different.',
action: 'Back to all images',
}
}
return {
icon: 'i-lucide-search',
title: truncatedQuery.value
@@ -51,8 +61,9 @@ const current = computed(() => {
<UButton
v-if="variant === 'first-run'"
icon="i-lucide-upload"
disabled
:disabled="offline || limited"
class="h-11 px-4 md:h-8"
@click="emit('action')"
>
{{ current.action }}
</UButton>
+51 -6
View File
@@ -1,17 +1,24 @@
<script setup lang="ts">
import { ref } from 'vue'
import MediaCardBadges from '@/components/MediaCardBadges.vue'
import MediaCheckbox from '@/components/MediaCheckbox.vue'
import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem } from '@/composables/useMedia'
import { accessibleName, formatDimensions, formatSize } from '@/lib/mediaFormat'
const SELECTED_CARD =
'border-primary bg-secondary-200 text-primary-900 inset-ring-1 inset-ring-primary dark:bg-secondary-300'
const IDLE_CARD = 'border-default bg-default text-default'
const props = defineProps<{
items: MediaItem[]
columns: number
selected: number[]
}>()
const emit = defineEmits<{
open: [id: number, opener: HTMLElement]
toggle: [id: number]
}>()
const grid = ref<HTMLElement | null>(null)
@@ -38,6 +45,9 @@ function targetIndex(key: string, index: number) {
function onKeydown(event: KeyboardEvent, item: MediaItem, index: number) {
if (event.key === ' ') {
event.preventDefault()
if (!event.repeat) {
emit('toggle', item.id)
}
return
}
if (event.key === 'Enter') {
@@ -62,23 +72,40 @@ function onKeyup(event: KeyboardEvent) {
<template>
<div ref="grid" class="grid grid-cols-2 gap-2.5 md:grid-cols-5 md:gap-4">
<div v-for="(item, index) in items" :key="item.id" class="group/card relative min-w-0">
<button
v-for="(item, index) in items"
:key="item.id"
type="button"
data-media-card
:data-media-id="item.id"
:data-selected="selected.includes(item.id) || undefined"
: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"
class="flex w-full min-w-0 flex-col border text-start transition-shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
:class="
selected.includes(item.id)
? SELECTED_CARD
: [IDLE_CARD, 'hover:border-accented hover:shadow-(--shadow-popover)']
"
@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" />
<MediaThumb
:item="item"
class="aspect-4/3 w-full border-b"
:class="selected.includes(item.id) ? 'border-primary' : '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"
<span
class="truncate font-medium"
:class="
selected.includes(item.id) ? 'text-primary-900' : 'text-highlighted'
"
>{{ item.filename }}</span
>
<span
class="truncate text-xs/5"
:class="selected.includes(item.id) ? 'text-primary-600' : 'text-muted'"
>{{ formatSize(item.size) }} ·
{{ formatDimensions(item.width, item.height) }}</span
>
@@ -88,5 +115,23 @@ function onKeyup(event: KeyboardEvent) {
</span>
</span>
</button>
<div
data-testid="card-checkbox"
class="absolute top-0 left-0 flex size-11 items-center justify-center md:size-9"
:class="
selected.length > 0
? 'opacity-100'
: 'max-md:opacity-100 md:opacity-0 md:group-hover/card:opacity-100 md:group-has-[:focus-visible]/card:opacity-100'
"
@click.self="emit('toggle', item.id)"
>
<MediaCheckbox
:state="selected.includes(item.id) ? 'checked' : 'unchecked'"
:label="`Select ${item.filename}`"
:tabbable="false"
@toggle="emit('toggle', item.id)"
/>
</div>
</div>
</div>
</template>
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { SelectAllState } from '@/composables/useMediaSelection'
const props = defineProps<{
state: SelectAllState
count: number
}>()
const emit = defineEmits<{ toggle: [] }>()
const modelValue = computed(() => {
if (props.state === 'partial') {
return 'indeterminate' as const
}
return props.state === 'all'
})
const imageCount = computed(() => `${props.count} ${props.count === 1 ? 'image' : 'images'}`)
</script>
<template>
<div class="flex items-center gap-2.5 text-muted">
<UCheckbox
:model-value="modelValue"
label="Select all"
color="primary"
class="-ml-2.75 md:ml-0"
:ui="{
root: 'items-center',
container: 'size-11 shrink-0 items-center justify-center md:size-4.5',
base: 'size-5.5 bg-default md:size-4.5',
wrapper: 'ms-2.5',
label: 'font-medium text-highlighted',
}"
@update:model-value="emit('toggle')"
/>
<span data-testid="image-count">{{ imageCount }}</span>
</div>
</template>
+38 -5
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import MediaBadge from '@/components/MediaBadge.vue'
import MediaCheckbox from '@/components/MediaCheckbox.vue'
import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem } from '@/composables/useMedia'
import type { SelectAllState } from '@/composables/useMediaSelection'
import {
formatDimensions,
formatMediaDate,
@@ -15,12 +17,22 @@ const ROW_GRID =
defineProps<{
items: MediaItem[]
selected: number[]
allState: SelectAllState
}>()
const emit = defineEmits<{
open: [id: number, opener: HTMLElement]
toggle: [id: number]
'toggle-all': []
}>()
const ALL_STATES = {
none: 'unchecked',
partial: 'partial',
all: 'checked',
} as const
function open(event: Event, id: number) {
emit('open', id, event.currentTarget as HTMLElement)
}
@@ -29,7 +41,14 @@ function open(event: Event, id: number) {
<template>
<div class="border border-default bg-default">
<div :class="ROW_GRID" class="h-9 bg-elevated font-medium text-muted">
<span></span>
<span class="flex justify-center">
<MediaCheckbox
size="sm"
label="Select all"
:state="ALL_STATES[allState]"
@toggle="emit('toggle-all')"
/>
</span>
<span>Filename</span>
<span>Dimensions</span>
<span>Size</span>
@@ -46,17 +65,31 @@ function open(event: Event, id: number) {
: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"
:data-selected="selected.includes(item.id) || undefined"
class="relative h-14 border-t border-default"
:class="[
ROW_GRID,
selected.includes(item.id)
? 'bg-secondary-200 text-primary-900 dark:bg-secondary-300'
: 'bg-default text-muted hover:bg-elevated',
]"
>
<span></span>
<span class="relative z-10 flex justify-center">
<MediaCheckbox
size="sm"
:label="`Select ${item.filename}`"
:state="selected.includes(item.id) ? 'checked' : 'unchecked'"
@toggle="emit('toggle', item.id)"
/>
</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"
class="truncate font-medium after:absolute after:inset-0 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
:class="selected.includes(item.id) ? 'text-primary-900' : 'text-highlighted'"
@click="open($event, item.id)"
>
{{ item.filename }}
+1 -1
View File
@@ -41,7 +41,7 @@ const logoSize = computed(() => ({ sm: 16, md: 20, lg: 28, xl: 40 })[props.size]
<UIcon v-else name="i-lucide-image" class="opacity-55" :class="iconClass" />
</span>
<img
v-if="!failed"
v-if="!failed && item.url"
:src="item.url"
alt=""
loading="lazy"
+2 -2
View File
@@ -72,13 +72,13 @@ 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 },
{ label: 'Duplicates', value: 'duplicates', count: props.counts?.duplicates },
])
const tab = computed({
get: () => props.controls.filter,
set: (value: string) => {
if (value === 'all' || value === 'unused') {
if (value === 'all' || value === 'unused' || value === 'duplicates') {
emit('update:filter', value)
}
},
+178
View File
@@ -0,0 +1,178 @@
<script setup lang="ts">
import MediaThumb from '@/components/MediaThumb.vue'
import type { UploadRow } from '@/composables/useMediaUpload'
import { formatUploadProgress } from '@/lib/imageUpload'
import { formatMediaDate, formatSize } from '@/lib/mediaFormat'
import {
ADDED_TEXT,
DUPLICATE_TITLE,
FAILED_TEXT,
FAILED_TITLE,
duplicateText,
rejectionText,
rejectionTitle,
type UploadHeader,
} from '@/lib/mediaUploadRows'
const BUTTON =
'h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 max-md:flex-1 md:h-7'
const PERCENT = 100
defineProps<{
rows: UploadRow[]
header: UploadHeader
}>()
const emit = defineEmits<{
'use-existing': [key: number]
'upload-anyway': [key: number]
retry: [key: number]
'cancel-row': [key: number]
'cancel-all': []
dismiss: []
}>()
function thumbItem(row: UploadRow) {
return {
url: row.previewUrl ?? '',
content_type: row.file.type,
sha256: String(row.tone),
}
}
function percent(row: UploadRow) {
return row.total > 0 ? Math.round((row.loaded / row.total) * PERCENT) : 0
}
</script>
<template>
<section
data-testid="media-upload-panel"
aria-label="Uploads"
class="flex flex-col border border-default bg-default"
>
<div class="flex items-center gap-3 px-3.5 py-3">
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span data-testid="upload-title" class="font-semibold text-highlighted">{{
header.title
}}</span>
<span data-testid="upload-sub" class="text-muted">{{ header.sub }}</span>
</div>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-3 text-xs text-highlighted ring-0 md:h-7"
@click="header.phase === 'working' ? emit('cancel-all') : emit('dismiss')"
>
{{ header.phase === 'working' ? 'Cancel all' : 'Dismiss' }}
</UButton>
</div>
<div
v-for="row in rows"
:key="row.key"
data-testid="upload-row"
:data-status="row.status"
class="flex items-start gap-3 border-t border-muted px-3.5 py-3"
>
<MediaThumb :item="thumbItem(row)" size="sm" class="size-10 border border-default" />
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
<div class="flex justify-between gap-2">
<span class="truncate font-medium text-highlighted">{{ row.name }}</span>
<span class="whitespace-nowrap text-muted">{{ formatSize(row.size) }}</span>
</div>
<template v-if="row.status === 'waiting'">
<span class="text-muted">Waiting</span>
</template>
<template v-else-if="row.status === 'uploading'">
<span>{{ formatUploadProgress(row.loaded, row.total) }}</span>
<UProgress
:model-value="percent(row)"
size="sm"
:ui="{ base: 'rounded-none', indicator: 'rounded-none' }"
/>
</template>
<template v-else-if="row.status === 'added'">
<span class="flex items-center gap-1.5 text-success">
<UIcon name="i-lucide-circle-check" class="size-3.5 shrink-0" />
{{ ADDED_TEXT }}
</span>
</template>
<template v-else-if="row.status === 'duplicate' && row.existing">
<span class="font-semibold text-highlighted">{{ DUPLICATE_TITLE }}</span>
<span class="text-xs/5 text-toned">{{
duplicateText(row.existing, formatMediaDate)
}}</span>
<div class="flex flex-wrap gap-2">
<UButton
color="neutral"
variant="outline"
:class="BUTTON"
@click="emit('use-existing', row.key)"
>
Use existing
</UButton>
<UButton
color="neutral"
variant="outline"
:class="BUTTON"
@click="emit('upload-anyway', row.key)"
>
Upload anyway
</UButton>
</div>
</template>
<template v-else-if="row.status === 'rejected' && row.rejection">
<span class="flex items-center gap-1.5 font-semibold text-error">
<UIcon name="i-lucide-file-x" class="size-3.5 shrink-0" />
{{ rejectionTitle(row.rejection, row.name, row.size) }}
</span>
<span class="text-xs/5 text-toned">{{ rejectionText(row.rejection) }}</span>
</template>
<template v-else-if="row.status === 'failed'">
<span class="flex items-center gap-1.5 font-semibold text-error">
<UIcon name="i-lucide-circle-x" class="size-3.5 shrink-0" />
{{ FAILED_TITLE }}
</span>
<span class="text-xs/5 text-toned">{{ FAILED_TEXT }}</span>
<div class="flex">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
:class="BUTTON"
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('retry', row.key)"
>
Try again
</UButton>
</div>
</template>
</div>
<UButton
v-if="row.status === 'uploading'"
color="neutral"
variant="ghost"
icon="i-lucide-x"
:aria-label="`Cancel upload of ${row.name}`"
class="size-6 justify-center text-muted"
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('cancel-row', row.key)"
/>
</div>
<div
v-if="header.summary"
data-testid="upload-summary"
class="border-t border-default bg-elevated px-3.5 py-3 font-medium text-toned"
>
{{ header.summary }}
</div>
</section>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem } from '@/composables/useMedia'
import { mergeArticleCount } from '@/lib/mediaDuplicates'
import { pluralArticles } from '@/lib/mediaFormat'
const props = defineProps<{
keep: MediaItem
copies: MediaItem[]
action: () => Promise<void | boolean>
}>()
const emit = defineEmits<{ close: [confirmed: boolean] }>()
const otherCount = computed(() => props.copies.length - 1)
const others = computed(() => (otherCount.value === 1 ? 'copy' : `${otherCount.value} copies`))
const articles = computed(() => mergeArticleCount(props.copies, props.keep.id))
const description = computed(
() =>
`Articles that use the other ${others.value} will be switched to ${props.keep.filename}, and the other ${others.value} will be deleted. ${pluralArticles(articles.value)} will be updated. This can't be undone.`,
)
</script>
<template>
<ConfirmDialog
:title="`Merge into ${keep.filename}?`"
:description="description"
:confirm-label="`Merge ${otherCount === 1 ? '1 copy' : `${otherCount} copies`}`"
cancel-label="Cancel"
pending-label="Merging…"
tone="danger"
:action="action"
@close="emit('close', $event)"
>
<template #body>
<div class="flex items-center gap-3 border border-default px-3 py-2.5">
<MediaThumb :item="keep" size="sm" class="h-10.5 w-14 border border-default" />
<div class="flex min-w-0 flex-col">
<span class="truncate font-medium text-highlighted">{{ keep.filename }}</span>
<span class="text-xs/5 text-muted"
>Kept · used in {{ pluralArticles(keep.used_in) }} now</span
>
</div>
</div>
</template>
</ConfirmDialog>
</template>
+23 -15
View File
@@ -79,6 +79,27 @@ if (typeof window !== 'undefined') {
})
}
export function redirectToLogin() {
const currentPath = router.currentRoute.value.fullPath
useSessionStore().clear()
authRedirectPending.value = true
router
.push({
path: '/login',
query: { redirect: currentPath, reason: 'expired' },
})
.finally(() => {
authRedirectPending.value = false
})
}
export function redirectUnverified(onForbidden: OnForbidden = 'redirect') {
const session = useSessionStore()
if (onForbidden !== 'passthrough' && session.session?.verified === false) {
router.push('/register/pending')
}
}
export function useApi() {
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { onUnauthorized = 'redirect', onForbidden = 'redirect', ...init } = options
@@ -95,25 +116,12 @@ export function useApi() {
if (!response.ok) {
if (response.status === 401 && onUnauthorized === 'redirect') {
const currentPath = router.currentRoute.value.fullPath
useSessionStore().clear()
authRedirectPending.value = true
router
.push({
path: '/login',
query: { redirect: currentPath, reason: 'expired' },
})
.finally(() => {
authRedirectPending.value = false
})
redirectToLogin()
throw new ApiError(401)
}
if (response.status === 403) {
const session = useSessionStore()
if (onForbidden !== 'passthrough' && session.session?.verified === false) {
router.push('/register/pending')
}
redirectUnverified(onForbidden)
throw new ApiError(403)
}
+48
View File
@@ -30,6 +30,17 @@ export interface MediaUsageItem {
published: boolean
}
export interface MediaMergeResult {
articles_updated: number
merged: number
}
export interface BulkDeleteResult {
deleted: MediaItem[]
failed: MediaItem[]
handled: boolean
}
interface MediaListResponse {
items: MediaItem[]
next_cursor: string | null
@@ -142,6 +153,40 @@ export function useMedia() {
removeLocal(id)
}
async function deleteMany(targets: MediaItem[]): Promise<BulkDeleteResult> {
const outcomes = await Promise.all(
targets.map(async (item) => {
try {
await request<void>(`/v1/media/${item.id}`, { method: 'DELETE' })
return { item, outcome: 'deleted' as const }
} catch (error) {
const failure = classifyMediaError(error)
return {
item,
outcome: failure === 'not-found' ? ('deleted' as const) : failure,
}
}
}),
)
const pick = (outcome: string) =>
outcomes.filter((entry) => entry.outcome === outcome).map((entry) => entry.item)
const deleted = pick('deleted')
items.value = items.value.filter((entry) => !deleted.some((gone) => gone.id === entry.id))
return { deleted, failed: pick('failed'), handled: pick('handled').length > 0 }
}
function mergeCopies(keepId: number, mergeIds: number[]) {
return request<MediaMergeResult>('/v1/media/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep_id: keepId, merge_ids: mergeIds }),
})
}
function removeLocalMany(ids: number[]) {
items.value = items.value.filter((entry) => !ids.includes(entry.id))
}
function copyUrl(item: MediaItem) {
return navigator.clipboard.writeText(`${window.location.origin}${item.url}`)
}
@@ -159,6 +204,9 @@ export function useMedia() {
renameItem,
deleteItem,
removeLocal,
removeLocalMany,
deleteMany,
mergeCopies,
copyUrl,
}
}
+67
View File
@@ -0,0 +1,67 @@
import { computed, ref, watch, type Ref } from 'vue'
import type { MediaItem } from '@/composables/useMedia'
export type SelectAllState = 'none' | 'partial' | 'all'
export function useMediaSelection(items: Ref<MediaItem[]>) {
const selectedIds = ref<number[]>([])
const count = computed(() => selectedIds.value.length)
const selectedItems = computed(() =>
items.value.filter((item) => selectedIds.value.includes(item.id)),
)
const allState = computed<SelectAllState>(() => {
if (count.value === 0) {
return 'none'
}
return count.value >= items.value.length ? 'all' : 'partial'
})
function isSelected(id: number) {
return selectedIds.value.includes(id)
}
function select(id: number) {
if (!isSelected(id)) {
selectedIds.value = [...selectedIds.value, id]
}
}
function toggle(id: number) {
selectedIds.value = isSelected(id)
? selectedIds.value.filter((entry) => entry !== id)
: [...selectedIds.value, id]
}
function replace(ids: number[]) {
selectedIds.value = ids
}
function clear() {
selectedIds.value = []
}
function toggleAll() {
selectedIds.value = allState.value === 'all' ? [] : items.value.map((item) => item.id)
}
watch(items, (loaded) => {
const kept = selectedIds.value.filter((id) => loaded.some((item) => item.id === id))
if (kept.length !== selectedIds.value.length) {
selectedIds.value = kept
}
})
return {
selectedIds,
selectedItems,
count,
allState,
isSelected,
select,
toggle,
replace,
clear,
toggleAll,
}
}
+307
View File
@@ -0,0 +1,307 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { redirectToLogin, redirectUnverified, useApi } from '@/composables/useApi'
import { classifyMediaError, type MediaItem } from '@/composables/useMedia'
import { useRetryAfter } from '@/composables/useRetryAfter'
import { hashFile } from '@/lib/fileHash'
import { extensionMatchesType, validateImageFile } from '@/lib/imageUpload'
import { describeUpload, parseRetryAfter } from '@/lib/mediaUploadRows'
import type { UploadRejection, UploadRowStatus } from '@/lib/mediaUploadRows'
const MAX_CONCURRENT_UPLOADS = 2
const UPLOAD_URL = '/v1/media/'
const HTTP_CREATED = 201
const HTTP_BAD_REQUEST = 400
const HTTP_FORBIDDEN = 403
const HTTP_UNAUTHORIZED = 401
const HTTP_TOO_LARGE = 413
const HTTP_UNSUPPORTED = 415
const HTTP_RATE_LIMITED = 429
const TONE_COUNT = 4
export interface UploadRow {
key: number
file: File
name: string
size: number
status: UploadRowStatus
loaded: number
total: number
rejection: UploadRejection | null
existing: MediaItem | null
previewUrl: string | null
skipLookup: boolean
tone: number
}
interface UploadOptions {
onAdded?: () => void
}
function toneOf(name: string) {
return Array.from(name).reduce((sum, letter) => sum + letter.charCodeAt(0), 0) % TONE_COUNT
}
function oldestOf(items: MediaItem[]) {
return [...items].sort(
(first, second) =>
Date.parse(first.created_at) - Date.parse(second.created_at) || first.id - second.id,
)[0]
}
function mismatchRejection(file: File): UploadRejection | null {
return extensionMatchesType(file) ? null : 'type'
}
function createPreview(file: File) {
return typeof URL.createObjectURL === 'function' ? URL.createObjectURL(file) : null
}
function releasePreview(row: UploadRow) {
if (row.previewUrl && typeof URL.revokeObjectURL === 'function') {
URL.revokeObjectURL(row.previewUrl)
}
}
export function useMediaUpload(options: UploadOptions = {}) {
const { request } = useApi()
const retry = useRetryAfter()
const rows = ref<UploadRow[]>([])
const slots = new Set<number>()
const requests = new Map<number, XMLHttpRequest>()
let nextKey = 1
let halted = false
const header = computed(() => describeUpload(rows.value))
const limited = computed(() => retry.active.value)
const countdown = computed(() => retry.formatted.value)
function createRow(file: File): UploadRow {
const verdict = validateImageFile(file)
const rejection = verdict.ok ? mismatchRejection(file) : verdict.kind
return {
key: nextKey++,
file,
name: file.name,
size: file.size,
status: rejection ? 'rejected' : 'waiting',
loaded: 0,
total: file.size,
rejection,
existing: null,
previewUrl: rejection ? null : createPreview(file),
skipLookup: false,
tone: toneOf(file.name),
}
}
function find(key: number) {
return rows.value.find((row) => row.key === key)
}
function release(key: number) {
slots.delete(key)
requests.delete(key)
}
function settle(key: number) {
release(key)
pump()
}
function halt(row: UploadRow) {
row.status = 'waiting'
release(row.key)
halted = true
}
function pump() {
if (halted || limited.value) {
return
}
for (const row of rows.value) {
if (slots.size >= MAX_CONCURRENT_UPLOADS) {
return
}
if (row.status === 'waiting' && !slots.has(row.key)) {
slots.add(row.key)
void process(row)
}
}
}
async function findExisting(file: File): Promise<MediaItem | null | 'halted'> {
const hash = await hashFile(file)
if (hash === null) {
return null
}
try {
const response = await request<{ items: MediaItem[] }>(`/v1/media/?hash=${hash}`)
return oldestOf(response.items) ?? null
} catch (error) {
return classifyMediaError(error) === 'handled' ? 'halted' : null
}
}
async function process(row: UploadRow) {
if (!row.skipLookup) {
const match = await findExisting(row.file)
if (!find(row.key)) {
settle(row.key)
return
}
if (match === 'halted') {
halt(row)
return
}
if (match) {
row.status = 'duplicate'
row.existing = match
settle(row.key)
return
}
}
send(row)
}
function reject(row: UploadRow, kind: UploadRejection) {
row.status = 'rejected'
row.rejection = kind
settle(row.key)
}
function pause(row: UploadRow, header: string | null) {
row.status = 'waiting'
release(row.key)
retry.start(parseRetryAfter(header))
}
function onLoaded(row: UploadRow, xhr: XMLHttpRequest) {
requests.delete(row.key)
switch (xhr.status) {
case HTTP_CREATED:
row.status = 'added'
options.onAdded?.()
settle(row.key)
return
case HTTP_BAD_REQUEST:
reject(row, 'unreadable')
return
case HTTP_TOO_LARGE:
reject(row, 'size')
return
case HTTP_UNSUPPORTED:
reject(row, 'type')
return
case HTTP_UNAUTHORIZED:
redirectToLogin()
halt(row)
return
case HTTP_FORBIDDEN:
redirectUnverified()
halt(row)
return
case HTTP_RATE_LIMITED:
pause(row, xhr.getResponseHeader('Retry-After'))
return
default:
row.status = 'failed'
settle(row.key)
}
}
function send(row: UploadRow) {
row.status = 'uploading'
row.loaded = 0
row.total = row.size
const xhr = new XMLHttpRequest()
requests.set(row.key, xhr)
xhr.open('POST', UPLOAD_URL)
xhr.withCredentials = true
xhr.upload.addEventListener('progress', (event) => {
row.loaded = event.loaded
row.total = event.total || row.size
})
xhr.addEventListener('load', () => onLoaded(row, xhr))
xhr.addEventListener('error', () => {
row.status = 'failed'
settle(row.key)
})
const form = new FormData()
form.append('file', row.file)
xhr.send(form)
}
function addFiles(files: File[]) {
if (limited.value || files.length === 0) {
return
}
rows.value.push(...files.map(createRow))
pump()
}
function remove(key: number) {
const row = find(key)
if (row) {
releasePreview(row)
}
rows.value = rows.value.filter((entry) => entry.key !== key)
}
function cancel(key: number) {
requests.get(key)?.abort()
release(key)
remove(key)
pump()
}
function cancelAll() {
for (const row of [...rows.value]) {
if (row.status === 'uploading' || row.status === 'waiting') {
requests.get(row.key)?.abort()
release(row.key)
remove(row.key)
}
}
}
function retryRow(key: number) {
const row = find(key)
if (!row || (row.status !== 'failed' && row.status !== 'duplicate')) {
return
}
row.status = 'waiting'
row.skipLookup = true
row.existing = null
pump()
}
function dismiss() {
rows.value.forEach(releasePreview)
rows.value = []
}
watch(limited, (active) => {
if (!active) {
pump()
}
})
onBeforeUnmount(() => {
requests.forEach((xhr) => xhr.abort())
requests.clear()
rows.value.forEach(releasePreview)
})
return {
rows,
header,
limited,
countdown,
addFiles,
remove,
cancel,
cancelAll,
retryRow,
dismiss,
}
}
+3
View File
@@ -0,0 +1,3 @@
import { ref } from 'vue'
export const toastsAboveBulkBar = ref(false)
+48
View File
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { hashFile } from '../fileHash'
const SHA256_OF_ABC = 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'
function fileOf(bytes: number[]) {
return new File([new Uint8Array(bytes)], 'a.png', { type: 'image/png' })
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('hashFile', () => {
it('resolves the lowercase 64-character hex of the digest', async () => {
const digest = vi.fn(async (_algorithm: string, data: ArrayBuffer) => {
expect(Array.from(new Uint8Array(data))).toEqual([97, 98, 99])
return new Uint8Array(
SHA256_OF_ABC.match(/../g)!.map((pair) => Number.parseInt(pair, 16)),
).buffer
})
vi.stubGlobal('crypto', { subtle: { digest } })
const hex = await hashFile(fileOf([97, 98, 99]))
expect(digest).toHaveBeenCalledWith('SHA-256', expect.anything())
expect(hex).toBe(SHA256_OF_ABC)
expect(hex).toMatch(/^[0-9a-f]{64}$/)
})
it('resolves null when crypto.subtle is unavailable', async () => {
vi.stubGlobal('crypto', { subtle: undefined })
expect(await hashFile(fileOf([1]))).toBeNull()
})
it('resolves null when the digest rejects', async () => {
vi.stubGlobal('crypto', { subtle: { digest: vi.fn().mockRejectedValue(new Error('no')) } })
expect(await hashFile(fileOf([1]))).toBeNull()
})
it('imports nothing from the Media page', async () => {
const source = (await import('../fileHash?raw')).default
expect(source).not.toMatch(/import .*Media/)
expect(source).not.toMatch(/from '@\/(components|views|composables)/)
})
})
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { extensionMatchesType, validateImageFile } from '../imageUpload'
function fileOf(name: string, type: string, size = 100) {
const file = new File(['x'], name, { type })
Object.defineProperty(file, 'size', { value: size })
return file
}
describe('validateImageFile', () => {
it('checks the size first, 10 MB allowed and one byte more too large', () => {
expect(validateImageFile(fileOf('a.png', 'image/png', 10485760))).toEqual({ ok: true })
expect(validateImageFile(fileOf('a.png', 'image/png', 10485761))).toEqual({
ok: false,
kind: 'size',
})
expect(validateImageFile(fileOf('a.tif', 'image/tiff', 10485761))).toEqual({
ok: false,
kind: 'size',
})
})
it('needs an allowed MIME type and an allowed extension', () => {
expect(validateImageFile(fileOf('a.gif', 'image/gif'))).toEqual({ ok: false, kind: 'type' })
expect(validateImageFile(fileOf('a.jpg', 'text/plain'))).toEqual({
ok: false,
kind: 'type',
})
})
})
describe('extensionMatchesType', () => {
it.each([
['a.png', 'image/png', true],
['a.JPG', 'image/jpeg', true],
['a.jpeg', 'image/jpeg', true],
['a.webp', 'image/webp', true],
['a.svg', 'image/svg+xml', true],
['pic.jpg', 'image/png', false],
['a.svg', 'image/jpeg', false],
['a.tif', 'image/tiff', false],
])('%s as %s is %s', (name, type, expected) => {
expect(extensionMatchesType(fileOf(name, type))).toBe(expected)
})
})
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import type { MediaItem } from '@/composables/useMedia'
import { groupDuplicates, mergeArticleCount } from '../mediaDuplicates'
function copy(id: number, usedIn: number, createdAt: string, overrides: Partial<MediaItem> = {}) {
return {
id,
filename: `copy-${id}.jpg`,
url: `/media/copy-${id}.jpg`,
size: 2516582,
content_type: 'image/jpeg',
width: 3600,
height: 2400,
sha256: 'aa',
uploaded_by: 'Inès Duarte',
created_at: createdAt,
used_in: usedIn,
duplicate: true,
...overrides,
} satisfies MediaItem
}
describe('groupDuplicates', () => {
it.each([
[
'the most used copy',
[
copy(1, 4, '2026-06-14T12:00:00Z'),
copy(2, 3, '2026-09-02T12:00:00Z'),
copy(3, 2, '2026-08-30T12:00:00Z'),
],
1,
],
[
'the oldest copy at equal use',
[copy(1, 2, '2026-08-29T12:00:00Z'), copy(2, 2, '2026-08-21T12:00:00Z')],
2,
],
[
'the oldest unused copy',
[copy(1, 0, '2026-09-03T12:00:00Z'), copy(2, 0, '2026-09-01T12:00:00Z')],
2,
],
[
'the lowest id at equal use and date',
[copy(9, 1, '2026-09-03T12:00:00Z'), copy(4, 1, '2026-09-03T12:00:00Z')],
4,
],
])('suggests %s', (_name, copies, suggestedId) => {
const [group] = groupDuplicates(copies)
expect(group!.suggested.id).toBe(suggestedId)
expect(group!.copies[0]!.id).toBe(suggestedId)
})
it('groups by sha256, orders copies and puts the oldest suggested copy first', () => {
const groups = groupDuplicates([
copy(7, 2, '2026-08-29T12:00:00Z', { sha256: 'bb', size: 3250585 }),
copy(5, 3, '2026-09-02T12:00:00Z'),
copy(9, 1, '2026-08-21T12:00:00Z', { sha256: 'bb', size: 3250585 }),
copy(12, 4, '2026-06-14T12:00:00Z'),
copy(6, 2, '2026-08-30T12:00:00Z'),
])
expect(groups.map((group) => group.copies.map((item) => item.id))).toEqual([
[12, 5, 6],
[7, 9],
])
expect(groups.map((group) => group.header)).toEqual([
'3 identical images · 2.4 MB each · 3600 × 2400',
'2 identical images · 3.1 MB each · 3600 × 2400',
])
})
it('reads Vector for SVG groups and ignores single copies', () => {
const groups = groupDuplicates([
copy(1, 0, '2026-09-01T12:00:00Z', { width: null, height: null, size: 8192 }),
copy(2, 0, '2026-09-02T12:00:00Z', { width: null, height: null, size: 8192 }),
copy(3, 0, '2026-09-02T12:00:00Z', { sha256: 'zz' }),
])
expect(groups).toHaveLength(1)
expect(groups[0]!.header).toBe('2 identical images · 8 KB each · Vector')
})
})
describe('mergeArticleCount', () => {
it('sums the use of every copy except the kept one', () => {
const copies = [
copy(12, 4, '2026-06-14T12:00:00Z'),
copy(5, 3, '2026-09-02T12:00:00Z'),
copy(6, 2, '2026-08-30T12:00:00Z'),
]
expect(mergeArticleCount(copies, 12)).toBe(5)
expect(mergeArticleCount(copies, 6)).toBe(7)
})
})
+2 -1
View File
@@ -14,7 +14,7 @@ describe('parseMediaControls', () => {
[{ q: 'wall' }, { q: 'wall' }],
[{ view: 'list' }, { view: 'list' }],
[{ filter: 'bogus', sort: 'zzz' }, {}],
[{ filter: 'duplicates' }, {}],
[{ filter: 'duplicates' }, { filter: 'duplicates' }],
[{ view: 'grid' }, {}],
[{ sort: 'newest' }, {}],
])('maps %j', (query, expected) => {
@@ -42,6 +42,7 @@ describe('toRequestQuery', () => {
it.each([
[{}, 'filter=all&sort=newest&limit=100'],
[{ filter: 'unused' }, 'filter=unused&sort=newest&limit=100'],
[{ filter: 'duplicates' }, 'filter=duplicates&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'],
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import {
describeUpload,
parseRetryAfter,
rejectionText,
rejectionTitle,
summaryLine,
type UploadRowStatus,
} from '../mediaUploadRows'
function rows(...statuses: UploadRowStatus[]) {
return statuses.map((status) => ({ status }))
}
describe('summaryLine', () => {
it.each([
[2, 1, 2, '2 uploaded · 1 already in your library · 2 need attention'],
[3, 0, 0, '3 uploaded'],
[0, 1, 0, '1 already in your library'],
[1, 0, 1, '1 uploaded · 1 need attention'],
])('%i added, %i duplicate, %i attention reads "%s"', (added, duplicate, attention, line) => {
expect(summaryLine({ added, duplicate, attention })).toBe(line)
})
})
describe('describeUpload', () => {
it('counts settled rows while working', () => {
const header = describeUpload(
rows('added', 'duplicate', 'rejected', 'failed', 'uploading', 'waiting'),
)
expect(header).toEqual({
phase: 'working',
title: 'Uploading 6 images',
sub: '4 of 6 done',
summary: null,
})
})
it('uses singular forms', () => {
expect(describeUpload(rows('uploading')).title).toBe('Uploading 1 image')
expect(describeUpload(rows('added')).sub).toBe('1 image')
})
it('settles with a summary', () => {
const header = describeUpload(rows('added', 'added', 'duplicate', 'rejected', 'failed'))
expect(header).toEqual({
phase: 'settled',
title: 'Upload finished',
sub: '5 images',
summary: '2 uploaded · 1 already in your library · 2 need attention',
})
})
it('reads Nothing was uploaded without a summary when nothing was added', () => {
expect(describeUpload(rows('rejected', 'rejected', 'rejected'))).toEqual({
phase: 'settled',
title: 'Nothing was uploaded',
sub: '3 files were rejected',
summary: null,
})
expect(describeUpload(rows('rejected', 'failed'))).toMatchObject({
title: 'Nothing was uploaded',
sub: '2 files need attention',
summary: null,
})
expect(describeUpload(rows('failed'))).toMatchObject({ sub: '1 file needs attention' })
expect(describeUpload(rows('rejected'))).toMatchObject({ sub: '1 file was rejected' })
})
})
describe('rejection copy', () => {
it.each([
['size', 'scan.tif', 14.2 * 1024 * 1024, 'scan.tif is 14.2 MB'],
['size', 'photo.png', 10.1 * 1024 * 1024, 'photo.png is 10.1 MB'],
['type', 'notes.pdf', 215040, "notes.pdf isn't an image"],
['unreadable', 'broken.png', 3145728, "broken.png couldn't be read"],
] as const)('titles a %s rejection of %s', (kind, name, bytes, title) => {
expect(rejectionTitle(kind, name, bytes)).toBe(title)
})
it('picks the text per kind', () => {
expect(rejectionText('size')).toBe(
'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.',
)
expect(rejectionText('type')).toBe(rejectionText('size'))
expect(rejectionText('unreadable')).toBe(
"The server couldn't read this image. Save it again as a PNG or JPEG, then try again.",
)
})
})
describe('parseRetryAfter', () => {
it('reads whole seconds and falls back to 60', () => {
expect(parseRetryAfter('42')).toBe(42)
expect(parseRetryAfter(null)).toBe(60)
expect(parseRetryAfter('soon')).toBe(60)
expect(parseRetryAfter('')).toBe(60)
})
})
+33
View File
@@ -0,0 +1,33 @@
const HASH_ALGORITHM = 'SHA-256'
const HEX_RADIX = 16
const HEX_PAIR_WIDTH = 2
function readBytes(file: File): Promise<ArrayBuffer> {
if (typeof file.arrayBuffer === 'function') {
return file.arrayBuffer()
}
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as ArrayBuffer)
reader.onerror = () => reject(reader.error)
reader.readAsArrayBuffer(file)
})
}
function toHex(digest: ArrayBuffer) {
return Array.from(new Uint8Array(digest), (byte) =>
byte.toString(HEX_RADIX).padStart(HEX_PAIR_WIDTH, '0'),
).join('')
}
export async function hashFile(file: File): Promise<string | null> {
const subtle = globalThis.crypto?.subtle
if (!subtle) {
return null
}
try {
return toHex(await subtle.digest(HASH_ALGORITHM, await readBytes(file)))
} catch {
return null
}
}
+11
View File
@@ -27,6 +27,17 @@ export function validateImageFile(file: File): ImageValidationResult {
return { ok: true }
}
const EXTENSIONS_BY_MIME_TYPE: Record<string, string[]> = {
'image/png': ['.png'],
'image/jpeg': ['.jpg', '.jpeg'],
'image/webp': ['.webp'],
'image/svg+xml': ['.svg'],
}
export function extensionMatchesType(file: File) {
return EXTENSIONS_BY_MIME_TYPE[file.type]?.includes(fileExtension(file.name)) ?? false
}
export function formatMegabytes(bytes: number) {
return (bytes / (1024 * 1024)).toFixed(1)
}
+42
View File
@@ -0,0 +1,42 @@
import type { MediaItem } from '@/composables/useMedia'
import { formatDimensions, formatSize } from '@/lib/mediaFormat'
export interface DuplicateGroup {
hash: string
copies: MediaItem[]
suggested: MediaItem
header: string
}
function byAge(first: MediaItem, second: MediaItem) {
return Date.parse(first.created_at) - Date.parse(second.created_at) || first.id - second.id
}
function byPreference(first: MediaItem, second: MediaItem) {
return second.used_in - first.used_in || byAge(first, second)
}
export function groupHeader(copies: MediaItem[]) {
const [first] = copies
return `${copies.length} identical images · ${formatSize(first!.size)} each · ${formatDimensions(first!.width, first!.height)}`
}
export function groupDuplicates(items: MediaItem[]): DuplicateGroup[] {
const byHash = new Map<string, MediaItem[]>()
for (const item of items) {
byHash.set(item.sha256, [...(byHash.get(item.sha256) ?? []), item])
}
return [...byHash.entries()]
.filter(([, copies]) => copies.length > 1)
.map(([hash, copies]) => {
const sorted = [...copies].sort(byPreference)
return { hash, copies: sorted, suggested: sorted[0]!, header: groupHeader(sorted) }
})
.sort((first, second) => byAge(first.suggested, second.suggested))
}
export function mergeArticleCount(copies: MediaItem[], keepId: number) {
return copies
.filter((copy) => copy.id !== keepId)
.reduce((total, copy) => total + copy.used_in, 0)
}
+7
View File
@@ -69,3 +69,10 @@ export function isValidFilenameStem(value: string) {
LETTER_OR_NUMBER.test(stem)
)
}
export function joinNames(names: string[]) {
if (names.length <= 1) {
return names.join('')
}
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
}
+2 -2
View File
@@ -1,6 +1,6 @@
import type { LocationQuery } from 'vue-router'
export type MediaFilter = 'all' | 'unused'
export type MediaFilter = 'all' | 'unused' | 'duplicates'
export type MediaSort = 'newest' | 'oldest' | 'name' | 'largest'
export type MediaView = 'grid' | 'list'
@@ -20,7 +20,7 @@ export const DEFAULT_CONTROLS: MediaControls = {
view: 'grid',
}
const FILTERS: MediaFilter[] = ['all', 'unused']
const FILTERS: MediaFilter[] = ['all', 'unused', 'duplicates']
const SORTS: MediaSort[] = ['newest', 'oldest', 'name', 'largest']
const VIEWS: MediaView[] = ['grid', 'list']
+118
View File
@@ -0,0 +1,118 @@
import { formatMegabytes } from '@/lib/imageUpload'
export type UploadRowStatus =
| 'waiting'
| 'uploading'
| 'added'
| 'duplicate'
| 'rejected'
| 'failed'
export type UploadRejection = 'size' | 'type' | 'unreadable'
export type UploadPhase = 'working' | 'settled'
export interface UploadRowLike {
status: UploadRowStatus
}
export interface UploadHeader {
phase: UploadPhase
title: string
sub: string
summary: string | null
}
export const REJECTION_TEXT = 'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.'
export const UNREADABLE_TEXT =
"The server couldn't read this image. Save it again as a PNG or JPEG, then try again."
export const FAILED_TITLE = 'Upload failed'
export const FAILED_TEXT = "The server didn't accept the file. Nothing was added to your library."
export const ADDED_TEXT = 'Added to your library'
export const DUPLICATE_TITLE = 'Already in your library'
export const DROP_TITLE = 'Drop images to upload'
export const DROP_TEXT = 'PNG, JPEG, WEBP, SVG · max 10 MB each'
export const RATE_LIMITED_TITLE = 'Too many uploads'
export const RATE_LIMITED_TEXT = 'For your security, wait a moment before uploading again.'
export const DEFAULT_RETRY_AFTER_SECONDS = 60
export const UPLOAD_ACCEPT = 'image/png,image/jpeg,image/webp,image/svg+xml'
const SETTLED: UploadRowStatus[] = ['added', 'duplicate', 'rejected', 'failed']
export function isSettled(status: UploadRowStatus) {
return SETTLED.includes(status)
}
function imageCount(total: number) {
return `${total} ${total === 1 ? 'image' : 'images'}`
}
function fileCount(total: number) {
return `${total} ${total === 1 ? 'file' : 'files'}`
}
export function rejectionTitle(kind: UploadRejection, filename: string, bytes: number) {
if (kind === 'size') {
return `${filename} is ${formatMegabytes(bytes)} MB`
}
if (kind === 'unreadable') {
return `${filename} couldn't be read`
}
return `${filename} isn't an image`
}
export function rejectionText(kind: UploadRejection) {
return kind === 'unreadable' ? UNREADABLE_TEXT : REJECTION_TEXT
}
export function duplicateText(
existing: { filename: string; created_at: string; uploaded_by: string },
formatDate: (iso: string) => string,
) {
const by = existing.uploaded_by ? ` by ${existing.uploaded_by}` : ''
return `Same as "${existing.filename}", uploaded ${formatDate(existing.created_at)}${by}.`
}
export function summaryLine(counts: { added: number; duplicate: number; attention: number }) {
const parts = [
...(counts.added > 0 ? [`${counts.added} uploaded`] : []),
...(counts.duplicate > 0 ? [`${counts.duplicate} already in your library`] : []),
...(counts.attention > 0 ? [`${counts.attention} need attention`] : []),
]
return parts.join(' · ')
}
export function describeUpload(rows: UploadRowLike[]): UploadHeader {
const total = rows.length
const count = (status: UploadRowStatus) => rows.filter((row) => row.status === status).length
const settled = rows.filter((row) => isSettled(row.status)).length
if (settled < total) {
return {
phase: 'working',
title: `Uploading ${imageCount(total)}`,
sub: `${settled} of ${total} done`,
summary: null,
}
}
const added = count('added')
const duplicate = count('duplicate')
const attention = count('rejected') + count('failed')
if (added === 0 && duplicate === 0) {
const sub =
count('rejected') === total
? `${fileCount(total)} ${total === 1 ? 'was' : 'were'} rejected`
: `${fileCount(total)} ${total === 1 ? 'needs' : 'need'} attention`
return { phase: 'settled', title: 'Nothing was uploaded', sub, summary: null }
}
return {
phase: 'settled',
title: 'Upload finished',
sub: imageCount(total),
summary: summaryLine({ added, duplicate, attention }),
}
}
export function parseRetryAfter(header: string | null) {
if (header === null || !/^\d+$/.test(header.trim())) {
return DEFAULT_RETRY_AFTER_SECONDS
}
return Math.max(1, Number.parseInt(header, 10))
}
+537 -36
View File
@@ -1,40 +1,68 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMediaQuery, useOnline } from '@vueuse/core'
import { useEventListener, useMediaQuery, useOnline } from '@vueuse/core'
import { useToast } from '@nuxt/ui/composables'
import BulkDeleteMediaDialog from '@/components/BulkDeleteMediaDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import DeleteMediaDialog from '@/components/DeleteMediaDialog.vue'
import DuplicateGroups from '@/components/DuplicateGroups.vue'
import MediaBulkBar from '@/components/MediaBulkBar.vue'
import MediaCardList from '@/components/MediaCardList.vue'
import MediaDetailPanel from '@/components/MediaDetailPanel.vue'
import MediaDropArea from '@/components/MediaDropArea.vue'
import MediaEmptyState from '@/components/MediaEmptyState.vue'
import MediaGrid from '@/components/MediaGrid.vue'
import MediaSelectAllRow from '@/components/MediaSelectAllRow.vue'
import MediaSkeletonGrid from '@/components/MediaSkeletonGrid.vue'
import MediaTable from '@/components/MediaTable.vue'
import MediaToolbar from '@/components/MediaToolbar.vue'
import MediaUploadPanel from '@/components/MediaUploadPanel.vue'
import MergeMediaDialog from '@/components/MergeMediaDialog.vue'
import RenameMediaDialog from '@/components/RenameMediaDialog.vue'
import { ApiError } from '@/composables/useApi'
import {
classifyMediaError,
useMedia,
type BulkDeleteResult,
type MediaItem,
type MediaUsageItem,
} from '@/composables/useMedia'
import { useMediaSelection } from '@/composables/useMediaSelection'
import { useMediaUpload } from '@/composables/useMediaUpload'
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
import { groupDuplicates, type DuplicateGroup } from '@/lib/mediaDuplicates'
import { joinNames } from '@/lib/mediaFormat'
import {
parseMediaControls,
toRequestQuery,
toRouteQuery,
type MediaControls,
} from '@/lib/mediaQuery'
import { RATE_LIMITED_TEXT, RATE_LIMITED_TITLE, UPLOAD_ACCEPT } from '@/lib/mediaUploadRows'
const DESKTOP_QUERY = '(min-width: 48rem)'
const TOAST_DURATION_MS = 5000
const PERSISTENT_TOAST = 0
const BULK_FAILED_TOAST_ID = 'media-bulk-delete-failed'
const RETRY_BUTTON =
'h-11 w-full justify-center border border-accented bg-default text-xs text-highlighted ring-0 md:h-7'
const DESKTOP_SKELETON_CARDS = 10
const MOBILE_SKELETON_CARDS = 6
const DESKTOP_COLUMNS = 5
const MOBILE_COLUMNS = 2
const HTTP_CONFLICT = 409
const FOCUS_DELAY_MS = 0
const OVERLAY_SELECTOR = '[role="dialog"],[role="alertdialog"],[role="menu"],[role="listbox"]'
const SELECTION_CLEARED = 'Selection cleared'
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 BULK_DELETE_FAILED_COPY =
"Couldn't delete the images, so they're still there. Try again in a moment."
const MERGE_FAILED_COPY = "Couldn't merge the copies. Nothing was changed. Try again in a moment."
const MERGE_CONFLICT_COPY =
'One of these images changed, so nothing was merged. The list is up to date now. Check it and try again.'
const OFFLINE_COPY =
"You're offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection."
@@ -42,6 +70,8 @@ const route = useRoute()
const router = useRouter()
const toast = useToast()
const media = useMedia()
const selection = useMediaSelection(media.items)
const upload = useMediaUpload({ onAdded: refreshInBackground })
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const online = useOnline()
@@ -51,14 +81,23 @@ const offline = computed(() => !online.value)
const hasLoaded = ref(false)
const heading = ref<HTMLElement | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const activeId = ref<number | null>(null)
const panelOpen = ref(false)
const renameOpen = ref(false)
const deleteOpen = ref(false)
const bulkDeleteOpen = ref(false)
const bulkTargets = ref<MediaItem[]>([])
const mergeTarget = ref<{ group: DuplicateGroup; keep: MediaItem } | null>(null)
const dragDepth = ref(0)
const announcement = ref('')
const usage = ref<MediaUsageItem[]>([])
const copies = ref<MediaItem[]>([])
let opener: HTMLElement | null = null
let skipFocusRestore = false
let pendingSelectId: number | null = null
let bulkDeletedTotal = 0
let failureToastShown = false
const activeItem = computed(
() => media.items.value.find((item) => item.id === activeId.value) ?? null,
@@ -69,6 +108,8 @@ 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 isDuplicates = computed(() => controls.value.filter === 'duplicates')
const groups = computed(() => (isDuplicates.value ? groupDuplicates(media.items.value) : []))
const emptyLibrary = computed(
() =>
ready.value &&
@@ -76,23 +117,49 @@ const emptyLibrary = computed(
media.counts.value.all === 0 &&
controls.value.q === '',
)
const noResults = computed(
() => ready.value && media.items.value.length === 0 && !emptyLibrary.value,
const nothingToShow = computed(
() =>
ready.value &&
!emptyLibrary.value &&
(isDuplicates.value ? groups.value.length === 0 : media.items.value.length === 0),
)
const showList = computed(() => ready.value && media.items.value.length > 0)
const emptyVariant = computed(() =>
isDuplicates.value && controls.value.q === '' ? 'no-duplicates' : 'no-results',
)
const filterLabel = computed(
() => ({ all: 'All', unused: 'Unused', duplicates: 'Duplicates' })[controls.value.filter],
)
const showList = computed(() => ready.value && !nothingToShow.value && !emptyLibrary.value)
const dragging = computed(() => dragDepth.value > 0)
const uploadBlocked = computed(() => offline.value || upload.limited.value)
const uploadLabel = computed(() =>
upload.limited.value ? `Try again in ${upload.countdown.value}` : 'Upload',
)
const uploadIcon = computed(() => (upload.limited.value ? 'i-lucide-timer' : 'i-lucide-upload'))
const dialogOpen = computed(
() =>
panelOpen.value ||
renameOpen.value ||
deleteOpen.value ||
bulkDeleteOpen.value ||
mergeTarget.value !== null,
)
const hasSelection = computed(() => selection.count.value > 0)
const showFab = computed(
() =>
!isDesktop.value &&
!panelOpen.value &&
!renameOpen.value &&
!deleteOpen.value &&
!dialogOpen.value &&
!hasSelection.value &&
!dragging.value &&
!loadFailed.value &&
!emptyLibrary.value,
)
const imageCount = computed(() => {
const total = media.items.value.length
return `${total} ${total === 1 ? 'image' : 'images'}`
})
const gridSelected = computed(() => selection.selectedIds.value)
const showBulkBar = computed(() => hasSelection.value && !emptyLibrary.value)
watch(showBulkBar, (visible) => (toastsAboveBulkBar.value = visible), { immediate: true })
onBeforeUnmount(() => (toastsAboveBulkBar.value = false))
function canonicalizeUrl() {
const canonical = toRouteQuery(controls.value)
@@ -115,6 +182,10 @@ function clearFilters() {
updateControls({ q: '', filter: 'all' })
}
function announce(text: string) {
announcement.value = text
}
canonicalizeUrl()
watch(activeItem, (item) => {
@@ -126,12 +197,24 @@ watch(activeItem, (item) => {
watch(
requestQuery,
async () => {
selection.clear()
await media.load(controls.value)
hasLoaded.value = true
selectPending()
},
{ immediate: true },
)
watch(
() => upload.header.value,
(next, previous) => {
const settledNow = next.phase === 'settled' && previous?.phase === 'working'
if (settledNow && upload.rows.value.length > 0) {
announce(next.summary ?? `${next.title}. ${next.sub}`)
}
},
)
function refreshInBackground() {
media.load(controls.value, { silent: true })
}
@@ -150,7 +233,20 @@ function focusAfterRemoval(index: number) {
return
}
heading.value?.focus()
}, 0)
}, FOCUS_DELAY_MS)
})
}
function focusElement(selector: string) {
nextTick(() => {
setTimeout(() => {
const target = document.querySelector<HTMLElement>(selector)
if (target) {
target.focus()
return
}
heading.value?.focus()
}, FOCUS_DELAY_MS)
})
}
@@ -163,7 +259,7 @@ function restoreFocusToOpener(id: number | null) {
focusCard(id)
}
opener = null
}, 0)
}, FOCUS_DELAY_MS)
})
}
@@ -229,6 +325,11 @@ function closeEverything() {
panelOpen.value = false
}
function reviewDuplicates() {
closeEverything()
updateControls({ filter: 'duplicates' })
}
function removeAndRefocus(item: MediaItem) {
const index = media.items.value.findIndex((entry) => entry.id === item.id)
closeEverything()
@@ -237,10 +338,9 @@ function removeAndRefocus(item: MediaItem) {
refreshInBackground()
}
function handleGone(item: MediaItem) {
removeAndRefocus(item)
function notFoundToast(id: number) {
toast.add({
id: `media-not-found-${item.id}`,
id: `media-not-found-${id}`,
color: 'info',
icon: 'i-lucide-info',
title: 'That image no longer exists',
@@ -248,6 +348,11 @@ function handleGone(item: MediaItem) {
})
}
function handleGone(item: MediaItem) {
removeAndRefocus(item)
notFoundToast(item.id)
}
async function copyUrl() {
const item = activeItem.value
if (!item) {
@@ -327,6 +432,292 @@ async function deleteAction() {
})
refreshInBackground()
}
function imageCount(total: number) {
return `${total} ${total === 1 ? 'image' : 'images'}`
}
function announceSelection(filename: string, selected: boolean) {
if (!selected && selection.count.value === 0) {
announce(SELECTION_CLEARED)
return
}
announce(
`${filename} ${selected ? 'selected' : 'unselected'}, ${selection.count.value} selected`,
)
}
function toggleSelected(id: number) {
const item = media.items.value.find((entry) => entry.id === id)
if (!item) {
return
}
const selected = !selection.isSelected(id)
selection.toggle(id)
announceSelection(item.filename, selected)
}
function toggleAllSelected() {
selection.toggleAll()
announce(hasSelection.value ? `${selection.count.value} selected` : SELECTION_CLEARED)
}
function clearSelection() {
if (!hasSelection.value) {
return
}
selection.clear()
announce(SELECTION_CLEARED)
}
function selectExisting(item: MediaItem) {
selection.select(item.id)
announceSelection(item.filename, true)
}
function selectPending() {
const id = pendingSelectId
pendingSelectId = null
const item = id === null ? undefined : media.items.value.find((entry) => entry.id === id)
if (item) {
selectExisting(item)
}
}
function useExisting(key: number) {
const existing = upload.rows.value.find((row) => row.key === key)?.existing
if (!existing) {
return
}
upload.remove(key)
if (media.items.value.some((item) => item.id === existing.id)) {
selectExisting(existing)
return
}
if (controls.value.q !== '' || controls.value.filter !== 'all') {
pendingSelectId = existing.id
clearFilters()
}
}
useEventListener(
window,
'keydown',
(event: KeyboardEvent) => {
if (event.key !== 'Escape' || !hasSelection.value) {
return
}
if (document.querySelector(OVERLAY_SELECTOR)) {
return
}
clearSelection()
},
{ capture: true },
)
function openChooser() {
if (!uploadBlocked.value) {
fileInput.value?.click()
}
}
function onFilesChosen(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
input.value = ''
if (!uploadBlocked.value) {
upload.addFiles(files)
}
}
function carriesFiles(event: DragEvent) {
return Array.from(event.dataTransfer?.types ?? []).includes('Files')
}
useEventListener(window, 'dragenter', (event: DragEvent) => {
if (!carriesFiles(event)) {
return
}
event.preventDefault()
if (!uploadBlocked.value) {
dragDepth.value += 1
}
})
useEventListener(window, 'dragover', (event: DragEvent) => {
if (carriesFiles(event)) {
event.preventDefault()
}
})
useEventListener(window, 'dragleave', (event: DragEvent) => {
if (carriesFiles(event)) {
dragDepth.value = Math.max(0, dragDepth.value - 1)
}
})
useEventListener(window, 'drop', (event: DragEvent) => {
if (!carriesFiles(event)) {
return
}
event.preventDefault()
dragDepth.value = 0
if (!uploadBlocked.value) {
upload.addFiles(Array.from(event.dataTransfer?.files ?? []))
}
})
function openBulkDelete() {
dismissBulkFailedToast()
bulkTargets.value = selection.selectedItems.value
bulkDeletedTotal = 0
bulkDeleteOpen.value = true
}
function bulkFailedDescription(failed: MediaItem[]) {
const names = failed.map((item) => item.filename)
const deleted = bulkDeletedTotal === 1 ? '1 image was' : `${bulkDeletedTotal} images were`
return `${deleted} deleted. ${joinNames(names)} ${names.length === 1 ? 'is' : 'are'} still in your library. Try again.`
}
function bulkFailedToast(failed: MediaItem[]) {
const content = {
color: 'error' as const,
icon: 'i-lucide-circle-x',
title: `Couldn't delete ${imageCount(failed.length)}`,
description: bulkFailedDescription(failed),
duration: PERSISTENT_TOAST,
actions: [
{
label: 'Retry',
icon: 'i-lucide-rotate-cw',
color: 'neutral' as const,
variant: 'outline' as const,
class: RETRY_BUTTON,
ui: { leadingIcon: 'size-3.5' },
onClick: () => retryBulkDelete(failed),
},
],
}
if (failureToastShown) {
toast.update(BULK_FAILED_TOAST_ID, content)
return
}
failureToastShown = true
toast.add({ id: BULK_FAILED_TOAST_ID, ...content })
}
function dismissBulkFailedToast() {
if (failureToastShown) {
failureToastShown = false
toast.remove(BULK_FAILED_TOAST_ID)
}
}
function finishBulkDelete(result: BulkDeleteResult, firstIndex: number) {
bulkDeletedTotal += result.deleted.length
if (result.failed.length > 0) {
selection.replace(result.failed.map((item) => item.id))
bulkFailedToast(result.failed)
focusElement('[data-testid="bulk-delete"]')
} else {
dismissBulkFailedToast()
selection.clear()
announce(`${imageCount(bulkDeletedTotal)} deleted`)
toast.add({
id: 'media-bulk-deleted',
color: 'success',
icon: 'i-lucide-circle-check',
title: `${imageCount(bulkDeletedTotal)} deleted`,
description: "They're gone from your library.",
duration: TOAST_DURATION_MS,
})
focusAfterRemoval(firstIndex)
}
refreshInBackground()
}
function firstIndexOf(targets: MediaItem[]) {
const indexes = targets
.map((target) => media.items.value.findIndex((entry) => entry.id === target.id))
.filter((index) => index >= 0)
return indexes.length > 0 ? Math.min(...indexes) : 0
}
async function bulkDeleteAction() {
const targets = bulkTargets.value
const firstIndex = firstIndexOf(targets)
const result = await media.deleteMany(targets)
if (result.handled) {
return false
}
if (result.deleted.length === 0) {
throw new Error(BULK_DELETE_FAILED_COPY)
}
bulkDeleteOpen.value = false
finishBulkDelete(result, firstIndex)
}
async function retryBulkDelete(failed: MediaItem[]) {
const firstIndex = firstIndexOf(failed)
const result = await media.deleteMany(failed)
if (result.handled) {
return
}
finishBulkDelete(result, firstIndex)
}
function openMerge(group: DuplicateGroup, keep: MediaItem) {
mergeTarget.value = { group, keep }
}
function mergeText(merged: number, articlesUpdated: number) {
const copiesText = merged === 1 ? '1 copy' : `${merged} copies`
const articlesText = articlesUpdated === 1 ? '1 article' : `${articlesUpdated} articles`
return `${copiesText} merged. ${articlesText} updated.`
}
async function mergeAction() {
const target = mergeTarget.value
if (!target) {
return
}
const { group, keep } = target
const mergeIds = group.copies.filter((copy) => copy.id !== keep.id).map((copy) => copy.id)
try {
const result = await media.mergeCopies(keep.id, mergeIds)
mergeTarget.value = null
media.removeLocalMany(group.copies.map((copy) => copy.id))
const text = mergeText(result.merged, result.articles_updated)
announce(text)
toast.add({
id: `media-merged-${keep.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Copies merged',
description: text,
duration: TOAST_DURATION_MS,
})
focusElement('[data-testid="media-heading"]')
refreshInBackground()
} catch (error) {
const failure = classifyMediaError(error)
if (failure === 'handled') {
return false
}
if (failure === 'not-found') {
mergeTarget.value = null
notFoundToast(keep.id)
refreshInBackground()
return
}
if (error instanceof ApiError && error.status === HTTP_CONFLICT) {
refreshInBackground()
throw new Error(MERGE_CONFLICT_COPY)
}
throw new Error(MERGE_FAILED_COPY)
}
}
</script>
<template>
@@ -350,17 +741,50 @@ async function deleteAction() {
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
v-if="isDesktop"
:icon="uploadIcon"
:disabled="uploadBlocked"
class="h-8 px-3"
@click="openChooser"
>
{{ uploadLabel }}
</UButton>
</div>
<template v-if="emptyLibrary">
<MediaEmptyState variant="first-run" />
<MediaUploadPanel
v-if="upload.rows.value.length > 0"
:rows="upload.rows.value"
:header="upload.header.value"
@use-existing="useExisting"
@upload-anyway="upload.retryRow"
@retry="upload.retryRow"
@cancel-row="upload.cancel"
@cancel-all="upload.cancelAll"
@dismiss="upload.dismiss"
/>
<MediaEmptyState
variant="first-run"
:offline="offline"
:limited="upload.limited.value"
@action="openChooser"
/>
<p class="self-center text-xs text-muted">PNG, JPEG, WEBP, SVG · max 10 MB each</p>
</template>
<template v-else>
<UAlert
v-if="upload.limited.value"
role="status"
color="info"
variant="soft"
:close="false"
icon="i-lucide-timer"
:title="RATE_LIMITED_TITLE"
:description="RATE_LIMITED_TEXT"
/>
<UAlert
v-if="loadFailed"
role="alert"
@@ -396,53 +820,114 @@ async function deleteAction() {
@update:view="updateControls({ view: $event })"
/>
<MediaUploadPanel
v-if="upload.rows.value.length > 0"
:rows="upload.rows.value"
:header="upload.header.value"
@use-existing="useExisting"
@upload-anyway="upload.retryRow"
@retry="upload.retryRow"
@cancel-row="upload.cancel"
@cancel-all="upload.cancelAll"
@dismiss="upload.dismiss"
/>
<MediaSkeletonGrid
v-if="loading && media.showSkeleton.value"
:count="isDesktop ? DESKTOP_SKELETON_CARDS : MOBILE_SKELETON_CARDS"
/>
<MediaEmptyState
v-else-if="noResults"
variant="no-results"
v-else-if="nothingToShow"
:variant="emptyVariant"
:query="controls.q"
:filter-label="controls.filter === 'unused' ? 'Unused' : 'All'"
:filter-label="filterLabel"
@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" />
<DuplicateGroups
v-if="isDuplicates"
:groups="groups"
:offline="offline"
@merge="openMerge"
@open="openPanel"
/>
<template v-else-if="controls.view === 'list'">
<MediaTable
v-if="isDesktop"
:items="media.items.value"
:selected="gridSelected"
:all-state="selection.allState.value"
@open="openPanel"
@toggle="toggleSelected"
@toggle-all="toggleAllSelected"
/>
<template v-else>
<MediaCardList
:items="media.items.value"
:selected="gridSelected"
@open="openPanel"
@toggle="toggleSelected"
/>
</template>
</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"
<MediaSelectAllRow
:state="selection.allState.value"
:count="media.items.value.length"
@toggle="toggleAllSelected"
/>
<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"
:selected="gridSelected"
@open="openPanel"
@toggle="toggleSelected"
/>
</template>
</template>
</template>
</div>
<input
ref="fileInput"
type="file"
multiple
:accept="UPLOAD_ACCEPT"
data-testid="media-file-input"
class="hidden"
tabindex="-1"
aria-hidden="true"
@change="onFilesChosen"
/>
<p role="status" aria-live="polite" data-testid="media-announcer" class="sr-only">
{{ announcement }}
</p>
<MediaDropArea v-if="dragging" />
<UButton
v-if="showFab"
icon="i-lucide-upload"
disabled
:icon="uploadIcon"
:disabled="uploadBlocked"
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' }"
@click="openChooser"
>
Upload
{{ uploadLabel }}
</UButton>
<MediaBulkBar
v-if="showBulkBar"
:count="selection.count.value"
:offline="offline"
:obscured="dialogOpen"
@delete="openBulkDelete"
@clear="clearSelection"
/>
<MediaDetailPanel
:open="panelOpen"
:item="panelItem"
@@ -453,6 +938,7 @@ async function deleteAction() {
@copy-url="copyUrl"
@rename="renameOpen = true"
@delete="deleteOpen = true"
@review-duplicates="reviewDuplicates"
/>
<RenameMediaDialog
@@ -469,5 +955,20 @@ async function deleteAction() {
:action="deleteAction"
@close="deleteOpen = false"
/>
<BulkDeleteMediaDialog
v-if="bulkDeleteOpen"
:items="bulkTargets"
:action="bulkDeleteAction"
@close="bulkDeleteOpen = false"
/>
<MergeMediaDialog
v-if="mergeTarget"
:keep="mergeTarget.keep"
:copies="mergeTarget.group.copies"
:action="mergeAction"
@close="mergeTarget = null"
/>
</DashboardShell>
</template>
@@ -0,0 +1,443 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError, NetworkError } from '@/composables/useApi'
import type { MediaItem } from '@/composables/useMedia'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
announcer,
callsTo,
deferred,
dialog,
findButton,
fixture,
lastListPath,
listBody,
listCalls,
mountPage,
panel,
press,
settle,
tab,
tabs,
textOf,
unmountPage,
type Handlers,
} from './mediaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn() }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => toasts }
})
const DUPLICATE_IDS = [5, 6, 7, 9, 12]
const DUPLICATE_COUNTS = { all: 12, unused: 2, duplicates: 2 }
function duplicatesList(ids = DUPLICATE_IDS): Handlers['list'] {
return (path) =>
path.includes('filter=duplicates')
? listBody(fixture(ids), DUPLICATE_COUNTS)
: listBody(fixture())
}
function groups() {
return Array.from(
document.body.querySelectorAll<HTMLElement>('[data-testid="duplicate-group"]'),
)
}
function copiesOf(group: HTMLElement) {
return Array.from(group.querySelectorAll<HTMLElement>('[data-testid="duplicate-copy"]'))
}
function mergeButton(filename: string) {
const copy = copiesOf(groups()[0]!)
.concat(copiesOf(groups()[1] ?? groups()[0]!))
.find((entry) => textOf(entry).includes(filename))!
return findButton('Merge into this one', copy)!
}
async function openMerge(filename: string, handlers: Handlers = {}) {
await mountPage({ list: duplicatesList(), ...handlers }, '/media?filter=duplicates')
mergeButton(filename).click()
await flushPromises()
}
beforeEach(() => {
toasts.add.mockClear()
toasts.remove.mockClear()
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
})
afterEach(unmountPage)
describe('The Duplicates tab', () => {
it('is enabled, writes the URL, requests filter=duplicates with limit=100 and shows sort and view', async () => {
await mountPage({ list: duplicatesList() })
expect(document.body.querySelector('[aria-label="Sort"]')).not.toBeNull()
const duplicates = tab('Duplicates 2')
duplicates.focus()
press(duplicates, 'Enter')
duplicates.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
await settle()
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&limit=100')
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
expect(tabs().map(textOf)).toEqual(['All 12', 'Unused 2', 'Duplicates 2'])
expect(document.body.querySelector('[data-testid="duplicates-count"]')?.textContent).toBe(
'2',
)
expect(document.body.querySelector('[aria-label="Sort"]')).not.toBeNull()
expect(document.body.querySelector('[aria-label="Grid view"]')).not.toBeNull()
expect(
document.body.querySelector('input[placeholder="Search by filename"]'),
).not.toBeNull()
})
it('opens from a deep link on the same view', async () => {
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates')
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
expect(groups()).toHaveLength(2)
})
it('applies the search to the duplicates request', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates')
const input = document.body.querySelector<HTMLInputElement>(
'input[placeholder="Search by filename"]',
)!
input.value = 'floor'
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
vi.advanceTimersByTime(120)
await flushPromises()
expect(lastListPath()).toBe('/v1/media/?q=floor&filter=duplicates&sort=newest&limit=100')
})
})
describe('Duplicates, groups', () => {
it('groups the images by content into bordered cards in the suggested order', async () => {
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates')
expect(groups()).toHaveLength(2)
const [first, second] = groups()
expect(textOf(first!.querySelector('header'))).toBe(
'Group 1 3 identical images · 2.4 MB each · 3600 × 2400',
)
expect(textOf(second!.querySelector('header'))).toBe(
'Group 2 2 identical images · 3.1 MB each · 3600 × 2400',
)
const names = (group: HTMLElement) =>
copiesOf(group).map((copy) => textOf(copy).split(' ')[0])
expect(names(first!)).toEqual(['floorboards.jpg', 'floorboards-copy.jpg', 'IMG_2208.jpg'])
expect(names(second!)).toEqual(['IMG_4471.jpg', 'stone-wall-detail.jpg'])
expect(first!.className).toContain('border')
expect(first!.parentElement!.className).toContain('gap-4')
expect(textOf(first!.querySelector('footer'))).toBe(
'Suggested: the most used copy, then the oldest. Choosing another copy switches articles to it.',
)
})
it('shows each copy with its uploader, use, merge button and the suggested styling', async () => {
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates')
const [suggested, other] = copiesOf(groups()[0]!)
expect(textOf(suggested)).toContain('Uploaded 14 Jun 2026 by Inès Duarte')
expect(textOf(suggested)).toContain('Used in 4 articles')
expect(textOf(suggested)).toContain('Suggested')
expect(suggested!.hasAttribute('data-suggested')).toBe(true)
expect(suggested!.className).toContain('bg-secondary-200')
expect(suggested!.className).toContain('md:border-primary')
expect(findButton('Merge into this one', suggested)!.className).toContain('bg-primary')
expect(textOf(other)).not.toContain('Suggested')
expect(other!.hasAttribute('data-suggested')).toBe(false)
expect(findButton('Merge into this one', other)!.className).toContain('border-accented')
await vi.waitFor(() =>
expect(
findButton('Merge into this one', suggested)!.querySelector('svg.iconify--lucide'),
).toBeTruthy(),
)
})
it('opens the detail panel from the underlined "Used in" count', async () => {
await mountPage(
{
list: duplicatesList(),
usage: () => [{ id: 10, title: 'How we price', published: true }],
},
'/media?filter=duplicates',
)
copiesOf(groups()[0]!)[0]!
.querySelector<HTMLButtonElement>('[data-testid="duplicate-used-in"]')!
.click()
await flushPromises()
expect(panel()).not.toBeNull()
expect(textOf(panel())).toContain('floorboards.jpg')
})
it('stacks the columns below md', async () => {
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates', MOBILE_WIDTH)
const grid = copiesOf(groups()[0]!)[0]!.parentElement!
expect(grid.className).toContain('md:grid-cols-(--columns)')
expect(grid.className).not.toMatch(/(^| )grid-cols-/)
})
it('reads the vector size and omits "by" when the uploader is unknown', async () => {
const svg = (id: number, createdAt: string): MediaItem => ({
...fixture([4])[0]!,
id,
filename: `mark-${id}.svg`,
sha256: 'svg',
uploaded_by: '',
created_at: createdAt,
used_in: 0,
duplicate: true,
})
await mountPage(
{
list: () =>
listBody(
[svg(20, '2026-09-01T12:00:00Z'), svg(21, '2026-09-02T12:00:00Z')],
DUPLICATE_COUNTS,
),
},
'/media?filter=duplicates',
)
expect(textOf(groups()[0]!.querySelector('header'))).toBe(
'Group 1 2 identical images · 8 KB each · Vector',
)
expect(textOf(copiesOf(groups()[0]!)[0])).toContain('Uploaded 1 Sep 2026')
expect(textOf(copiesOf(groups()[0]!)[0])).not.toContain(' by ')
})
})
describe('Duplicates, empty', () => {
const noDuplicates = () => listBody([], { all: 12, unused: 2, duplicates: 0 })
it('has no count badge and shows the panel, and Back to all images returns to All', async () => {
await mountPage(
{
list: (path) =>
path.includes('filter=duplicates') ? noDuplicates() : listBody(fixture()),
},
'/media?filter=duplicates',
)
expect(document.body.querySelector('[data-testid="duplicates-count"]')).toBeNull()
expect(textOf(tab('Duplicates'))).toBe('Duplicates')
expect(document.body.textContent).toContain('No duplicates')
expect(document.body.textContent).toContain('Every image in your library is different.')
findButton('Back to all images')!.click()
await settle()
expect(router.currentRoute.value.query).toEqual({})
expect(tab('All').getAttribute('aria-selected')).toBe('true')
expect(document.body.querySelectorAll('[data-media-card]')).toHaveLength(12)
})
})
describe('Merge', () => {
it('confirms for the suggested copy with the kept image and the article count', async () => {
await openMerge('floorboards.jpg')
const box = dialog()!
expect(textOf(box)).toContain('Merge into floorboards.jpg?')
expect(textOf(box)).toContain(
"Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 articles will be updated. This can't be undone.",
)
expect(textOf(box)).toContain('floorboards.jpg Kept · used in 4 articles now')
expect(Array.from(box.querySelectorAll('button')).map(textOf)).toEqual([
'Cancel',
'Merge 2 copies',
])
expect(callsTo('POST', /merge/)).toHaveLength(0)
})
it('keeps another copy and sums the use of the others', async () => {
await openMerge('IMG_2208.jpg')
expect(textOf(dialog())).toContain('Merge into IMG_2208.jpg?')
expect(textOf(dialog())).toContain('7 articles will be updated.')
})
it('uses the singular for a group of two copies', async () => {
await openMerge('IMG_4471.jpg')
expect(textOf(dialog())).toContain('Articles that use the other copy will be switched to')
expect(textOf(dialog())).toContain('the other copy will be deleted.')
expect(findButton('Merge 1 copy', dialog()!)).toBeTruthy()
})
it('sends the ids and locks the dialog while the request is in flight', async () => {
const pending = deferred<unknown>()
await openMerge('floorboards.jpg', { merge: () => pending.promise })
findButton('Merge 2 copies', dialog()!)!.click()
await flushPromises()
const [call] = callsTo('POST', /merge/)
expect(call![0]).toBe('/v1/media/merge')
expect(JSON.parse(call![1].body)).toEqual({ keep_id: 12, merge_ids: [5, 6] })
expect(findButton('Cancel', dialog()!)!.disabled).toBe(true)
const confirm = findButton('Merging…', dialog()!)!
expect(confirm.disabled).toBe(true)
await vi.waitFor(() => expect(confirm.querySelector('svg.iconify--lucide')).toBeTruthy())
dialog()!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
)
await flushPromises()
expect(dialog()).not.toBeNull()
pending.resolve({ articles_updated: 5, merged: 2 })
await settle()
})
it('done: the group leaves, a toast and the live region say so, and the list reloads silently', async () => {
let merged = false
await openMerge('floorboards.jpg', {
list: (path) =>
path.includes('filter=duplicates')
? listBody(fixture(merged ? [7, 9] : DUPLICATE_IDS), {
all: merged ? 10 : 12,
unused: 2,
duplicates: merged ? 1 : 2,
})
: listBody(fixture()),
merge: () => {
merged = true
return { articles_updated: 5, merged: 2 }
},
})
const reloads = listCalls().length
findButton('Merge 2 copies', dialog()!)!.click()
await settle()
expect(dialog()).toBeNull()
expect(groups()).toHaveLength(1)
expect(textOf(groups()[0]!.querySelector('header'))).toContain('Group 1')
expect(textOf(copiesOf(groups()[0]!)[0])).toContain('IMG_4471.jpg')
expect(toasts.add).toHaveBeenCalledWith(
expect.objectContaining({
id: 'media-merged-12',
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Copies merged',
description: '2 copies merged. 5 articles updated.',
duration: 5000,
}),
)
expect(announcer()).toBe('2 copies merged. 5 articles updated.')
expect(listCalls().length).toBe(reloads + 1)
expect(document.body.querySelectorAll('[data-testid="media-skeleton-card"]')).toHaveLength(
0,
)
})
it('done with the last group shows "No duplicates" and drops the count badge', async () => {
let merged = false
await openMerge('IMG_4471.jpg', {
list: (path) =>
path.includes('filter=duplicates')
? listBody(fixture(merged ? [] : [7, 9]), {
all: merged ? 10 : 12,
unused: 2,
duplicates: merged ? 0 : 1,
})
: listBody(fixture()),
merge: () => {
merged = true
return { articles_updated: 1, merged: 1 }
},
})
findButton('Merge 1 copy', dialog()!)!.click()
await settle()
expect(document.body.textContent).toContain('No duplicates')
expect(document.body.querySelector('[data-testid="duplicates-count"]')).toBeNull()
expect(announcer()).toBe('1 copy merged. 1 article updated.')
})
it.each([
['a 500 response', () => Promise.reject(new ApiError(500))],
['a network failure', () => Promise.reject(new NetworkError())],
])(
'%s keeps the dialog open with the error alert and enables both buttons',
async (_name, merge) => {
await openMerge('floorboards.jpg', { merge })
findButton('Merge 2 copies', dialog()!)!.click()
await settle()
const box = dialog()!
expect(textOf(box.querySelector('[role="alert"]'))).toBe(
"Couldn't merge the copies. Nothing was changed. Try again in a moment.",
)
expect(findButton('Cancel', box)!.disabled).toBe(false)
expect(findButton('Merge 2 copies', box)!.disabled).toBe(false)
expect(groups()).toHaveLength(2)
},
)
it('a 409 keeps the dialog open with the conflict alert and reloads the list', async () => {
await openMerge('floorboards.jpg', { merge: () => Promise.reject(new ApiError(409)) })
const before = listCalls().length
findButton('Merge 2 copies', dialog()!)!.click()
await settle()
expect(textOf(dialog()!.querySelector('[role="alert"]'))).toBe(
'One of these images changed, so nothing was merged. The list is up to date now. Check it and try again.',
)
expect(listCalls().length).toBe(before + 1)
})
it('a 404 closes the dialog, shows the info toast and reloads the list', async () => {
await openMerge('floorboards.jpg', { merge: () => Promise.reject(new ApiError(404)) })
const before = listCalls().length
findButton('Merge 2 copies', dialog()!)!.click()
await settle()
expect(dialog()).toBeNull()
expect(toasts.add).toHaveBeenCalledWith(
expect.objectContaining({
id: 'media-not-found-12',
color: 'info',
title: 'That image no longer exists',
}),
)
expect(listCalls().length).toBe(before + 1)
})
it('disables every Merge into this one while offline', async () => {
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates')
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
const buttons = Array.from(document.body.querySelectorAll('button')).filter(
(button) => textOf(button) === 'Merge into this one',
)
expect(buttons).toHaveLength(5)
expect(buttons.every((button) => button.disabled)).toBe(true)
expect(callsTo('POST', /merge/)).toHaveLength(0)
})
})
@@ -0,0 +1,572 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError, NetworkError } from '@/composables/useApi'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
announcer,
bulkBar,
callsTo,
cardFor,
cards,
checkboxFor,
deferred,
dialog,
findButton,
fixture,
libraryHandlers,
listBody,
listCalls,
mountPage,
panel,
press,
pressOnWindow,
selectAllCheckbox,
settle,
tab,
tableHeaderCheckbox,
textOf,
unmountPage,
} from './mediaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => toasts }
})
const NAMES = ['north-wall.jpg', 'kitchen-after.webp', 'window-frame.jpg']
function select(...names: string[]) {
for (const name of names) {
checkboxFor(name).click()
}
return flushPromises()
}
function state(element: Element) {
return element.getAttribute('data-state')
}
function selectedNames() {
return cards()
.filter((card) => card.hasAttribute('data-selected'))
.map((card) => card.getAttribute('aria-label')!.split(',')[0])
}
function checkboxWrapper(filename: string) {
return checkboxFor(filename).closest<HTMLElement>('[data-testid="card-checkbox"]')!
}
beforeEach(() => {
toasts.add.mockClear()
toasts.remove.mockClear()
toasts.update.mockClear()
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
})
afterEach(unmountPage)
describe('Selecting images', () => {
it('shows no checkbox until the pointer or keyboard focus reaches a card, then a 36 px one', async () => {
await mountPage()
const wrapper = checkboxWrapper('north-wall.jpg')
expect(wrapper.classList.contains('opacity-100')).toBe(false)
expect(wrapper.classList.contains('md:opacity-0')).toBe(true)
expect(wrapper.className).toContain('md:group-hover/card:opacity-100')
expect(wrapper.className).toContain('md:group-has-[:focus-visible]/card:opacity-100')
expect(wrapper.className).toContain('md:size-9')
expect(checkboxFor('north-wall.jpg').getAttribute('tabindex')).toBe('-1')
})
it('ticking a checkbox selects the card, shows every checkbox and announces it', async () => {
await mountPage()
await select('north-wall.jpg')
const card = cardFor('north-wall.jpg')
expect(card.hasAttribute('data-selected')).toBe(true)
expect(card.className).toContain('bg-secondary-200')
expect(card.className).toContain('border-primary')
expect(card.className).toContain('inset-ring-primary')
expect(state(checkboxFor('north-wall.jpg'))).toBe('checked')
expect(checkboxWrapper('kitchen-after.webp').classList.contains('opacity-100')).toBe(true)
expect(announcer()).toBe('north-wall.jpg selected, 1 selected')
expect(panel()).toBeNull()
})
it('Space on the card toggles it and Enter still opens the detail panel', async () => {
await mountPage()
const card = cardFor('north-wall.jpg')
card.focus()
press(card, ' ')
await flushPromises()
expect(card.hasAttribute('data-selected')).toBe(true)
expect(announcer()).toBe('north-wall.jpg selected, 1 selected')
expect(panel()).toBeNull()
press(card, ' ')
await flushPromises()
expect(card.hasAttribute('data-selected')).toBe(false)
press(card, 'Enter')
await flushPromises()
expect(panel()).not.toBeNull()
})
it('announces the new count when unselecting, and "Selection cleared" for the last one', async () => {
await mountPage()
await select('north-wall.jpg', 'kitchen-after.webp', 'window-frame.jpg')
await select('north-wall.jpg')
expect(announcer()).toBe('north-wall.jpg unselected, 2 selected')
await select('kitchen-after.webp')
await select('window-frame.jpg')
expect(announcer()).toBe('Selection cleared')
expect(bulkBar()).toBeNull()
})
it('shows a 44 px checkbox on every card below md, and tapping it does not open the panel', async () => {
await mountPage({}, '/media', MOBILE_WIDTH)
const wrapper = checkboxWrapper('north-wall.jpg')
expect(wrapper.className).toContain('max-md:opacity-100')
expect(wrapper.className).toContain('size-11')
await select('north-wall.jpg')
expect(cardFor('north-wall.jpg').hasAttribute('data-selected')).toBe(true)
expect(panel()).toBeNull()
cardFor('kitchen-after.webp').click()
await flushPromises()
expect(panel()).not.toBeNull()
})
it('puts a 44 px checkbox on every row of the card list in list view', async () => {
await mountPage({}, '/media?view=list', MOBILE_WIDTH)
expect(document.body.querySelectorAll('[data-testid="media-card-row"]')).toHaveLength(12)
expect(document.body.querySelectorAll('[role="checkbox"]')).toHaveLength(12)
await select('north-wall.jpg')
expect(
document.body.querySelector('[data-testid="media-card-row"][data-selected]')!.className,
).toContain('bg-secondary-200')
expect(document.body.querySelector('[data-testid="image-count"]')).toBeNull()
})
})
describe('Select all and the partial state', () => {
it('shows Select all with the image count, partial with some selected, then all, then none', async () => {
await mountPage()
const row = document.body.querySelector('[data-testid="image-count"]')!.parentElement!
expect(textOf(row)).toBe('Select all 12 images')
expect(state(selectAllCheckbox())).toBe('unchecked')
await select('north-wall.jpg', 'kitchen-after.webp', 'window-frame.jpg')
expect(state(selectAllCheckbox())).toBe('indeterminate')
selectAllCheckbox().click()
await flushPromises()
expect(state(selectAllCheckbox())).toBe('checked')
expect(selectedNames()).toHaveLength(12)
expect(textOf(bulkBar())).toContain('12 selected')
selectAllCheckbox().click()
await flushPromises()
expect(state(selectAllCheckbox())).toBe('unchecked')
expect(bulkBar()).toBeNull()
expect(announcer()).toBe('Selection cleared')
})
it('the table header checkbox does the same in list view at md width or more', async () => {
await mountPage({}, '/media?view=list')
expect(state(tableHeaderCheckbox())).toBe('unchecked')
await select('north-wall.jpg')
expect(state(tableHeaderCheckbox())).toBe('indeterminate')
tableHeaderCheckbox().click()
await flushPromises()
expect(state(tableHeaderCheckbox())).toBe('checked')
expect(textOf(bulkBar())).toContain('12 selected')
tableHeaderCheckbox().click()
await flushPromises()
expect(bulkBar()).toBeNull()
})
it('has no Select all row in the mobile card list', async () => {
await mountPage({}, '/media?view=list', MOBILE_WIDTH)
expect(document.body.textContent).not.toContain('Select all')
await select('north-wall.jpg', 'kitchen-after.webp')
expect(textOf(bulkBar())).toContain('2 selected')
})
})
describe('Escape and Clear', () => {
it('Escape and Clear empty the selection when the panel is closed', async () => {
await mountPage()
await select('north-wall.jpg', 'kitchen-after.webp', 'window-frame.jpg')
pressOnWindow('Escape')
await flushPromises()
expect(bulkBar()).toBeNull()
expect(selectedNames()).toEqual([])
await select('north-wall.jpg', 'kitchen-after.webp')
findButton('Clear', bulkBar()!)!.click()
await flushPromises()
expect(bulkBar()).toBeNull()
expect(announcer()).toBe('Selection cleared')
})
it('Escape closes only the panel while it is open over a selection', async () => {
await mountPage()
await select('north-wall.jpg', 'kitchen-after.webp')
cardFor('window-frame.jpg').click()
await flushPromises()
expect(panel()).not.toBeNull()
panel()!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
)
await settle()
expect(panel()).toBeNull()
expect(selectedNames()).toEqual(['north-wall.jpg', 'kitchen-after.webp'])
pressOnWindow('Escape')
await flushPromises()
expect(selectedNames()).toEqual([])
})
})
describe('The selection follows the query', () => {
it('clears when the tab changes', async () => {
await mountPage()
await select('north-wall.jpg')
tab('Unused').focus()
press(tab('Unused'), 'Enter')
await settle()
expect(bulkBar()).toBeNull()
})
it('clears when the sort changes and stays when the view changes', async () => {
await mountPage()
await select('north-wall.jpg', 'kitchen-after.webp')
await router.replace({ query: { view: 'list' } })
await settle()
expect(textOf(bulkBar())).toContain('2 selected')
await router.replace({ query: { view: 'list', sort: 'oldest' } })
await settle()
expect(bulkBar()).toBeNull()
})
it('clears when the search text changes', async () => {
await mountPage()
await select('north-wall.jpg')
await router.replace({ query: { q: 'wall' } })
await settle()
expect(bulkBar()).toBeNull()
})
})
describe('The bulk bar', () => {
it('shows the count, a danger Delete and an inverted Clear, placed per breakpoint', async () => {
await mountPage()
await select(...NAMES)
const bar = bulkBar()!
expect(textOf(bar)).toContain('3 selected')
expect(bar.className).toContain('bg-inverted')
expect(bar.className).toContain('text-inverted')
expect(bar.className).toContain('fixed')
expect(bar.className).toContain('md:w-100')
expect(bar.className).toContain('md:bottom-6')
expect(bar.className).toContain('bottom-4')
expect(bar.className).toContain('max-md:inset-x-4')
const remove = findButton('Delete', bar)!
expect(remove.className).toContain('bg-error')
expect(findButton('Clear', bar)!.className).toContain('border-(--ui-text-inverted)')
await vi.waitFor(() => expect(remove.querySelector('svg.iconify--lucide')).toBeTruthy())
})
it('replaces the mobile pinned Upload button', async () => {
await mountPage({}, '/media', MOBILE_WIDTH)
expect(findButton('Upload')).toBeTruthy()
await select('north-wall.jpg')
expect(findButton('Upload')).toBeUndefined()
expect(textOf(bulkBar())).toContain('1 selected')
})
})
describe('Bulk delete', () => {
async function openBulkDialog(names = NAMES) {
await select(...names)
findButton('Delete', bulkBar()!)!.click()
await flushPromises()
}
it('confirms with the summed article count and one row per image, without a usage request', async () => {
await mountPage()
await openBulkDialog()
const box = dialog()!
expect(textOf(box)).toContain('Delete 3 images?')
expect(textOf(box)).toContain(
"2 of them are used in 5 articles, which will show a broken image. The other one isn't used anywhere. This can't be undone.",
)
const rows = Array.from(box.querySelectorAll('[data-testid="bulk-delete-row"]')).map(textOf)
expect(rows).toEqual([
'north-wall.jpg Used in 2',
'kitchen-after.webp Used in 3',
'window-frame.jpg Unused',
])
const buttons = Array.from(box.querySelectorAll('button')).map(textOf)
expect(buttons).toEqual(['Cancel', 'Delete 3 images'])
expect(callsTo('GET', /usage/)).toHaveLength(0)
expect(callsTo('DELETE', /media/)).toHaveLength(0)
})
it('uses the singular for one image', async () => {
await mountPage()
await openBulkDialog(['window-frame.jpg'])
expect(textOf(dialog())).toContain('Delete 1 image?')
expect(findButton('Delete 1 image', dialog()!)).toBeTruthy()
})
it('sends one DELETE per image and locks the dialog while they are in flight', async () => {
const pending = deferred<undefined>()
await mountPage({ remove: () => pending.promise })
await openBulkDialog()
findButton('Delete 3 images', dialog()!)!.click()
await flushPromises()
expect(callsTo('DELETE', /^\/v1\/media\/(1|2|8)$/).map(([path]) => path)).toEqual([
'/v1/media/1',
'/v1/media/2',
'/v1/media/8',
])
expect(findButton('Cancel', dialog()!)!.disabled).toBe(true)
const confirm = findButton('Deleting…', dialog()!)!
expect(confirm.disabled).toBe(true)
await vi.waitFor(() => expect(confirm.querySelector('svg.iconify--lucide')).toBeTruthy())
dialog()!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
)
await flushPromises()
expect(dialog()).not.toBeNull()
pending.resolve(undefined)
await settle()
})
it('done: the cards leave, the selection empties, focus moves on and a toast shows', async () => {
let calls = 0
await mountPage({
list: () =>
++calls === 1
? listBody(fixture())
: listBody(
fixture().filter((item) => ![1, 2, 8].includes(item.id)),
{ all: 9, unused: 1, duplicates: 2 },
),
})
await openBulkDialog()
findButton('Delete 3 images', dialog()!)!.click()
await settle()
expect(dialog()).toBeNull()
expect(cards()).toHaveLength(9)
expect(bulkBar()).toBeNull()
expect(document.activeElement).toBe(cardFor('kitchen-before.webp'))
expect(toasts.add).toHaveBeenCalledWith(
expect.objectContaining({
id: 'media-bulk-deleted',
color: 'success',
icon: 'i-lucide-circle-check',
title: '3 images deleted',
description: "They're gone from your library.",
duration: 5000,
}),
)
expect(listCalls()).toHaveLength(2)
expect(document.body.querySelectorAll('[data-testid="media-skeleton-card"]')).toHaveLength(
0,
)
})
it('done with nothing left moves focus to the page heading', async () => {
await mountPage(libraryHandlers(() => undefined, [1]))
await openBulkDialog(['north-wall.jpg'])
findButton('Delete 1 image', dialog()!)!.click()
await settle()
expect(document.activeElement).toBe(
document.body.querySelector('[data-testid="media-heading"]'),
)
})
it('partly failed: deleted cards leave, the failed one stays selected and an error toast offers Retry', async () => {
await mountPage(
libraryHandlers((id) => (id === 8 ? Promise.reject(new ApiError(500)) : undefined)),
)
await openBulkDialog()
findButton('Delete 3 images', dialog()!)!.click()
await settle()
expect(dialog()).toBeNull()
expect(cardFor('north-wall.jpg')).toBeUndefined()
expect(cardFor('kitchen-after.webp')).toBeUndefined()
expect(selectedNames()).toEqual(['window-frame.jpg'])
expect(textOf(bulkBar())).toContain('1 selected')
const toast = toasts.add.mock.calls.at(-1)![0]
expect(toast).toMatchObject({
id: 'media-bulk-delete-failed',
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't delete 1 image",
description:
'2 images were deleted. window-frame.jpg is still in your library. Try again.',
duration: 0,
})
expect(toast.actions).toHaveLength(1)
expect(toast.actions[0]).toMatchObject({ label: 'Retry', icon: 'i-lucide-rotate-cw' })
})
it('Retry sends only the failed DELETE with no dialog and replaces the toast on success', async () => {
let eightAttempts = 0
await mountPage(
libraryHandlers((id) => {
if (id === 8 && ++eightAttempts === 1) {
return Promise.reject(new NetworkError())
}
return undefined
}),
)
await openBulkDialog()
findButton('Delete 3 images', dialog()!)!.click()
await settle()
const failed = toasts.add.mock.calls.at(-1)![0]
const deletesBefore = callsTo('DELETE', /media/).length
failed.actions[0].onClick()
await settle()
expect(callsTo('DELETE', /media/).length).toBe(deletesBefore + 1)
expect(callsTo('DELETE', /media\/8$/)).toHaveLength(2)
expect(dialog()).toBeNull()
expect(cardFor('window-frame.jpg')).toBeUndefined()
expect(toasts.remove).toHaveBeenCalledWith('media-bulk-delete-failed')
expect(toasts.add.mock.calls.at(-1)![0]).toMatchObject({
id: 'media-bulk-deleted',
color: 'success',
title: '3 images deleted',
})
expect(bulkBar()).toBeNull()
})
it('several failures name every file and pluralise the title', async () => {
await mountPage({
remove: (id) => (id === 2 || id === 3 ? Promise.reject(new ApiError(500)) : undefined),
})
await openBulkDialog([
'north-wall.jpg',
'kitchen-after.webp',
'kitchen-before.webp',
'window-frame.jpg',
])
findButton('Delete 4 images', dialog()!)!.click()
await settle()
expect(toasts.add.mock.calls.at(-1)![0]).toMatchObject({
title: "Couldn't delete 2 images",
description:
'2 images were deleted. kitchen-after.webp and kitchen-before.webp are still in your library. Try again.',
})
})
it('an image that no longer exists counts as deleted', async () => {
await mountPage(
libraryHandlers((id) => (id === 2 ? Promise.reject(new ApiError(404)) : undefined)),
)
await openBulkDialog()
findButton('Delete 3 images', dialog()!)!.click()
await settle()
expect(cardFor('kitchen-after.webp')).toBeUndefined()
expect(toasts.add.mock.calls.at(-1)![0]).toMatchObject({
id: 'media-bulk-deleted',
title: '3 images deleted',
})
})
it.each([
['500', () => Promise.reject(new ApiError(500))],
['a network failure', () => Promise.reject(new NetworkError())],
])(
'every request failing with %s keeps the dialog open with an error alert',
async (_name, remove) => {
await mountPage({ remove })
await openBulkDialog()
findButton('Delete 3 images', dialog()!)!.click()
await settle()
const box = dialog()!
expect(textOf(box.querySelector('[role="alert"]'))).toBe(
"Couldn't delete the images, so they're still there. Try again in a moment.",
)
expect(findButton('Cancel', box)!.disabled).toBe(false)
expect(findButton('Delete 3 images', box)!.disabled).toBe(false)
expect(cards()).toHaveLength(12)
expect(toasts.add).not.toHaveBeenCalled()
},
)
it('disables Delete in the bulk bar while offline but keeps Clear working', async () => {
await mountPage()
await select('north-wall.jpg')
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
expect(findButton('Delete', bulkBar()!)!.disabled).toBe(true)
await select('kitchen-after.webp')
expect(textOf(bulkBar())).toContain('2 selected')
findButton('Clear', bulkBar()!)!.click()
await flushPromises()
expect(bulkBar()).toBeNull()
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
window.dispatchEvent(new Event('online'))
await select('north-wall.jpg')
expect(findButton('Delete', bulkBar()!)!.disabled).toBe(false)
})
})
@@ -0,0 +1,133 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import {
FakeXhr,
chooseFiles,
dialog,
findButton,
checkboxFor,
bulkBar,
fileWith,
fixture,
listBody,
mountPage,
settle,
stubDigest,
stubXhr,
textOf,
unmountPage,
type Handlers,
} from './mediaTestKit'
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn() }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => toasts }
})
const LOGIN = { path: '/login', query: { redirect: '/media', reason: 'expired' } }
const DUPLICATES_LOGIN = {
path: '/login',
query: { redirect: '/media?filter=duplicates', reason: 'expired' },
}
const PENDING = { path: '/register/pending', query: {} }
async function mountUnverified(handlers: Handlers, url = '/media') {
await mountPage(handlers, url, undefined, { realApi: true })
useSessionStore().session = {
verified: false,
username: 'marta',
id: 1,
displayName: '',
role: 'owner',
}
}
async function expectRedirect(destination: { path: string; query: Record<string, string> }) {
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe(destination.path))
expect(router.currentRoute.value.query).toEqual(destination.query)
}
beforeEach(() => {
toasts.add.mockClear()
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
stubDigest()
stubXhr()
})
afterEach(unmountPage)
describe('Session errors are global for the new calls', () => {
it.each([
['POST /v1/media/merge', 401, DUPLICATES_LOGIN],
['POST /v1/media/merge', 403, PENDING],
])('%s resolving %i redirects and shows no local copy', async (_call, status, destination) => {
await mountUnverified(
{
list: (path) =>
path.includes('filter=duplicates')
? listBody(fixture([5, 6, 12]))
: listBody(fixture()),
merge: () => Promise.reject(new ApiError(status)),
},
'/media?filter=duplicates',
)
document.body
.querySelector<HTMLButtonElement>(
'[data-testid="duplicate-copy"] button:not([data-testid])',
)!
.click()
await flushPromises()
findButton('Merge 2 copies', dialog()!)!.click()
await expectRedirect(destination)
await settle()
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(toasts.add).not.toHaveBeenCalled()
})
it.each([
['DELETE /v1/media/{id}', 403, PENDING],
['DELETE /v1/media/{id}', 401, LOGIN],
])('%s resolving %i redirects and shows no local copy', async (_call, status, destination) => {
await mountUnverified({ remove: () => Promise.reject(new ApiError(status)) })
checkboxFor('north-wall.jpg').click()
await flushPromises()
findButton('Delete', bulkBar()!)!.click()
await flushPromises()
findButton('Delete 1 image', dialog()!)!.click()
await expectRedirect(destination)
await settle()
expect(dialog()!.querySelector('[role="alert"]')).toBeNull()
expect(toasts.add).not.toHaveBeenCalled()
})
it('GET /v1/media/?hash= resolving 401 redirects to the login page and sends nothing', async () => {
await mountUnverified({ hash: () => Promise.reject(new ApiError(401)) })
await chooseFiles([fileWith('a.jpg', 'image/jpeg', 1000)])
await expectRedirect(LOGIN)
expect(FakeXhr.instances).toHaveLength(0)
expect(document.body.textContent).not.toContain('Upload failed')
})
it('GET /v1/media/?hash= resolving 403 redirects to the pending page', async () => {
await mountUnverified({ hash: () => Promise.reject(new ApiError(403)) })
await chooseFiles([fileWith('a.jpg', 'image/jpeg', 1000)])
await expectRedirect(PENDING)
expect(FakeXhr.instances).toHaveLength(0)
expect(textOf(document.body.querySelector('[data-testid="upload-row"]'))).not.toContain(
'Upload failed',
)
})
})
+844
View File
@@ -0,0 +1,844 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError, NetworkError } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
FakeXhr,
MEGABYTE,
announcer,
bulkBar,
callsTo,
cards,
chooseFiles,
dragEvent,
deferred,
fileInput,
fileWith,
findButton,
fixture,
hexOf,
inFlight,
listBody,
listCalls,
mountPage,
rowTexts,
settle,
stubDigest,
stubXhr,
tab,
textOf,
unmountPage,
uploadPanel,
uploadRows,
xhrFor,
type Handlers,
} from './mediaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn() }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => toasts }
})
const REJECTION_TEXT = 'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.'
const UNREADABLE_TEXT =
"The server couldn't read this image. Save it again as a PNG or JPEG, then try again."
const FAILED_TEXT = "The server didn't accept the file. Nothing was added to your library."
function photo(name: string, size = 1000) {
return fileWith(name, 'image/jpeg', size)
}
function hashLookups() {
return callsTo('GET', /\?hash=/)
}
function hashHandlerFor(name: string, existing = [5, 6, 12]): Handlers['hash'] {
return (path) =>
path === `/v1/media/?hash=${hexOf(name)}` ? listBody(fixture(existing)) : listBody([])
}
function headerButton(label: string) {
return findButton(label)
}
beforeEach(() => {
toasts.add.mockClear()
toasts.remove.mockClear()
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
stubDigest()
stubXhr()
})
afterEach(unmountPage)
describe('Upload entry points', () => {
it.each([
['at md width or more', 'Upload', 1440],
['below md width', 'Upload', MOBILE_WIDTH],
])(
'the Default state loaded %s: "%s" opens the file chooser',
async (_context, label, width) => {
await mountPage({}, '/media', width)
const click = vi.spyOn(HTMLInputElement.prototype, 'click')
const buttons = Array.from(document.body.querySelectorAll('button')).filter(
(button) => textOf(button) === label,
)
expect(buttons).toHaveLength(1)
buttons[0]!.click()
expect(click).toHaveBeenCalledTimes(1)
expect(click.mock.contexts[0]).toBe(fileInput())
},
)
it('and the library has no image: "Upload an image" opens the file chooser', async () => {
await mountPage({ list: () => listBody([], { all: 0, unused: 0, duplicates: 0 }) })
const click = vi.spyOn(HTMLInputElement.prototype, 'click')
findButton('Upload an image')!.click()
expect(click.mock.contexts[0]).toBe(fileInput())
})
it('the chooser accepts several files and offers the four image types', async () => {
await mountPage()
expect(fileInput().multiple).toBe(true)
expect(fileInput().getAttribute('accept')).toBe(
'image/png,image/jpeg,image/webp,image/svg+xml',
)
})
})
describe('Dropping files anywhere on the page', () => {
it('shows the drop area while files are dragged over, then uploads the drop with no crop step', async () => {
await mountPage()
dragEvent('dragenter', ['Files'])
await flushPromises()
const area = document.body.querySelector('[data-testid="media-drop-area"]')!
expect(textOf(area)).toBe('Drop images to upload PNG, JPEG, WEBP, SVG · max 10 MB each')
expect(area.className).toContain('border-dashed')
expect(area.className).toContain('border-primary')
dragEvent('dragleave', ['Files'])
await flushPromises()
expect(document.body.querySelector('[data-testid="media-drop-area"]')).toBeNull()
expect(FakeXhr.instances).toHaveLength(0)
expect(uploadPanel()).toBeNull()
dragEvent('dragenter', ['Files'])
dragEvent('drop', ['Files'], [photo('a.jpg'), photo('b.jpg')])
await settle()
expect(document.body.querySelector('[data-testid="media-drop-area"]')).toBeNull()
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual(['a.jpg', 'b.jpg'])
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
})
it('hides the mobile pinned Upload button while the area shows', async () => {
await mountPage({}, '/media', MOBILE_WIDTH)
expect(headerButton('Upload')).toBeTruthy()
dragEvent('dragenter', ['Files'])
await flushPromises()
expect(headerButton('Upload')).toBeUndefined()
dragEvent('dragleave', ['Files'])
await flushPromises()
expect(headerButton('Upload')).toBeTruthy()
})
it('does nothing when something that is not a file is dragged over', async () => {
await mountPage()
const event = dragEvent('dragenter', ['text/plain'])
await flushPromises()
expect(event.defaultPrevented).toBe(false)
expect(document.body.querySelector('[data-testid="media-drop-area"]')).toBeNull()
})
})
describe('Browser checks, nothing is sent', () => {
it.each([
['scan.tif', 14.2 * MEGABYTE, 'image/tiff', 'scan.tif is 14.2 MB'],
['notes.pdf', 210 * 1024, 'application/pdf', "notes.pdf isn't an image"],
['photo.png', 10.1 * MEGABYTE, 'image/png', 'photo.png is 10.1 MB'],
['fake.jpg', 40 * 1024, 'text/plain', "fake.jpg isn't an image"],
['logo.gif', 40 * 1024, 'image/gif', "logo.gif isn't an image"],
['pic.jpg', 40 * 1024, 'image/png', "pic.jpg isn't an image"],
])('%s becomes a rejected row', async (name, size, mime, title) => {
await mountPage()
const digest = globalThis.crypto.subtle.digest
await chooseFiles([fileWith(name, mime, size)])
const [row] = uploadRows()
expect(row!.getAttribute('data-status')).toBe('rejected')
expect(textOf(row)).toContain(title)
expect(textOf(row)).toContain(REJECTION_TEXT)
await vi.waitFor(() => expect(row!.querySelector('svg.iconify--lucide')).toBeTruthy())
expect(digest).not.toHaveBeenCalled()
expect(hashLookups()).toHaveLength(0)
expect(FakeXhr.instances).toHaveLength(0)
})
it('accepts exactly 10485760 bytes and rejects one byte more', async () => {
await mountPage()
await chooseFiles([
fileWith('edge.png', 'image/png', 10485760),
fileWith('over.png', 'image/png', 10485761),
])
expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([
'uploading',
'rejected',
])
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual(['edge.png'])
})
it('checks the size before the type', async () => {
await mountPage()
await chooseFiles([fileWith('huge.tif', 'image/tiff', 11 * MEGABYTE)])
expect(textOf(uploadRows()[0])).toContain('huge.tif is 11.0 MB')
})
it('leaves the panel with "Nothing was uploaded" when every file is rejected', async () => {
await mountPage()
await chooseFiles([
fileWith('scan.tif', 'image/tiff', 14.2 * MEGABYTE),
fileWith('notes.pdf', 'application/pdf', 210 * 1024),
fileWith('broken.png', 'image/png', 3 * MEGABYTE),
])
xhrFor('broken.png').respond(400)
await settle()
const panel = uploadPanel()!
expect(textOf(panel.querySelector('[data-testid="upload-title"]'))).toBe(
'Nothing was uploaded',
)
expect(textOf(panel.querySelector('[data-testid="upload-sub"]'))).toBe(
'3 files were rejected',
)
expect(findButton('Dismiss', panel)).toBeTruthy()
expect(panel.querySelector('[data-testid="upload-summary"]')).toBeNull()
const [scan, notes, broken] = rowTexts()
expect(scan).toContain(`scan.tif is 14.2 MB ${REJECTION_TEXT}`)
expect(notes).toContain(`notes.pdf isn't an image ${REJECTION_TEXT}`)
expect(broken).toContain(`broken.png couldn't be read ${UNREADABLE_TEXT}`)
})
it('reads "2 files need attention" for a rejected and a failed file, with no summary', async () => {
await mountPage()
await chooseFiles([fileWith('scan.tif', 'image/tiff', 14.2 * MEGABYTE), photo('roof.jpg')])
xhrFor('roof.jpg').respond(500)
await settle()
expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-title"]'))).toBe(
'Nothing was uploaded',
)
expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-sub"]'))).toBe(
'2 files need attention',
)
expect(uploadPanel()!.querySelector('[data-testid="upload-summary"]')).toBeNull()
})
})
describe('The upload panel and its rows', () => {
function sixImages() {
return Array.from({ length: 6 }, (_, index) =>
photo(`img-${index + 1}.jpg`, 2.6 * MEGABYTE),
)
}
it('uploads two at a time, in order, with progress', async () => {
await mountPage()
await chooseFiles(sixImages())
const panel = uploadPanel()!
expect(textOf(panel.querySelector('[data-testid="upload-title"]'))).toBe(
'Uploading 6 images',
)
expect(textOf(panel.querySelector('[data-testid="upload-sub"]'))).toBe('0 of 6 done')
expect(findButton('Cancel all', panel)).toBeTruthy()
expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([
'uploading',
'uploading',
'waiting',
'waiting',
'waiting',
'waiting',
])
expect(textOf(uploadRows()[2])).toContain('Waiting')
expect(textOf(uploadRows()[2])).toContain('2.6 MB')
expect(inFlight()).toHaveLength(2)
xhrFor('img-1.jpg').progress(1.1 * MEGABYTE, 2.6 * MEGABYTE)
await flushPromises()
expect(textOf(uploadRows()[0])).toContain('Uploading 1.1 of 2.6 MB')
expect(
uploadRows()[0]!.querySelector('[role="progressbar"]')!.getAttribute('aria-valuenow'),
).toBe('42')
expect(
uploadRows()[0]!.querySelector(`[aria-label="Cancel upload of img-1.jpg"]`),
).toBeTruthy()
xhrFor('img-1.jpg').respond(201)
await settle()
expect(textOf(uploadRows()[0])).toContain('Added to your library')
await vi.waitFor(() =>
expect(uploadRows()[0]!.querySelector('svg.iconify--lucide')).toBeTruthy(),
)
expect(textOf(panel.querySelector('[data-testid="upload-sub"]'))).toBe('1 of 6 done')
expect(uploadRows()[2]!.getAttribute('data-status')).toBe('uploading')
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual([
'img-1.jpg',
'img-2.jpg',
'img-3.jpg',
])
expect(inFlight()).toHaveLength(2)
expect(FakeXhr.instances[0]!.url).toBe('/v1/media/')
expect(FakeXhr.instances[0]!.method).toBe('POST')
expect(FakeXhr.instances[0]!.withCredentials).toBe(true)
})
it('never has more than two requests in flight while draining the queue', async () => {
await mountPage()
await chooseFiles(sixImages())
let peak = inFlight().length
for (let guard = 0; guard < 6 && inFlight().length > 0; guard++) {
inFlight()[0]!.respond(201)
await settle()
peak = Math.max(peak, inFlight().length)
}
expect(peak).toBe(2)
expect(FakeXhr.instances).toHaveLength(6)
expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-title"]'))).toBe(
'Upload finished',
)
})
it('counts settled rows in the header', async () => {
await mountPage({ hash: hashHandlerFor('dup.jpg') })
await chooseFiles([
photo('added.jpg'),
photo('dup.jpg'),
fileWith('rej.tif', 'image/tiff', 1000),
photo('fail.jpg'),
photo('up-1.jpg'),
photo('up-2.jpg'),
])
xhrFor('added.jpg').respond(201)
await settle()
xhrFor('fail.jpg').respond(500)
await settle()
expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([
'added',
'duplicate',
'rejected',
'failed',
'uploading',
'uploading',
])
expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-sub"]'))).toBe(
'4 of 6 done',
)
})
it('uses the singular for one image', async () => {
await mountPage()
await chooseFiles([photo('one.jpg')])
expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-title"]'))).toBe(
'Uploading 1 image',
)
})
it('appends files added while the panel is open', async () => {
await mountPage()
await chooseFiles([photo('a.jpg'), photo('b.jpg')])
await chooseFiles([photo('c.jpg')])
expect(uploadRows()).toHaveLength(3)
expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-title"]'))).toBe(
'Uploading 3 images',
)
expect(textOf(uploadRows()[2])).toContain('c.jpg')
})
it('refreshes the list in the background after a 201', async () => {
let calls = 0
await mountPage({
list: () =>
++calls === 1
? listBody(fixture())
: listBody(fixture(), { all: 13, unused: 2, duplicates: 2 }),
})
expect(listCalls()).toHaveLength(1)
await chooseFiles([photo('a.jpg')])
xhrFor('a.jpg').respond(201)
await settle()
expect(listCalls()).toHaveLength(2)
expect(document.body.querySelectorAll('[data-testid="media-skeleton-card"]')).toHaveLength(
0,
)
expect(tab('All').textContent).toContain('13')
})
})
describe('A row whose content already exists', () => {
const existingText = 'Same as "floorboards.jpg", uploaded 14 Jun 2026 by Inès Duarte.'
async function mountWithDuplicate(handlers: Handlers = {}) {
await mountPage({ hash: hashHandlerFor('floorboards-2.jpg'), ...handlers })
await chooseFiles([fileWith('floorboards-2.jpg', 'image/jpeg', 2.4 * MEGABYTE)])
}
it('looks up the hash before any POST and shows the duplicate row, naming the oldest match', async () => {
await mountWithDuplicate()
expect(hashLookups().map(([path]) => path)).toEqual([
`/v1/media/?hash=${hexOf('floorboards-2.jpg')}`,
])
expect(FakeXhr.instances).toHaveLength(0)
const row = uploadRows()[0]!
expect(row.getAttribute('data-status')).toBe('duplicate')
expect(textOf(row)).toContain('Already in your library')
expect(textOf(row)).toContain(existingText)
expect(findButton('Use existing', row)).toBeTruthy()
expect(findButton('Upload anyway', row)).toBeTruthy()
})
it('hashes inside the concurrency slot while the row reads Waiting', async () => {
const first = deferred<unknown>()
const second = deferred<unknown>()
const answers = [first, second]
await mountPage({ hash: () => (answers.shift() ?? deferred<unknown>()).promise })
await chooseFiles([photo('a.jpg'), photo('b.jpg'), photo('c.jpg')])
expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([
'waiting',
'waiting',
'waiting',
])
expect(hashLookups()).toHaveLength(2)
expect(FakeXhr.instances).toHaveLength(0)
first.resolve(listBody([]))
await settle()
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual(['a.jpg'])
expect(uploadRows()[0]!.getAttribute('data-status')).toBe('uploading')
expect(hashLookups()).toHaveLength(2)
xhrFor('a.jpg').respond(201)
await settle()
expect(hashLookups()).toHaveLength(3)
})
it('releases the concurrency slot so the next file starts', async () => {
await mountPage({ hash: hashHandlerFor('dup.jpg') })
await chooseFiles([photo('dup.jpg'), photo('b.jpg'), photo('c.jpg'), photo('d.jpg')])
expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([
'duplicate',
'uploading',
'uploading',
'waiting',
])
})
it('Use existing drops the row and selects the image', async () => {
await mountWithDuplicate()
findButton('Use existing')!.click()
await settle()
expect(uploadPanel()).toBeNull()
expect(textOf(bulkBar())).toContain('1 selected')
expect(announcer()).toBe('floorboards.jpg selected, 1 selected')
})
it('Use existing resets the search and the tab when the image is not loaded', async () => {
await mountPage(
{
hash: hashHandlerFor('floorboards-2.jpg'),
list: (path) =>
path.includes('filter=unused')
? listBody(fixture([8, 10]))
: listBody(fixture()),
},
'/media?filter=unused',
)
await chooseFiles([fileWith('floorboards-2.jpg', 'image/jpeg', 2.4 * MEGABYTE)])
findButton('Use existing')!.click()
await settle()
expect(router.currentRoute.value.query).toEqual({})
expect(tab('All').getAttribute('aria-selected')).toBe('true')
expect(textOf(bulkBar())).toContain('1 selected')
expect(announcer()).toBe('floorboards.jpg selected, 1 selected')
})
it('Use existing drops the row without selecting when the image is still not loaded', async () => {
await mountWithDuplicate({
list: () => listBody(fixture().filter((item) => item.id !== 12)),
})
findButton('Use existing')!.click()
await settle()
expect(uploadPanel()).toBeNull()
expect(bulkBar()).toBeNull()
})
it('Upload anyway stores a second copy and the Duplicates count follows', async () => {
let calls = 0
await mountWithDuplicate({
list: () =>
++calls === 1
? listBody(fixture())
: listBody(fixture(), { all: 13, unused: 2, duplicates: 3 }),
})
findButton('Upload anyway')!.click()
await settle()
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual(['floorboards-2.jpg'])
expect(hashLookups()).toHaveLength(1)
expect(uploadRows()[0]!.getAttribute('data-status')).toBe('uploading')
FakeXhr.instances[0]!.respond(201)
await settle()
expect(textOf(uploadRows()[0])).toContain('Added to your library')
expect(tab('Duplicates').textContent).toContain('3')
})
it.each([
[
'crypto.subtle is undefined',
() => vi.stubGlobal('crypto', { subtle: undefined }),
undefined,
],
['the lookup fails with 500', () => undefined, () => Promise.reject(new ApiError(500))],
[
'the lookup never reaches the server',
() => undefined,
() => Promise.reject(new NetworkError()),
],
])('skips the duplicate check when %s', async (_name, prepare, hash) => {
prepare()
await mountPage({ hash })
await chooseFiles([photo('a.jpg')])
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual(['a.jpg'])
expect(uploadRows()[0]!.getAttribute('data-status')).toBe('uploading')
})
})
describe('Server answers for an upload that passed the checks', () => {
it.each([
['roof.png', 1.4 * MEGABYTE, 201, 'Added to your library'],
['scan.png', 9.9 * MEGABYTE, 413, `scan.png is 9.9 MB ${REJECTION_TEXT}`],
['notes.png', 210 * 1024, 415, `notes.png isn't an image ${REJECTION_TEXT}`],
['broken.png', 3 * MEGABYTE, 400, `broken.png couldn't be read ${UNREADABLE_TEXT}`],
['roof.png', 1.4 * MEGABYTE, 500, `Upload failed ${FAILED_TEXT} Try again`],
['roof.png', 1.4 * MEGABYTE, 0, `Upload failed ${FAILED_TEXT} Try again`],
])('%s of %i bytes resolving %i reads the right row', async (name, size, status, text) => {
await mountPage()
await chooseFiles([fileWith(name, 'image/png', size)])
if (status === 0) {
FakeXhr.instances[0]!.fail()
} else {
FakeXhr.instances[0]!.respond(status)
}
await settle()
const row = uploadRows()[0]!
expect(textOf(row)).toContain(text)
await vi.waitFor(() => expect(row.querySelector('svg.iconify--lucide')).toBeTruthy())
})
it('Try again retries one file with no new duplicate lookup', async () => {
await mountPage()
await chooseFiles([photo('roof.jpg'), photo('other.jpg')])
xhrFor('roof.jpg').respond(500)
await settle()
expect(hashLookups()).toHaveLength(2)
findButton('Try again')!.click()
await settle()
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual([
'roof.jpg',
'other.jpg',
'roof.jpg',
])
expect(hashLookups()).toHaveLength(2)
expect(uploadRows()[0]!.getAttribute('data-status')).toBe('uploading')
})
})
describe('Cancelling one upload or all of them', () => {
it('the row x aborts that request, removes the row and starts the next waiting file', async () => {
await mountPage()
await chooseFiles([photo('a.jpg'), photo('b.jpg'), photo('c.jpg')])
document.body
.querySelector<HTMLButtonElement>('[aria-label="Cancel upload of a.jpg"]')!
.click()
await settle()
expect(xhrFor('a.jpg').aborted).toBe(true)
expect(uploadRows().map((row) => textOf(row).split(' ')[0])).toEqual(['b.jpg', 'c.jpg'])
expect(inFlight().map((xhr) => xhr.fileName)).toEqual(['b.jpg', 'c.jpg'])
})
it('Cancel all aborts what is in flight, removes waiting rows and keeps settled ones', async () => {
await mountPage()
await chooseFiles([
photo('a.jpg'),
photo('b.jpg'),
photo('c.jpg'),
fileWith('bad.tif', 'image/tiff', 10),
])
xhrFor('a.jpg').respond(201)
await settle()
findButton('Cancel all')!.click()
await settle()
expect(xhrFor('b.jpg').aborted).toBe(true)
expect(xhrFor('c.jpg').aborted).toBe(true)
expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([
'added',
'rejected',
])
})
it('closes the panel when no row is left', async () => {
await mountPage()
await chooseFiles([photo('a.jpg'), photo('b.jpg'), photo('c.jpg')])
findButton('Cancel all')!.click()
await settle()
expect(uploadPanel()).toBeNull()
expect(FakeXhr.instances.every((xhr) => xhr.aborted)).toBe(true)
})
})
describe('The panel settles with a summary', () => {
it('settles with the counts, announces the summary and Dismiss removes every row', async () => {
await mountPage({ hash: hashHandlerFor('dup.jpg') })
await chooseFiles([
photo('one.jpg'),
photo('two.jpg'),
photo('dup.jpg'),
fileWith('scan.tif', 'image/tiff', 14.2 * MEGABYTE),
photo('roof.jpg'),
])
xhrFor('one.jpg').respond(201)
xhrFor('two.jpg').respond(201)
await settle()
xhrFor('roof.jpg').respond(500)
await settle()
const panel = uploadPanel()!
expect(textOf(panel.querySelector('[data-testid="upload-title"]'))).toBe('Upload finished')
expect(textOf(panel.querySelector('[data-testid="upload-sub"]'))).toBe('5 images')
expect(findButton('Dismiss', panel)).toBeTruthy()
expect(findButton('Cancel all', panel)).toBeUndefined()
expect(textOf(panel.querySelector('[data-testid="upload-summary"]'))).toBe(
'2 uploaded · 1 already in your library · 2 need attention',
)
expect(announcer()).toBe('2 uploaded · 1 already in your library · 2 need attention')
findButton('Dismiss', panel)!.click()
await flushPromises()
expect(uploadPanel()).toBeNull()
expect(uploadRows()).toHaveLength(0)
})
})
describe('Too many uploads (429)', () => {
async function rateLimit(retryAfter?: string, width = 1440) {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
await mountPage({}, '/media', width)
await chooseFiles([photo('a.jpg'), photo('b.jpg'), photo('c.jpg'), photo('d.jpg')])
xhrFor('a.jpg').respond(429, retryAfter === undefined ? {} : { 'Retry-After': retryAfter })
await settle()
}
it('shows the alert, counts down in a disabled button and pauses the queue', async () => {
await rateLimit('42')
const alert = Array.from(document.body.querySelectorAll('[role="status"]')).find(
(element) => textOf(element).includes('Too many uploads'),
)
expect(textOf(alert)).toBe(
'Too many uploads For your security, wait a moment before uploading again.',
)
const button = findButton('Try again in 0:42')!
expect(button.disabled).toBe(true)
await vi.waitFor(() => expect(button.querySelector('svg.iconify--lucide')).toBeTruthy())
expect(findButton('Upload')).toBeUndefined()
expect(uploadRows()[0]!.getAttribute('data-status')).toBe('waiting')
expect(inFlight().map((xhr) => xhr.fileName)).toEqual(['b.jpg'])
expect(FakeXhr.instances).toHaveLength(2)
})
it('resumes by itself when the countdown reaches 0:00, keeping Added rows', async () => {
await rateLimit('42')
xhrFor('b.jpg').respond(201)
await settle()
expect(FakeXhr.instances).toHaveLength(2)
vi.advanceTimersByTime(1000)
await flushPromises()
expect(findButton('Try again in 0:41')).toBeTruthy()
vi.advanceTimersByTime(41000)
await settle()
expect(document.body.textContent).not.toContain('Too many uploads')
expect(findButton('Upload')!.disabled).toBe(false)
expect(uploadRows()[1]!.getAttribute('data-status')).toBe('added')
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual([
'a.jpg',
'b.jpg',
'a.jpg',
'c.jpg',
])
})
it('counts 60 seconds when Retry-After is missing or unreadable', async () => {
await rateLimit(undefined)
expect(findButton('Try again in 1:00')).toBeTruthy()
})
it('disables the mobile pinned button too', async () => {
await rateLimit('42', MOBILE_WIDTH)
const pinned = findButton('Try again in 0:42')!
expect(pinned.disabled).toBe(true)
expect(pinned.className).toContain('h-12')
})
it('ignores drops and the drop area while counting', async () => {
await rateLimit('42')
const before = FakeXhr.instances.length
dragEvent('dragenter', ['Files'])
await flushPromises()
expect(document.body.querySelector('[data-testid="media-drop-area"]')).toBeNull()
dragEvent('drop', ['Files'], [photo('z.jpg')])
await settle()
expect(FakeXhr.instances).toHaveLength(before)
expect(uploadRows()).toHaveLength(4)
})
})
describe('Offline', () => {
async function goOffline() {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
}
it('disables the Upload controls and ignores drops, then enables them again', async () => {
await mountPage()
await goOffline()
expect(document.body.textContent).toContain("You're offline.")
expect(findButton('Upload')!.disabled).toBe(true)
dragEvent('dragenter', ['Files'])
await flushPromises()
expect(document.body.querySelector('[data-testid="media-drop-area"]')).toBeNull()
dragEvent('drop', ['Files'], [photo('z.jpg')])
await settle()
expect(FakeXhr.instances).toHaveLength(0)
expect(cards()).toHaveLength(12)
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
window.dispatchEvent(new Event('online'))
await flushPromises()
expect(findButton('Upload')!.disabled).toBe(false)
})
it('disables the mobile pinned button and "Upload an image"', async () => {
await mountPage({}, '/media', MOBILE_WIDTH)
await goOffline()
expect(findButton('Upload')!.disabled).toBe(true)
await unmountPage()
stubDigest()
stubXhr()
await mountPage({ list: () => listBody([], { all: 0, unused: 0, duplicates: 0 }) })
await goOffline()
expect(findButton('Upload an image')!.disabled).toBe(true)
})
})
describe('Session errors on the upload calls are global', () => {
it.each([
[401, '/login', { redirect: '/media', reason: 'expired' }],
[403, '/register/pending', {}],
])(
'an XHR upload resolving %i sends the user to %s with no local copy',
async (status, path, query) => {
await mountPage()
useSessionStore().session = {
verified: false,
username: 'marta',
id: 1,
displayName: '',
role: 'owner',
}
await chooseFiles([photo('a.jpg')])
FakeXhr.instances[0]!.respond(status)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe(path))
await settle()
expect(router.currentRoute.value.path).toBe(path)
expect(router.currentRoute.value.query).toEqual(query)
expect(document.body.textContent).not.toContain('Upload failed')
expect(toasts.add).not.toHaveBeenCalled()
},
)
})
+22 -27
View File
@@ -18,7 +18,7 @@ let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: toastAdd }) }
return { ...actual, useToast: () => ({ add: toastAdd, remove: vi.fn() }) }
})
type Row = [
@@ -455,32 +455,28 @@ describe('Default, many images', () => {
expect(document.body.querySelector('[data-testid="image-count"]')?.textContent).toBe(
'12 images',
)
expect(document.body.textContent).not.toContain('Select all')
expect(document.body.querySelector('input[type="checkbox"], [role="checkbox"]')).toBeNull()
expect(document.body.textContent).toContain('Select all')
expect(document.body.querySelectorAll('[role="checkbox"]')).toHaveLength(13)
expect(searchInput()).toBeTruthy()
expect(textOf(ariaLabel('Sort'))).toBe('Newest')
expect(ariaLabel('Grid view').getAttribute('aria-pressed')).toBe('true')
expect(ariaLabel('List view').getAttribute('aria-pressed')).toBe('false')
})
it('renders the header Upload button disabled and inert', async () => {
it('renders the header Upload button enabled', async () => {
await mountPage()
const upload = findButton('Upload')!
expect(upload.disabled).toBe(true)
upload.click()
await flushPromises()
expect(panel()).toBeNull()
expect(findButton('Upload')!.disabled).toBe(false)
})
it('renders the mobile pinned Upload button disabled, full width, 48 px', async () => {
it('renders the mobile pinned Upload button enabled, full width, 48 px', async () => {
await mountPage({}, '/media', MOBILE_WIDTH)
const buttons = Array.from(document.body.querySelectorAll('button')).filter(
(button) => textOf(button) === 'Upload',
)
expect(buttons).toHaveLength(1)
expect(buttons[0]!.disabled).toBe(true)
expect(buttons[0]!.disabled).toBe(false)
expect(buttons[0]!.className).toContain('h-12')
expect(buttons[0]!.className).toContain('inset-x-4')
expect(document.body.querySelector('nav[aria-label="breadcrumb"]')?.className).toContain(
@@ -614,7 +610,7 @@ describe('The query string is the source of truth', () => {
['?q=wall', 'q=wall&filter=all&sort=newest&limit=100'],
['?view=list', 'filter=all&sort=newest&limit=100'],
['?filter=bogus&sort=zzz', 'filter=all&sort=newest&limit=100'],
['?filter=duplicates', 'filter=all&sort=newest&limit=100'],
['?filter=duplicates', 'filter=duplicates&sort=newest&limit=100'],
])('opening /media%s requests %s', async (url, request) => {
await mountPage({}, `/media${url}`)
@@ -638,7 +634,7 @@ describe('The query string is the source of truth', () => {
document.body.innerHTML = ''
await mountPage({}, '/media?filter=duplicates')
expect(tab('All').getAttribute('aria-selected')).toBe('true')
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
})
it('drops unknown values from the URL', async () => {
@@ -703,23 +699,20 @@ describe('The query string is the source of truth', () => {
})
})
describe('The disabled Duplicates tab', () => {
it('does not become selected, does not navigate and sends no request', async () => {
describe('The Duplicates tab', () => {
it('becomes selected, writes the filter to the URL and requests the duplicates', async () => {
await mountPage()
const duplicates = tab('Duplicates')
expect(duplicates.hasAttribute('disabled')).toBe(true)
expect(duplicates.hasAttribute('data-disabled')).toBe(true)
expect(duplicates.hasAttribute('disabled')).toBe(false)
duplicates.focus()
duplicates.click()
duplicates.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
press(duplicates, 'Enter')
duplicates.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
await flushPromises()
expect(duplicates.getAttribute('aria-selected')).toBe('false')
expect(tab('All').getAttribute('aria-selected')).toBe('true')
expect(router.currentRoute.value.query).toEqual({})
expect(listCalls()).toHaveLength(1)
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&limit=100')
})
})
@@ -799,7 +792,7 @@ describe('Empty library and no results', () => {
expect(document.body.textContent).toContain(
'Upload your first image. You can use it in any article.',
)
expect(findButton('Upload an image')!.disabled).toBe(true)
expect(findButton('Upload an image')!.disabled).toBe(false)
expect(document.body.textContent).toContain('PNG, JPEG, WEBP, SVG · max 10 MB each')
})
@@ -885,7 +878,7 @@ describe('Hover, focus and keyboard', () => {
expect(document.activeElement).toBe(list[2])
})
it('opens the panel on Enter and does nothing on Space', async () => {
it('opens the panel on Enter and Space does not open it', async () => {
await mountPage()
cards()[1]!.focus()
@@ -1000,7 +993,7 @@ describe('Detail panel', () => {
expect(textOf(panel())).toContain('This image is safe to delete. No article shows it.')
})
it('duplicate: looks up the hash, excludes the image itself and disables Review duplicates', async () => {
it('duplicate: looks up the hash, excludes the image itself and Review duplicates opens the tab', async () => {
await mountPage()
await clickCard('floorboards.jpg')
@@ -1012,10 +1005,12 @@ describe('Detail panel', () => {
'floorboards-copy.jpg and IMG_2208.jpg have the same content.',
)
const review = findButton('Review duplicates', alert)!
expect(review.disabled).toBe(true)
expect(review.disabled).toBe(false)
review.click()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/media')
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
expect(panel()).toBeNull()
})
it('duplicate: uses the singular for one other copy', async () => {
+587
View File
@@ -0,0 +1,587 @@
import { vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { ApiError, useApi } from '@/composables/useApi'
import type { MediaItem } from '@/composables/useMedia'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import MediaView from '../MediaView.vue'
type Row = [
number,
string,
number,
number | null,
number | null,
string,
number,
string,
string,
boolean,
string,
]
const ROWS: Row[] = [
[
1,
'north-wall.jpg',
4089446,
4032,
3024,
'image/jpeg',
2,
'Marta Alves',
'2026-09-12T12:00:00Z',
false,
'0a',
],
[
2,
'kitchen-after.webp',
1258291,
2400,
1600,
'image/webp',
3,
'Inès Duarte',
'2026-09-08T12:00:00Z',
false,
'1b',
],
[
3,
'kitchen-before.webp',
1153434,
2400,
1600,
'image/webp',
1,
'Inès Duarte',
'2026-09-08T11:00:00Z',
false,
'2c',
],
[
4,
'logo-renew.svg',
8192,
null,
null,
'image/svg+xml',
5,
'jonas',
'2026-09-05T12:00:00Z',
false,
'3d',
],
[
5,
'floorboards-copy.jpg',
2516582,
3600,
2400,
'image/jpeg',
3,
'Marta Alves',
'2026-09-02T12:00:00Z',
true,
'0e',
],
[
6,
'IMG_2208.jpg',
2516582,
3600,
2400,
'image/jpeg',
2,
'jonas',
'2026-08-30T12:00:00Z',
true,
'0e',
],
[
7,
'IMG_4471.jpg',
3250585,
3600,
2400,
'image/jpeg',
2,
'Marta Alves',
'2026-08-29T12:00:00Z',
true,
'2f',
],
[
8,
'window-frame.jpg',
1887437,
3000,
2000,
'image/jpeg',
0,
'Inès Duarte',
'2026-08-28T12:00:00Z',
false,
'3a',
],
[
9,
'stone-wall-detail.jpg',
3250585,
3600,
2400,
'image/jpeg',
1,
'jonas',
'2026-08-21T12:00:00Z',
true,
'2f',
],
[
10,
'bathroom-tiles.png',
880640,
1800,
1200,
'image/png',
0,
'Marta Alves',
'2026-08-04T12:00:00Z',
false,
'1b',
],
[
11,
'lime-plaster.jpg',
2202009,
3200,
2133,
'image/jpeg',
2,
'Inès Duarte',
'2026-07-19T12:00:00Z',
false,
'1c',
],
[
12,
'floorboards.jpg',
2516582,
3600,
2400,
'image/jpeg',
4,
'Inès Duarte',
'2026-06-14T12:00:00Z',
true,
'0e',
],
]
export const FULL_COUNTS = { all: 12, unused: 2, duplicates: 2 }
export const MEGABYTE = 1024 * 1024
export function fixture(ids?: number[]): MediaItem[] {
return ROWS.filter(([id]) => !ids || ids.includes(id)).map(
([
id,
filename,
size,
width,
height,
content_type,
used_in,
uploaded_by,
created_at,
duplicate,
sha256,
]) => ({
id,
filename,
url: `/media/${filename}`,
size,
content_type,
width,
height,
sha256,
uploaded_by,
created_at,
used_in,
duplicate,
}),
)
}
export function listBody(items: MediaItem[], counts = FULL_COUNTS) {
return { items, next_cursor: null, counts }
}
export 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 }
}
export interface Handlers {
list?: (path: string) => unknown
usage?: (id: number) => unknown
hash?: (path: string) => unknown
remove?: (id: number) => unknown
merge?: (body: { keep_id: number; merge_ids: number[] }) => unknown
}
export function libraryHandlers(
outcome: (id: number) => unknown = () => undefined,
ids?: number[],
): Handlers {
const gone = new Set<number>()
return {
list: () => listBody(fixture(ids).filter((item) => !gone.has(item.id))),
remove: async (id) => {
try {
const result = await outcome(id)
gone.add(id)
return result
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
gone.add(id)
}
throw error
}
},
}
}
export const kit = {
requestMock: vi.fn(),
wrapper: null as VueWrapper | null,
}
function dispatcher(handlers: Handlers) {
return (path: string, options?: RequestInit) => {
const method = options?.method ?? 'GET'
if (method === 'GET' && path.startsWith('/v1/media/?hash=')) {
return (handlers.hash ?? (() => listBody([], FULL_COUNTS)))(path)
}
if (method === 'GET' && path.startsWith('/v1/media/?')) {
return (handlers.list ?? (() => listBody(fixture())))(path)
}
const usage = /^\/v1\/media\/(\d+)\/usage$/.exec(path)
if (method === 'GET' && usage) {
return (handlers.usage ?? (() => []))(Number(usage[1]))
}
if (method === 'POST' && path === '/v1/media/merge') {
return (handlers.merge ?? (() => ({ articles_updated: 0, merged: 0 })))(
JSON.parse(String(options?.body)),
)
}
const single = /^\/v1\/media\/(\d+)$/.exec(path)
if (method === 'DELETE' && single) {
return (handlers.remove ?? (() => undefined))(Number(single[1]))
}
throw new Error(`Unexpected request: ${method} ${path}`)
}
}
function fetchFrom(dispatch: ReturnType<typeof dispatcher>) {
return vi.fn(async (path: string, init?: RequestInit) => {
const reply = (status: number, body?: unknown) => ({
ok: status >= 200 && status < 300,
status,
headers: new Headers(),
json: async () => body,
text: async () => '',
})
try {
const body = await dispatch(path, init)
return reply(body === undefined ? 204 : 200, body)
} catch (error) {
if (error instanceof ApiError) {
return reply(error.status)
}
throw error
}
})
}
export async function mountPage(
handlers: Handlers = {},
url = '/media',
width = DESKTOP_WIDTH,
options: { realApi?: boolean } = {},
) {
setViewportWidth(width)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1, displayName: '', role: 'owner' }
session.status = 'ready'
const dispatch = dispatcher(handlers)
if (options.realApi) {
kit.requestMock = fetchFrom(dispatch)
vi.stubGlobal('fetch', kit.requestMock)
} else {
kit.requestMock = vi.fn(async (path: string, init?: RequestInit) => dispatch(path, init))
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
request: kit.requestMock,
})
}
await router.push(url)
kit.wrapper = mount(MediaView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return kit.wrapper
}
export async function unmountPage() {
kit.wrapper?.unmount()
kit.wrapper = null
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
await router.push('/login')
document.body.innerHTML = ''
}
export function textOf(element: Element | null | undefined) {
if (!element) {
return ''
}
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
const parts: string[] = []
while (walker.nextNode()) {
const text = walker.currentNode.textContent?.replace(/\s+/g, ' ').trim()
if (text) {
parts.push(text)
}
}
return parts.join(' ')
}
export function cards() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-media-card]'))
}
export function cardFor(filename: string) {
return cards().find((card) => textOf(card).includes(filename))!
}
export function tabs() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]'))
}
export function tab(label: string) {
return tabs().find((element) => textOf(element).startsWith(label))!
}
export function findButton(label: string, root: ParentNode = document.body) {
return Array.from(root.querySelectorAll<HTMLButtonElement>('button')).find(
(button) => textOf(button) === label,
)
}
export function panel() {
return document.body.querySelector<HTMLElement>('[role="dialog"]')
}
export function dialog() {
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')
}
export function listCalls() {
return kit.requestMock.mock.calls.filter(
([path]) =>
typeof path === 'string' && path.startsWith('/v1/media/?') && !path.includes('hash='),
)
}
export function lastListPath() {
return listCalls().at(-1)![0] as string
}
export function callsTo(method: string, pattern: RegExp) {
return kit.requestMock.mock.calls.filter(
([path, options]) => (options?.method ?? 'GET') === method && pattern.test(path),
)
}
export function press(element: Element, key: string) {
element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
}
export function pressOnWindow(key: string) {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }),
)
}
export async function settle() {
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 10))
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 10))
await flushPromises()
}
export function announcer() {
return textOf(document.body.querySelector('[data-testid="media-announcer"]'))
}
export function checkboxFor(filename: string) {
return document.body.querySelector<HTMLElement>(
`[role="checkbox"][aria-label="Select ${filename}"]`,
)!
}
export function selectAllCheckbox() {
return document.body
.querySelector('[data-testid="image-count"]')!
.parentElement!.querySelector<HTMLElement>('[role="checkbox"]')!
}
export function tableHeaderCheckbox() {
return document.body.querySelector<HTMLElement>('[role="checkbox"][aria-label="Select all"]')!
}
export function bulkBar() {
return document.body.querySelector<HTMLElement>('[data-testid="media-bulk-bar"]')
}
export function fileWith(name: string, type: string, size?: number, content = name) {
const file = new File([content], name, { type })
if (size !== undefined) {
Object.defineProperty(file, 'size', { value: size })
}
return file
}
export function hexOf(content: string) {
return Array.from(new TextEncoder().encode(content), (byte) =>
byte.toString(16).padStart(2, '0'),
)
.join('')
.padEnd(64, '0')
.slice(0, 64)
}
export function stubDigest() {
const digest = vi.fn(async (_algorithm: string, data: ArrayBuffer) => {
const hex = Array.from(new Uint8Array(data), (byte) => byte.toString(16).padStart(2, '0'))
.join('')
.padEnd(64, '0')
.slice(0, 64)
return new Uint8Array(hex.match(/../g)!.map((pair) => Number.parseInt(pair, 16))).buffer
})
vi.stubGlobal('crypto', { subtle: { digest } })
return digest
}
export class FakeXhr extends EventTarget {
static instances: FakeXhr[] = []
upload = new EventTarget()
method = ''
url = ''
status = 0
responseText = ''
withCredentials = false
body: FormData | null = null
aborted = false
finished = false
headers: Record<string, string> = {}
open(method: string, url: string) {
this.method = method
this.url = url
}
send(body: FormData) {
this.body = body
FakeXhr.instances.push(this)
}
abort() {
this.aborted = true
this.finished = true
this.dispatchEvent(new Event('abort'))
}
getResponseHeader(name: string) {
return this.headers[name.toLowerCase()] ?? null
}
progress(loaded: number, total: number) {
this.upload.dispatchEvent(Object.assign(new Event('progress'), { loaded, total }))
}
respond(status: number, headers: Record<string, string> = {}, text = '') {
this.status = status
this.headers = Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
)
this.responseText = text
this.finished = true
this.dispatchEvent(new Event('load'))
}
fail() {
this.finished = true
this.dispatchEvent(new Event('error'))
}
get fileName() {
return (this.body?.get('file') as File).name
}
}
export function stubXhr() {
FakeXhr.instances = []
vi.stubGlobal('XMLHttpRequest', FakeXhr)
}
export function inFlight() {
return FakeXhr.instances.filter((xhr) => !xhr.finished)
}
export function xhrFor(name: string) {
return FakeXhr.instances.find((xhr) => xhr.fileName === name)!
}
export function fileInput() {
return document.body.querySelector<HTMLInputElement>('input[type="file"]')!
}
export async function chooseFiles(files: File[]) {
const input = fileInput()
Object.defineProperty(input, 'files', { value: files, configurable: true })
input.dispatchEvent(new Event('change', { bubbles: true }))
await settle()
}
export function dragEvent(type: string, types: string[], files: File[] = []) {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', { value: { types, files } })
document.body.dispatchEvent(event)
return event
}
export function uploadPanel() {
return document.body.querySelector<HTMLElement>('[data-testid="media-upload-panel"]')
}
export function uploadRows() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-testid="upload-row"]'))
}
export function rowTexts() {
return uploadRows().map(textOf)
}
+369 -19
View File
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto'
import type { Page } from 'playwright'
export interface ApiMock {
@@ -996,6 +997,13 @@ const mediaFloorboardsUsage = [
{ id: 13, title: 'Why we reuse original floorboards', published: true },
]
const mediaDuplicateIds = [12, 5, 6]
const mediaDuplicateGroups = [mediaDuplicateIds, [7, 9]]
function mediaHashFor(sample: MediaSample) {
const group = mediaDuplicateGroups.find((ids) => ids.includes(sample.id))
const key = group ? group[0]! : sample.id
return `${sample.tone}${String(key).padStart(2, '0').repeat(31)}a`
}
function mediaItem(sample: MediaSample, filename = sample.filename) {
return {
@@ -1006,7 +1014,7 @@ function mediaItem(sample: MediaSample, filename = sample.filename) {
content_type: sample.contentType,
width: sample.width,
height: sample.height,
sha256: `${sample.tone}${String(sample.id).padStart(2, '0').repeat(31)}a`,
sha256: mediaHashFor(sample),
uploaded_by: sample.uploadedBy,
created_at: sample.createdAt,
used_in: sample.usedIn,
@@ -1061,7 +1069,257 @@ function mediaState(
return { path, origin: mediaOrigin, api, steps }
}
const mediaPending: VisualState = { path: '/media', skip: 'pending' }
const mediaDuplicateListIds = [12, 5, 6, 7, 9]
const mediaMergedDuplicateIds = [7, 9]
const mediaMergedCounts = { all: 10, unused: 2, duplicates: 1 }
const mediaMegabyte = 1024 * 1024
const mediaHungHashLabel = 'attic'
const mediaProgressRatio = 0.423
const mediaRetryAfter = '42'
const mediaDuplicatesPath = '/media?filter=duplicates'
function mediaListApi(
lists: { first: unknown; later?: unknown },
overrides: Record<string, ApiMock> = {},
) {
return mediaApi({
'GET /v1/media/': {
status: 200,
bodyFor: (_url, call) => (call === 1 ? lists.first : (lists.later ?? lists.first)),
},
...overrides,
})
}
function mediaDuplicatesApi(
later?: { ids: number[]; counts: typeof mediaFullCounts },
overrides: Record<string, ApiMock> = {},
) {
const duplicates = mediaList(mediaItems(mediaDuplicateListIds))
return mediaListApi(
{
first: duplicates,
later: later ? mediaList(mediaItems(later.ids), later.counts) : duplicates,
},
overrides,
)
}
function mediaBulkApi(
deleteResponses: Record<string, ApiMock>,
removed: number[],
counts: typeof mediaFullCounts,
) {
return mediaListApi(
{
first: mediaList(mediaItems()),
later: mediaList(mediaItems(undefined, removed), counts),
},
deleteResponses,
)
}
function mediaCheckbox(page: Page, filename: string) {
return page.getByRole('checkbox', { name: `Select ${filename}`, exact: true })
}
async function selectMediaCards(page: Page) {
for (const filename of ['north-wall.jpg', 'kitchen-after.webp', 'window-frame.jpg']) {
await mediaCheckbox(page, filename).click()
}
await page.getByTestId('media-bulk-bar').waitFor()
await scrollToTop(page)
await page.mouse.move(0, 0)
}
async function openBulkDeleteDialog(page: Page) {
await selectMediaCards(page)
await page.getByTestId('bulk-delete').click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
function confirmBulkDelete(wait?: string) {
return async (page: Page) => {
await openBulkDeleteDialog(page)
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete 3 images', exact: true })
.click()
if (wait) {
await page.getByText(wait, { exact: true }).first().waitFor()
await scrollToTop(page)
}
await page.mouse.move(0, 0)
}
}
interface MediaUploadFile {
name: string
mimeType: string
size: number
label?: string
}
type MediaUploadBehavior =
| { kind: 'hold' }
| { kind: 'respond'; status: number; headers?: Record<string, string> }
function mediaUploadFile(name: string, mimeType: string, size: number, label?: string) {
return { name, mimeType, size: Math.round(size), label } satisfies MediaUploadFile
}
const mediaUploadFiles = {
kitchenRoof: mediaUploadFile('kitchen-roof.jpg', 'image/jpeg', 2.2 * mediaMegabyte),
porchSteps: mediaUploadFile('porch-steps.jpg', 'image/jpeg', 2.6 * mediaMegabyte),
floorboards: mediaUploadFile('floorboards-2.jpg', 'image/jpeg', 2.4 * mediaMegabyte),
scan: mediaUploadFile('scan.tif', 'image/tiff', 14.2 * mediaMegabyte),
roof: mediaUploadFile('roof.png', 'image/png', 1.4 * mediaMegabyte),
attic: mediaUploadFile('attic.jpg', 'image/jpeg', 1.9 * mediaMegabyte, mediaHungHashLabel),
notes: mediaUploadFile('notes.pdf', 'application/pdf', 210 * 1024),
broken: mediaUploadFile('broken.png', 'image/png', 3 * mediaMegabyte),
}
const uploadCreated: MediaUploadBehavior = { kind: 'respond', status: 201 }
const uploadServerError: MediaUploadBehavior = { kind: 'respond', status: 500 }
const uploadUnreadable: MediaUploadBehavior = { kind: 'respond', status: 400 }
function mediaUploadPayload(file: MediaUploadFile) {
const buffer = Buffer.alloc(file.size)
buffer.write(file.label ?? file.name)
return { name: file.name, mimeType: file.mimeType, buffer }
}
function mediaUploadApi() {
const duplicateHash = createHash('sha256')
.update(mediaUploadPayload(mediaUploadFiles.floorboards).buffer)
.digest('hex')
return mediaApi({
'GET /v1/media/': {
status: 200,
bodyFor: (url) => {
const hash = url.searchParams.get('hash')
if (hash === null) {
return mediaList(mediaItems())
}
return hash === duplicateHash ? mediaList(mediaItems([12, 5, 6])) : mediaList([])
},
},
})
}
async function stubMediaUploads(page: Page, behaviors: Record<string, MediaUploadBehavior>) {
await page.evaluate(
({ behaviors, ratio, hungLabel }) => {
class StubRequest extends EventTarget {
upload = new EventTarget()
status = 0
responseText = ''
withCredentials = false
responseHeaders: Record<string, string> = {}
open() {}
send(form: FormData) {
const file = form.get('file') as File
const behavior = behaviors[file.name]
Promise.resolve().then(() => {
this.upload.dispatchEvent(
Object.assign(new Event('progress'), {
loaded: Math.round(
file.size * (behavior?.kind === 'hold' ? ratio : 1),
),
total: file.size,
}),
)
if (behavior?.kind === 'respond') {
this.status = behavior.status
this.responseHeaders = behavior.headers ?? {}
this.dispatchEvent(new Event('load'))
}
})
}
abort() {
this.dispatchEvent(new Event('abort'))
}
getResponseHeader(name: string) {
return this.responseHeaders[name] ?? null
}
}
;(window as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = StubRequest
const subtle = crypto.subtle
const digest = subtle.digest.bind(subtle)
subtle.digest = (algorithm, data) => {
const head = new TextDecoder().decode(
(data as ArrayBuffer).slice(0, hungLabel.length),
)
return head === hungLabel ? new Promise(() => {}) : digest(algorithm, data)
}
},
{ behaviors, ratio: mediaProgressRatio, hungLabel: mediaHungHashLabel },
)
}
async function chooseMediaFiles(page: Page, files: MediaUploadFile[]) {
await page.locator('input[type="file"]').setInputFiles(files.map(mediaUploadPayload))
}
function mediaUploadState(
files: MediaUploadFile[],
behaviors: Record<string, MediaUploadBehavior>,
settled: string,
): VisualState {
return mediaState(mediaUploadApi(), async (page) => {
await stubMediaUploads(page, behaviors)
await chooseMediaFiles(page, files)
await page.getByText(settled, { exact: true }).first().waitFor()
await page.mouse.move(0, 0)
})
}
async function dragFilesOverPage(page: Page) {
await page.evaluate(() => {
const event = new Event('dragenter', { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', { value: { types: ['Files'], files: [] } })
document.body.dispatchEvent(event)
})
await page.getByTestId('media-drop-area').waitFor()
}
async function rateLimitAnUpload(page: Page) {
const frozen = new Date('2026-09-30T12:00:00Z')
await page.clock.install({ time: frozen })
await page.clock.pauseAt(new Date(frozen.getTime() + 1000))
await stubMediaUploads(page, {
[mediaUploadFiles.porchSteps.name]: {
kind: 'respond',
status: 429,
headers: { 'Retry-After': mediaRetryAfter },
},
})
await chooseMediaFiles(page, [mediaUploadFiles.porchSteps])
await page.getByText('Too many uploads', { exact: true }).first().waitFor()
await page.getByRole('button', { name: 'Cancel all', exact: true }).click()
await page.mouse.move(0, 0)
}
async function openMergeDialog(page: Page) {
await page.getByRole('button', { name: 'Merge into this one' }).first().click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
function confirmMerge(wait?: string) {
return async (page: Page) => {
await openMergeDialog(page)
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Merge 2 copies', exact: true })
.click()
if (wait) {
await page.getByText(wait, { exact: true }).first().waitFor()
}
await page.mouse.move(0, 0)
}
}
function mediaCard(page: Page, id: number) {
return page.locator(`[data-media-card][data-media-id="${id}"]`)
@@ -1145,11 +1403,40 @@ const mediaStates: Record<string, VisualState> = {
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,
'Selection and bulk bar': mediaState(mediaApi(), selectMediaCards),
'Bulk delete: confirm': mediaState(mediaApi(), openBulkDeleteDialog),
'Bulk delete: in progress': mediaState(
mediaApi({
'DELETE /v1/media/1': { status: 204, hang: true },
'DELETE /v1/media/2': { status: 204, hang: true },
'DELETE /v1/media/8': { status: 204, hang: true },
}),
confirmBulkDelete(),
),
'Bulk delete: partly failed': mediaState(
mediaBulkApi(
{
'DELETE /v1/media/1': { status: 204 },
'DELETE /v1/media/2': { status: 204 },
'DELETE /v1/media/8': { status: 500 },
},
[1, 2],
{ all: 10, unused: 2, duplicates: 2 },
),
confirmBulkDelete("Couldn't delete 1 image"),
),
'Bulk delete: done': mediaState(
mediaBulkApi(
{
'DELETE /v1/media/1': { status: 204 },
'DELETE /v1/media/2': { status: 204 },
'DELETE /v1/media/8': { status: 204 },
},
[1, 2, 8],
{ all: 9, unused: 1, duplicates: 2 },
),
confirmBulkDelete('3 images deleted'),
),
'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)),
@@ -1217,18 +1504,81 @@ const mediaStates: Record<string, VisualState> = {
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,
'Upload: drag over the page': mediaState(mediaApi(), dragFilesOverPage),
'Upload: several files, mixed': mediaUploadState(
[
mediaUploadFiles.kitchenRoof,
mediaUploadFiles.porchSteps,
mediaUploadFiles.floorboards,
mediaUploadFiles.scan,
mediaUploadFiles.roof,
mediaUploadFiles.attic,
],
{
[mediaUploadFiles.kitchenRoof.name]: uploadCreated,
[mediaUploadFiles.porchSteps.name]: { kind: 'hold' },
[mediaUploadFiles.roof.name]: uploadServerError,
},
'Waiting',
),
'Upload: summary': mediaUploadState(
[
mediaUploadFiles.kitchenRoof,
mediaUploadFiles.porchSteps,
mediaUploadFiles.floorboards,
mediaUploadFiles.scan,
mediaUploadFiles.roof,
],
{
[mediaUploadFiles.kitchenRoof.name]: uploadCreated,
[mediaUploadFiles.porchSteps.name]: uploadCreated,
[mediaUploadFiles.roof.name]: uploadServerError,
},
'Upload finished',
),
'Upload: rejected files (413, 400)': mediaUploadState(
[mediaUploadFiles.scan, mediaUploadFiles.notes, mediaUploadFiles.broken],
{ [mediaUploadFiles.broken.name]: uploadUnreadable },
'Nothing was uploaded',
),
'Upload: too many requests (429)': mediaState(mediaUploadApi(), rateLimitAnUpload),
'Duplicates: groups': mediaState(mediaDuplicatesApi(), undefined, mediaDuplicatesPath),
'Duplicates: empty': mediaState(
mediaApi({
'GET /v1/media/': {
status: 200,
body: mediaList([], { all: 12, unused: 2, duplicates: 0 }),
},
}),
undefined,
mediaDuplicatesPath,
),
'Merge: confirm': mediaState(mediaDuplicatesApi(), openMergeDialog, mediaDuplicatesPath),
'Merge: in progress': mediaState(
mediaDuplicatesApi(undefined, { 'POST /v1/media/merge': { status: 200, hang: true } }),
confirmMerge(),
mediaDuplicatesPath,
),
'Merge: failed': mediaState(
mediaDuplicatesApi(undefined, { 'POST /v1/media/merge': { status: 500 } }),
confirmMerge("Couldn't merge the copies. Nothing was changed. Try again in a moment."),
mediaDuplicatesPath,
),
'Merge: conflict (409)': mediaState(
mediaDuplicatesApi(undefined, { 'POST /v1/media/merge': { status: 409 } }),
confirmMerge(
'One of these images changed, so nothing was merged. The list is up to date now. Check it and try again.',
),
mediaDuplicatesPath,
),
'Merge: done': mediaState(
mediaDuplicatesApi(
{ ids: mediaMergedDuplicateIds, counts: mediaMergedCounts },
{ 'POST /v1/media/merge': { status: 200, body: { articles_updated: 5, merged: 2 } } },
),
confirmMerge('Copies merged'),
mediaDuplicatesPath,
),
'List failed to load': mediaState(mediaApi({ 'GET /v1/media/': { status: 500 } })),
Offline: mediaState(mediaApi(), goOffline),
}