feat: media page T1
This commit is contained in:
1 parent
67d456baf6
commit
008b0dfbb9
46 files changed
+5742
-204
No files matched your search
@@ -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: [
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,31 +72,66 @@ function onKeyup(event: KeyboardEvent) {
|
||||
|
||||
<template>
|
||||
<div ref="grid" class="grid grid-cols-2 gap-2.5 md:grid-cols-5 md:gap-4">
|
||||
<button
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
data-media-card
|
||||
:data-media-id="item.id"
|
||||
:aria-label="accessibleName(item)"
|
||||
class="flex min-w-0 flex-col border border-default bg-default text-start text-default transition-shadow hover:border-accented hover:shadow-(--shadow-popover) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||
@click="emit('open', item.id, $event.currentTarget as HTMLElement)"
|
||||
@keydown="onKeydown($event, item, index)"
|
||||
@keyup="onKeyup"
|
||||
>
|
||||
<MediaThumb :item="item" class="aspect-4/3 w-full border-b border-default" />
|
||||
<span class="flex min-w-0 flex-col gap-1.5 px-3 py-2.5">
|
||||
<span class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="truncate font-medium text-highlighted">{{ item.filename }}</span>
|
||||
<span class="truncate text-xs/5 text-muted"
|
||||
>{{ formatSize(item.size) }} ·
|
||||
{{ formatDimensions(item.width, item.height) }}</span
|
||||
>
|
||||
<div v-for="(item, index) in items" :key="item.id" class="group/card relative min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
data-media-card
|
||||
:data-media-id="item.id"
|
||||
:data-selected="selected.includes(item.id) || undefined"
|
||||
:aria-label="accessibleName(item)"
|
||||
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"
|
||||
: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"
|
||||
: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
|
||||
>
|
||||
</span>
|
||||
<span class="flex flex-wrap gap-1">
|
||||
<MediaCardBadges :item="item" />
|
||||
</span>
|
||||
</span>
|
||||
<span class="flex flex-wrap gap-1">
|
||||
<MediaCardBadges :item="item" />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</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>
|
||||
@@ -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>
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const toastsAboveBulkBar = ref(false)
|
||||
@@ -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)/)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]}`
|
||||
}
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
+538
-37
@@ -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"
|
||||
/>
|
||||
<span aria-hidden="true" class="w-13.75 shrink-0" />
|
||||
<span data-testid="image-count">{{ imageCount }}</span>
|
||||
</div>
|
||||
<MediaSelectAllRow
|
||||
:state="selection.allState.value"
|
||||
:count="media.items.value.length"
|
||||
@toggle="toggleAllSelected"
|
||||
/>
|
||||
<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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user