feat: image variants settings screens

This commit is contained in:
fsylvain committed 2026-10-08 13:57:41 +02:00
1 parent f1d56ef50e
commit f567866161
42 files changed
+7192 -11

No files matched your search

+1 -1
View File
@@ -70,7 +70,7 @@ Responsive 400 px card from sm up, with its top 12 vh from the top. Below sm , t
### DashboardShell
The sidebar has seven destinations for owner and admin (Home, Articles, Media, Users, Activity, Integration, Settings; regular users get Home, Articles, Media, Activity), and the user menu sits at its foot. The top bar carries the breadcrumbs and page title. Collapsing the sidebar leaves a 56 px icon rail. Below lg , the sidebar becomes a slideover.
The sidebar has seven destinations for owner and admin (Home, Articles, Media, Users, Activity, Integration, Settings; regular users get Home, Articles, Media, Activity), and the user menu sits at its foot. For owner and admin, Settings expands to a Media group with Variants and Cache (see "Sidebar entry (1l)" in [image-variants](screens/image-variants.md)); it is built with the `children` of UNavigationMenu, and the same group appears in the mobile drawer. The top bar carries the breadcrumbs and page title. Collapsing the sidebar leaves a 56 px icon rail. Below lg , the sidebar becomes a slideover.
RenewCMS
+2
View File
@@ -27,6 +27,8 @@ A global `router.beforeEach` guard reads `meta.auth`. On the first navigation it
| `/invite/accept` | AuthShell | public | AcceptInvitePage | GET /v1/auth/invite?c=* · POST /v1/auth/invite/accept* |
| `/:pathMatch(.*)*` | either | any | NotFoundPage | none |
The sidebar reaches `/settings/media/variants` and `/settings/media/cache` through the Media group under Settings (owner and admin only). See [image-variants](screens/image-variants.md), frame 1l.
`/media` and `/article/:id/edit` also gain image variants: GET /v1/media/{id}/variants · POST|DELETE /v1/media/{id}/variants/{name} and the `variants` and `sort=total_size` query parameters on GET /v1/media/ (proposed). See [image-variants](screens/image-variants.md).
Auth values: `public` needs no session, `session` needs a logged-in account (verified or not), `verified` needs a verified account, `admin` needs an admin or owner account, `any` applies to redirects and the catch-all.
+3
View File
@@ -31,6 +31,9 @@ Admin-only page under Settings → Media, with two tabs: Variants and Cache. Des
| 1i | Delete confirm | Delete in the row menu. |
| 1j | Delete in progress | Delete was confirmed. The button shows a spinner. |
| 1k | Request reference | Mobile scrolled to the reference card. On desktop it is visible below the table. |
| 1l | Entry: sidebar | From any page, Settings expands in the sidebar to Media: Variants and Cache. Admin only. On mobile the menu button opens it as a drawer. |
**Sidebar entry (1l).** For owner and admin, the Settings item has a chevron (right when closed, down when open) and expands to a group: a muted uppercase label "Media" (11 px, semibold, 0.04em tracking), then "Variants" (layers icon) and "Cache" (database icon). The group is indented 19 px with a 1 px left border; its items are 32 px high (44 px in the mobile drawer) with 14 px icons. The group is open on every `/settings` route. When a subpage is current, its item takes the sand fill and Settings keeps only the highlighted text and medium weight. On `/settings` itself Settings is the current item. Other roles see no chevron and no group. Cache stays inert until its screen exists.
### Copy: Variants list
@@ -113,6 +113,7 @@ class Component extends DCLogic {
st('1h', 'Purge confirm', 'Purge cache in the row menu.', l({}, { modal: 'purgeVariant' })),
st('1i', 'Delete confirm', 'Delete in the row menu.', l({}, { modal: 'deleteVariant' })),
st('1j', 'Delete in progress', 'Delete was confirmed. The button shows a spinner.', l({}, { modal: 'deleteVariant', loadingModal: true })),
st('1l', 'Entry: sidebar', 'From any page, Settings expands in the sidebar to Media: Variants and Cache. Admin only. On mobile the menu button opens it as a drawer.', { kind: 'media', ...MB, sideOpen: true, ml: {}, drawer: true }),
st('1k', 'Request reference', 'Mobile scrolled to the reference card. On desktop it is visible below the table.', l({}), { scroll: 900 })
],
tables: [tb('Copy: Variants list', [
+10 -4
View File
@@ -86,10 +86,15 @@ class Component extends DCLogic {
// ---------- shell
const NAV = [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['users', 'users', 'Users'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']]
const nav = s.role === 'user' ? NAV.filter(n => ['home', 'articles', 'media'].includes(n[0])) : NAV
const side = D('width:224px;flex-shrink:0;background:var(--ui-bg);border-right:1px solid var(--ui-border);display:flex;flex-direction:column;padding:12px;gap:2px',
D('display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px', mark(26), S('font-weight:600;font-size:14px;color:var(--ui-text-highlighted)', 'RenewCMS')),
...nav.map(([k, ic, t]) => D('display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:' + (k === s.nav ? 'var(--rc-sand)' : 'transparent') + ';color:' + (k === s.nav ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)') + ';font-weight:' + (k === s.nav ? 500 : 400), I(ic), t)),
D('flex:1'), D('display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;color:var(--ui-text-highlighted)', S('width:26px;height:26px;flex-shrink:0;background:var(--rc-sand);color:var(--rc-on-sand);display:flex;align-items:center;justify-content:center;font:600 11px \'IBM Plex Sans\',sans-serif', 'MA'), S('flex:1;font-weight:500', 'Marta Alves'), I('chevrons-up-down', 14)))
const Side = dr => {
const open = s.nav === 'settings' || s.sideOpen, act = s.nav === 'settings' ? (s.crumbs || []).slice(-1)[0] : null, h = dr ? 44 : 32
const row = (ic, t, on, pl, tail, hi) => D('display:flex;align-items:center;gap:10px;height:' + h + 'px;padding:0 10px 0 ' + pl + 'px;background:' + (on ? 'var(--rc-sand)' : 'transparent') + ';color:' + (on ? 'var(--rc-on-sand)' : hi ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)') + ';font-weight:' + (on || hi ? 500 : 400), I(ic, pl > 10 ? 14 : undefined), S('flex:1', t), tail)
return D('width:' + (dr ? '100%' : '224px') + ';height:' + (dr ? '100%' : 'auto') + ';flex-shrink:0;background:var(--ui-bg);' + (dr ? 'box-shadow:var(--rc-shadow-modal);' : 'border-right:1px solid var(--ui-border);') + 'display:flex;flex-direction:column;padding:12px;gap:2px',
D('display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px', mark(26), S('flex:1;font-weight:600;font-size:14px;color:var(--ui-text-highlighted)', 'RenewCMS'), dr && I('x', 20)),
...nav.flatMap(([k, ic, t]) => k !== 'settings' ? [row(ic, t, k === s.nav, 10)] : [row(ic, t, false, 10, I(open ? 'chevron-down' : 'chevron-right', 14), open), open && D('display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)', S('padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)', 'Media'), row('layers', 'Variants', act === 'Variants', 12), row('database', 'Cache', act === 'Cache', 12))]),
D('flex:1'), D('display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;color:var(--ui-text-highlighted)', S('width:26px;height:26px;flex-shrink:0;background:var(--rc-sand);color:var(--rc-on-sand);display:flex;align-items:center;justify-content:center;font:600 11px \'IBM Plex Sans\',sans-serif', 'MA'), S('flex:1;font-weight:500', 'Marta Alves'), I('chevrons-up-down', 14)))
}
const side = Side(false)
const crumbs = s.crumbs || []
const topbar = m
? D('height:56px;flex-shrink:0;border-bottom:1px solid var(--ui-border);background:var(--ui-bg);display:flex;align-items:center;padding:0 6px;gap:4px;color:var(--ui-text-highlighted)', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center', I(s.back ? 'arrow-left' : 'menu', 20)), S('flex:1;font-weight:600;font-size:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', s.mTitle || crumbs[crumbs.length - 1]))
@@ -319,6 +324,7 @@ class Component extends DCLogic {
k === 'edit' && (m ? D('position:absolute;inset:0;z-index:40;display:flex', EditPanel()) : D('position:absolute;inset:0;z-index:40;display:flex;justify-content:flex-end;background:var(--rc-overlay)', EditPanel())),
k === 'detail' && (DT.preview ? [DetailPanel(), PrevDlg()] : DetailPanel()),
k === 'editor' && ED.picker && Picker(),
m && s.drawer && D('position:absolute;inset:0;z-index:60;display:flex;background:var(--rc-overlay)', D('width:300px;max-width:85%;height:100%', Side(true))),
s.modal && Modal(), s.toasts && Toasts())
return { view: root }
}
+690
View File
@@ -0,0 +1,690 @@
Feature: Image variants settings screens, list and add/edit slide-over
As an owner or admin of a RenewCMS site
I want to list, enable, disable, purge, delete, add and edit image variants from Settings → Media
So that I can manage the named sizes articles request with ?v=<name> without leaving the back office
Background:
Given the spec of record is docs/design/screens/image-variants.md sections 1 and 2 and its "Back-office API errors" table, the board docs/design/source/RenewCMS Screens Image variants.dc.html drawn by VariantsFrame.dc.html, and docs/design/routes.md
And this slice is SPA only: no Go change, and the API is the one of features/image-variants.feature
And it excludes the Cache tab content, the Media library additions, the image detail Variants tab and the editor picker (sections 3 to 6 of the design)
And /settings/media/variants is a verified route rendered as VariantsPage inside DashboardShell, with the sidebar item "Settings" current
And the desktop breadcrumb is "Settings › Media › Variants" and the mobile top bar title is "Variants"
And the page uses Nuxt UI components only and design tokens, with no raw hex value and no arbitrary Tailwind colour
And every new English string has a French entry in web/src/i18n/messages.ts
And the list comes from GET /v1/image-variants once per navigation, and items are used in the order the API returns (name ascending)
And the Default fixture holds six variants, in API order, with cache_size in bytes (1 MB = 1048576):
| name | width | height | fit | format | quality | generated_files | cache_size | enabled |
| card | 800 | null | inside | webp | 80 | 318 | 58 MB | true |
| hero | 1200 | 630 | cover | webp | 82 | 244 | 84 MB | true |
| og | 1200 | 630 | cover | jpeg | 80 | 96 | 9 MB | true |
| print | null | 2400 | inside | png | null | 74 | 21 MB | false |
| square | 600 | 600 | cover | jpeg | 85 | 140 | 19 MB | true |
| thumb | 400 | 300 | cover | webp | 80 | 412 | 21 MB | true |
And the session user has role "owner" or "admin" from GET /v1/auth/me unless a scenario says otherwise
And plain-text API errors are mapped by status, never shown raw
Rule: Only admins and owners see the page
Scenario Outline: A session whose role is not owner or admin gets the Forbidden state
Given the session has <session>
When the user opens /settings/media/variants
Then the sidebar shows no current item
And the page shows the empty block with a lock icon, the title "You don't have access" and the text "Only admins can manage image variants. Ask an admin if you need one changed."
And it offers one button "Back to Home" with an arrow-left icon
And no page header, no tabs, no table, no cards and no reference card are drawn
And <request>
Examples:
| session | request |
| no role | no request to GET /v1/image-variants is sent |
| role "owner" but the API answers 403 to the list | the request was sent and its 403 is handled here |
Scenario: Back to Home leaves the page
Given the Forbidden state is shown
When the user activates "Back to Home"
Then the route is /home
Scenario Outline: An owner or an admin sees the list
Given the session role is "<role>"
When the user opens /settings/media/variants
Then GET /v1/image-variants is sent once and the page header is shown
Examples:
| role |
| owner |
| admin |
Rule: Header and tabs
Scenario: The page header and the tabs
When the Default fixture is loaded
Then the title is "Image variants"
And the description is "Named sizes and formats generated from your original images. Originals are never changed."
And a primary button "Add variant" with a plus icon is shown, right of the title on desktop and below the description on mobile
And two tabs follow the header: "Variants" (icon layers, active, medium weight, 2 px primary underline) and "Cache" (icon database, toned text), the Cache tab is aria-disabled="true", activating it changes nothing and no route exists for it
Rule: Loaded, table on desktop and cards on mobile
Scenario: Desktop table
Given the viewport is 640 px wide or more
When the Default fixture is loaded
Then a table has the columns "Name", "Dimensions", "Fit", "Format", "Quality", "Generated", "Cache size", "Status" and an unlabeled actions column
And the grid is minmax(0,1.1fr) 104px 76px 72px 68px 92px 92px 132px 44px, the header is 36 px high on --ui-bg-elevated and rows are at least 48 px high with a top border
And the rows are, in order, with cells name | dimensions | fit | format | quality | generated | cache size | status:
| card | 800 × auto | inside | WebP | 80 | 318 | 58 MB | Enabled |
| hero | 1200×630 | cover | WebP | 82 | 244 | 84 MB | Enabled |
| og | 1200×630 | cover | JPEG | 80 | 96 | 9 MB | Enabled |
| print | auto × 2400| inside | PNG | — | 74 | 21 MB | Disabled |
| square | 600×600 | cover | JPEG | 85 | 140 | 19 MB | Enabled |
| thumb | 400×300 | cover | WebP | 80 | 412 | 21 MB | Enabled |
And the name and dimensions cells are IBM Plex Mono, the name 13 px medium and the dimensions 12 px
And the row of a disabled variant has muted text
And the footer under the table reads "6 variants · 1,284 generated files · 212 MB"
And below the footer the Request reference card is visible
Scenario Outline: Dimensions, quality and format cells
Given a variant with width <width>, height <height>, format <format> and quality <quality>
Then the Dimensions cell reads "<dimensions>", the Format cell reads "<label>" and the Quality cell reads "<qualityCell>"
Examples:
| width | height | format | quality | dimensions | label | qualityCell |
| 400 | 300 | webp | 80 | 400×300 | WebP | 80 |
| 800 | null | jpeg | 80 | 800 × auto | JPEG | 80 |
| null | 2400 | png | null | auto × 2400 | PNG | — |
| 600 | 600 | original | 82 | 600×600 | Original | 82 |
Scenario: Mobile cards
Given the viewport is below 640 px
When the Default fixture is loaded
Then each variant is a card with 12 px padding and a 1 px border, stacked with 8 px gaps
And the first line of the card "hero" is the name in IBM Plex Mono 14 px medium with the status switch at its right
And the second line is the mono 12 px spec line "1200×630 · cover · WebP · q82"
And the third line is "244 generated · 84 MB" with the row menu button at its right
And the card "print" spec line is "auto × 2400 · inside · PNG" with no quality part
And the footer text "6 variants · 1,284 generated files · 212 MB" follows the last card
And the Request reference card follows the footer, in one column
Scenario: Singular footer
Given the list holds only "thumb" with 1 generated file of 18 KB
Then the footer reads "1 variant · 1 generated file · 18 KB"
Scenario: Footer sums are computed from the list
Given the Default fixture
Then the footer file count is the sum of generated_files and the size is the sum of cache_size
Rule: Loading
Scenario: Skeletons appear only after 150 ms
Given GET /v1/image-variants has not answered
Then nothing is drawn in the content area at 149 ms
And at 150 ms the header and tabs stay, and a region announces "Loading variants" with aria-busy="true"
And on desktop it holds a 36 px header bar and 5 skeleton rows of 48 px with six bars
And below 640 px it holds 4 skeleton cards with three bars of 40 %, 70 % and 55 % width
And no text is drawn in the skeleton
Scenario: A fast answer never flashes skeletons
Given GET /v1/image-variants answers before 150 ms
Then no skeleton is ever drawn
Rule: Empty
Scenario: No variants
Given GET /v1/image-variants answers {"items": []}
Then the header and tabs are shown
And the empty block has a layers icon, the title "No variants yet" and the text "A variant is a named size that an image can be requested in, like ?v=thumb. Start with one of these."
And two template cards sit side by side on desktop and stacked on mobile (at least 64 px high):
| thumb | 400×300 · cover · WebP | Use template |
| hero | 1200×630 · cover · WebP | Use template |
And a primary button "Add variant" with a plus icon follows them
And no table, footer or Request reference card is drawn
Scenario Outline: A template opens the form pre-filled
Given the empty state is shown
When the user activates the template <template>
Then the Add variant form opens in create mode with name "<template>", width <width>, height <height>, fit cover, format WebP and quality <quality>
And generate on upload is off and enabled is on
And Save is enabled
Examples:
| template | width | height | quality |
| thumb | 400 | 300 | 80 |
| hero | 1200 | 630 | 82 |
Rule: Error loading
Scenario Outline: A failed list request
Given GET /v1/image-variants fails with <failure>
Then the header and tabs are shown
And an error alert shows the title "Couldn't load variants", the text "Check your connection and try again." and a button "Try again" with a rotate-cw icon
And the alert has no close button and no table, cards, footer or reference card are drawn
Examples:
| failure |
| status 500 |
| a network failure |
Scenario: Try again reloads
Given the error alert is shown
When the user activates "Try again"
Then GET /v1/image-variants is sent again and the Default fixture replaces the alert
Rule: Status switch
Scenario: Each row has a labelled switch paired with an icon and a word
When the Default fixture is loaded
Then every row shows a switch (32 × 18 px) and the icon circle-check with "Enabled" or the icon circle-off with "Disabled"
And the switch of "hero" has the accessible name "Enable variant hero"
Scenario Outline: Toggling sends PATCH and updates the row
Given the variant "<name>" is <before>
When the user toggles its switch
Then PATCH /v1/image-variants/<name> is sent with {"enabled": <after>}
And while it is in flight the switch is disabled
And when it answers 200 the status reads "<label>" and the other fields of the row are unchanged
And no purge happens and generated_files and cache_size are those of the response
Examples:
| name | before | after | label |
| hero | enabled | false | Disabled |
| print | disabled | true | Enabled |
Scenario Outline: A failed toggle reverts and reports
Given the variant "hero" is enabled
When the user toggles its switch and the API answers <status>
Then the switch and the word return to "Enabled"
And a toast shows the title "<title>" and the text "<text>"
Examples:
| status | title | text |
| 403 | You don't have access | Only admins can manage image variants. |
| 404 | Not found | This variant or image no longer exists. Refresh the page. |
| 500 | Something went wrong | The server couldn't finish. Try again. If it keeps happening, check the server log. |
Rule: Row menu
Scenario: The ellipsis opens a three-item menu
When the user activates the ellipsis button of the row "hero" (accessible name and tooltip "Actions for hero")
Then a 176 px menu opens at the right of the row with the items "Edit" (pencil), "Purge cache" (eraser) and "Delete" (trash-2, error colour)
And on desktop items are 32 px high and on mobile 44 px high
And the ellipsis button shows a primary focus ring while the menu is open
Scenario: Menu keyboard behaviour
Given the menu is open
Then the arrow keys move between items, Enter activates the focused item and Escape closes the menu with focus back on the ellipsis button
Scenario Outline: Menu items
Given the menu of "hero" is open
When the user chooses "<item>"
Then <result>
Examples:
| item | result |
| Edit | the add/edit slide-over opens in edit mode for "hero" |
| Purge cache | the purge dialog opens |
| Delete | the delete dialog opens |
Rule: Purge cache
Scenario: The purge dialog
When the purge dialog of "hero" opens (hero has 244 files and 84 MB)
Then the title is "Purge cache for “hero”?"
And the text is "This removes 244 generated files (84 MB). Files are generated again on the next request."
And the buttons are "Cancel" and a primary "Purge 244 files"
And focus is trapped in the dialog and returns to the ellipsis button on close
Scenario: Cancel closes without a request
When the user activates "Cancel" or presses Escape
Then the dialog closes, no request is sent and the row is unchanged
Scenario: Confirming purges while the row is dimmed
When the user activates "Purge 244 files"
Then POST /v1/image-variants/hero/purge is sent
And the dialog closes
And the row "hero" is on --ui-bg-elevated, its switch and ellipsis button are disabled and its status cell reads "Purging…" with a loader-circle icon in the info colour
And the other rows stay interactive
Scenario: Purge succeeds
Given the API answers 200 {"purged_files": 244, "purged_bytes": 88080384}
Then the row "hero" shows Generated 0 and Cache size 0 KB, its status returns to Enabled and its controls are active
And the footer is recomputed with no refetch
And a toast reads "Purged 244 files from “hero”" (the count comes from the response)
Scenario Outline: Purge fails
When the purge answers <status>
Then the row returns to its previous values and controls
And a toast shows "<title>" and "<text>"
Examples:
| status | title | text |
| 404 | Not found | This variant or image no longer exists. Refresh the page. |
| 500 | Something went wrong | The server couldn't finish. Try again. If it keeps happening, check the server log. |
Rule: Delete variant
Scenario: The delete dialog
When the delete dialog of "hero" opens
Then the title is "Delete variant “hero”?"
And the text is "This removes the variant and its 244 generated files (84 MB). Articles that use ?v=hero will start returning 400. This can't be undone."
And the buttons are "Cancel" and a danger "Delete variant"
Scenario: Confirming shows a spinner and blocks dismissal
When the user activates "Delete variant"
Then DELETE /v1/image-variants/hero is sent
And the confirm button shows a loader-circle icon and "Cancel" is disabled
And Escape and a backdrop click do not close the dialog
Scenario: Delete succeeds
Given the API answers 204
Then the dialog closes, the row "hero" is removed, the footer reads "5 variants · 1,040 generated files · 128 MB"
And a toast reads "Variant “hero” deleted"
Scenario: Deleting the last variant shows the empty state
Given only "thumb" exists and DELETE answers 204
Then the "No variants yet" empty state replaces the table
Scenario Outline: Delete fails
When the delete answers <status>
Then the dialog closes, the row stays [404: the list is reloaded], and a toast shows "<title>" and "<text>"
Examples:
| status | title | text |
| 404 | Not found | This variant or image no longer exists. Refresh the page. |
| 500 | Something went wrong | The server couldn't finish. Try again. If it keeps happening, check the server log. |
Rule: Request reference card
Scenario: The card under the table
Given the list is loaded
Then a card titled "How requests are handled" shows two columns on desktop (1.3fr / 1fr, 16 px gap) and one on mobile
And the left column is a bordered table (rows at least 36 px, 44 px on mobile):
| /static/uploadedImages/photo.png?v=thumb | 200 | Image |
| Unknown or disabled variant | 400 | Unknown variant |
| width, height, format or quality | 400 | Unsupported parameter |
| Missing source image | 404 | Not found |
And the first cell is mono 12 px
And the 200 badge is success-soft with circle-check and the 400 and 404 badges are error-soft with circle-x
And the right column has the muted label "Body of a 400 response" over a mono 12 px block reading exactly:
"""
{
"status": 400,
"error": "unknown_variant",
"message": "Unknown variant",
"detail": "banner"
}
"""
And the note "unsupported_parameter replaces unknown_variant when a raw parameter is sent. There is no arbitrary-size mode." closes the card
Scenario: The card is not drawn when there is no list
Given the state is Loading, Empty, Error or Forbidden
Then the Request reference card is absent
Rule: Slide-over opening and closing
Scenario: Add variant opens the slide-over on desktop
Given the viewport is 640 px wide or more
When the user activates "Add variant"
Then a slide-over 960 px wide opens at the right over an overlay, the list stays behind it
And its header is 56 px high with the title "Add variant" and a close button (x icon, accessible name "Close")
And its body has the form on the left (440 px column) and the preview on the right on --ui-bg-muted, separated by a border
And its footer is 64 px high with "Cancel" and a primary "Add variant", and the status text is empty
And focus moves to the Name field
Scenario: Add variant opens a full page on mobile
Given the viewport is below 640 px
When the user activates "Add variant"
Then a full-screen page opens with a 56 px header holding an arrow-left back button and the title "Add variant", no x button
And the form is followed by the Preview section, stacked, separated by a top border
And the footer is 72 px high with "Cancel" and "Add variant" sharing the width
Scenario: Edit opens with the stored values
When the user chooses "Edit" on the row "hero"
Then the title is "Edit variant: hero"
And Name is "hero" in a locked, elevated field with a lock icon and the help "The name can't be changed."
And Width 1200, Height 630, Fit Cover, Format WebP, Quality 82, Generate on upload off and Enabled on are shown
And the always-visible warning alert reads "Changing dimensions, fit, format or quality purges 244 generated files; they will be regenerated on next request."
And "Save changes" is disabled and the footer status is empty
Scenario: Dismissing a pristine form closes immediately
Given a form with no change
When the user activates "Cancel", the close button or presses Escape
Then the slide-over closes with no dialog and focus returns to the opener
Scenario: Dismissing a dirty form asks first
Given the form is dirty
When the user activates "Cancel", the close button, the back arrow or presses Escape
Then a dialog opens with the title "Discard changes?" and the text "You have unsaved changes to “hero”. If you close now, they're lost." in edit mode
And its buttons are "Keep editing" and a danger "Discard changes"
And "Keep editing" returns to the form with its values
And "Discard changes" closes the slide-over and discards the values
Scenario: The discard text names the new variant in create mode
Given the form is dirty in create mode with name "banner"
When the user closes it
Then the text reads "You have unsaved changes to “banner”. If you close now, they're lost."
And with an empty name the text reads "You have unsaved changes. If you close now, they're lost."
Rule: Form states
Scenario: Create: pristine
When Add variant has just opened
Then Name is empty and focused (primary border and 1 px primary ring) with the placeholder "e.g. hero" and the help "Lowercase letters, numbers and dashes. Can't be changed later."
And Width and Height are empty with the placeholder "auto", the suffix "px" and the help "Set a width, a height, or both. 1–8000 px. Images are never enlarged."
And Fit is a horizontal 3-option radio group, Cover selected, with the help "Fills the box and crops what overflows."
And Format is a select showing "Original" with the help "Keeps the format of each source image."
And Quality shows a slider and a number field at 80 with the help "1–100. Higher is sharper and larger."
And the switch "Generate on upload" (help "Creates this variant when an image is uploaded.") is off
And the switch "Enabled" (help "Disabled variants return 400 on the public site.") is on
And "Add variant" in the footer is disabled
And the Preview shows "Set a width or height to see a preview." over the sample
Scenario Outline: The Fit help follows the selection
When Fit <fit> is selected
Then the help reads "<help>"
Examples:
| fit | help |
| Cover | Fills the box and crops what overflows. |
| Contain | Whole image inside the box, padded to the box size. |
| Inside | Whole image inside the box, no padding. |
Scenario: The Name counter
When the user types "hero"
Then a mono counter "4/32" shows at the right of the label; in edit mode there is no counter
Scenario: Create: dirty with valid values
Given the user typed name "hero", Width 1200, Height 630, Format WebP, Quality 82, Generate on upload on
Then "Add variant" is enabled and the footer status reads "Unsaved changes" with a circle-dot icon
And the Preview shows the comparison, its chips and the estimate (see Preview rules)
Scenario Outline: Field errors show on blur and on submit
When the user enters <value> in <field> and leaves it
Then the field has an error border and the inline message "<message>" with a circle-alert icon
And the help text is replaced by the message
And once an error shows it is revalidated on every input and clears when the value is valid
Examples:
| field | value | message |
| Name | Hero banner | Use lowercase letters, numbers and dashes, 2 to 32 characters. |
| Name | a | Use lowercase letters, numbers and dashes, 2 to 32 characters. |
| Name | 33 letters | Use lowercase letters, numbers and dashes, 2 to 32 characters. |
| Width | 9000 | Enter a value from 1 to 8000. |
| Width | 0 | Enter a value from 1 to 8000. |
| Height | 8001 | Enter a value from 1 to 8000. |
| Quality | 140 | Enter a value from 1 to 100. |
| Quality | 0 | Enter a value from 1 to 100. |
Scenario Outline: Valid boundary values show no error
Then <field> <value> shows no error
Examples:
| field | value |
| Name | ab |
| Name | a-b-c |
| Width | 1 |
| Height | 8000 |
| Quality | 1 |
| Quality | 100 |
Scenario: Submitting with several errors sends nothing
Given name "Hero banner", Width 9000, Height 630, Format WebP, Quality 140
When the user activates "Add variant"
Then the three errors of the Field errors scenario show (Name format, "Enter a value from 1 to 8000." under Width, "Enter a value from 1 to 100." under Quality)
And no request is sent and focus moves to the first invalid field
Scenario: No dimension
Given name "banner", Width and Height both empty, Format WebP
When the user activates "Add variant"
Then the message "Set a width, a height, or both." shows under Width, and the Height field has an error border without text
And the default help under the two fields is hidden
And no request is sent
Scenario: PNG hides Quality
When Format PNG is selected
Then the Quality section (slider, number field and help) is not rendered
And the estimate and output label follow the PNG rules
And the POST or PUT body has no "quality" key
Scenario: Switching back from PNG restores the previous quality
Given Quality was 70 before PNG was selected
When Format WebP is selected again
Then Quality shows 70
Scenario: The Quality slider and field stay in sync
When the slider moves to 60
Then the number field reads 60
When the number field is set to 90
Then the slider fill is at 90 %
Rule: Saving a new variant
Scenario: Add sends POST with the form values
Given name "hero", Width 1200, Height 630, Fit Cover, Format WebP, Quality 82, Generate on upload on, Enabled on
When the user activates "Add variant"
Then POST /v1/image-variants is sent with {"name": "hero", "width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 82, "generate_on_upload": true, "enabled": true}
Scenario: An empty Width or Height is sent as null or omitted
Given name "card", Width 800, Height empty
Then the body has "width": 800 and no positive "height"
And an empty dimension is omitted or null in the body, both being valid for the API
Scenario: While saving, the form is locked
Given the POST is in flight
Then every field, switch and select is disabled at 60 % opacity
And "Cancel" is disabled and the primary button reads "Saving…" with a loader-circle icon
And Escape, the close button and a backdrop click do not close the slide-over
Scenario: A successful create
Given the API answers 201 with the variant
Then the slide-over closes, the new row is inserted in name order without a refetch with Generated 0 and Cache size 0 KB, the footer is recomputed
And a toast reads "Variant “hero” added"
And focus returns to "Add variant"
Scenario: A create from the empty state leaves the empty state
Given the list was empty and a create answers 201
Then the table shows one row and the footer reads "1 variant · 0 generated files · 0 KB"
Scenario: Name already taken (409)
Given name "thumb" and the API answers 409 with "A variant named “thumb” already exists. Choose another name."
Then the slide-over stays open and the Name field has an error border with the message "A variant named “thumb” already exists. Choose another name."
And all values are kept, focus is on Name, the form is re-enabled and no alert or toast is shown
And editing the name clears the message
Scenario Outline: Save error
Given the API fails with <failure>
Then the slide-over stays open with all values kept and the form re-enabled
And an error alert at the top of the form shows the title "Couldn't save the variant", the text "Check your connection and try again." and a button "Try again"
And "Try again" repeats the same request; the alert disappears when the request starts
Examples:
| failure |
| status 500 |
| a network failure |
Scenario Outline: Other statuses on save
Given the API answers <status> to the save
Then the slide-over stays open with values kept and a toast shows "<title>" and "<text>"
Examples:
| status | title | text |
| 400 | Check the request | A value in the request isn't valid. Reload the page and try again. |
| 403 | You don't have access | Only admins can manage image variants. |
| 404 | Not found | This variant or image no longer exists. Refresh the page. |
Scenario Outline: A 422 from the server is shown inline
Given the API answers 422 with the plain text "<server text>"
Then the message "<inline>" shows under <field>
Examples:
| server text | inline | field |
| Use lowercase letters, numbers and dashes, 2 to 32 characters. | Use lowercase letters, numbers and dashes, 2 to 32 characters. | Name |
| Width and height must be between 1 and 8000, and one of them is required. | Set a width, a height, or both. | Width |
| Quality must be between 1 and 100. | Enter a value from 1 to 100. | Quality |
| Fit must be cover, contain or inside. | the "Couldn't save the variant" alert | the form top |
Rule: Saving an edit
Scenario: Edit: dirty
Given the user changes Quality from 82 to 70 on "hero"
Then "Save changes" is enabled, the footer status reads "Unsaved changes", the Quality number reads 70
And the output label reads "1200×630 · about 71 KB"
And the warning alert stays visible
Scenario: Save sends PUT with the complete definition
When the user activates "Save changes"
Then PUT /v1/image-variants/hero is sent with {"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 70, "generate_on_upload": false, "enabled": true} and no "name"
Scenario: A successful edit reports the purge
Given the API answers 200 with quality 70, generated_files 0, cache_size 0 and "purged_files": 244
Then the slide-over closes, the row "hero" is updated in place without a refetch (quality 70, Generated 0, Cache size 0 KB)
And a toast reads "Variant “hero” saved. 244 files purged."
Scenario: A switch-only edit says nothing about purging
Given the user turned "Generate on upload" on only and the API answers 200 with "purged_files": 0
Then the toast reads "Variant “hero” saved."
Scenario: Saving while saving and the save error behave as in create
Then the Saving, 409-free error, other-status and Try again behaviours of the create rule apply with PUT
Rule: Preview and ImageCompare
Scenario: Preview layout
Then the Preview heading "Preview" is followed, right-aligned and muted, by "Sample: north-wall.jpg"
And the compare box is a 3:2 box with a 1 px border and hidden overflow
And two labels sit under it: "Original" over "4032 × 3024 · 3.9 MB", and the variant name in mono over "1200×630 · about 96 KB"
And the hint "Drag the divider, or focus it and use the arrow keys. The estimate comes from a sample; output is never larger than the source." follows
And the images are placeholders, not real photos
Scenario: Compare box content
Given name "hero", Width 1200, Height 630
Then the original placeholder fills the box and the variant placeholder covers the part right of the divider (clip-path inset(0 0 0 50%))
And a chip "Original" sits 8 px from the top-left and a mono chip "hero" 8 px from the top-right
And the divider is a 2 px line at 50 % with a 32 px square handle (44 px on mobile) holding a chevrons-left-right icon centred on it
Scenario: ImageCompare is a native range input
Then the slider is an input type="range" with min 0, max 100, step 1, value 50
And it has the accessible name "Compare original and hero" and aria-valuetext "50% hero"
And aria-valuenow is 50
Scenario Outline: Keyboard behaviour
Given the slider has focus at value <start>
When the user presses <keys>
Then the value is <end> and aria-valuetext is "<end>% hero"
And the divider and the clip move to <end> %
Examples:
| start | keys | end |
| 50 | ArrowRight | 51 |
| 50 | ArrowUp | 51 |
| 50 | ArrowLeft | 49 |
| 50 | ArrowDown | 49 |
| 50 | Shift+ArrowRight | 60 |
| 50 | Shift+ArrowLeft | 40 |
| 50 | Home | 0 |
| 50 | End | 100 |
| 100 | ArrowRight | 100 |
| 0 | ArrowLeft | 0 |
| 95 | Shift+ArrowRight | 100 |
| 5 | Shift+ArrowLeft | 0 |
Scenario: Focus ring
When the slider has keyboard focus
Then the handle shows a 2 px primary outline with a 2 px offset
And the slider keeps its value when the form fields change
Scenario: Pointer dragging
When the user drags the handle to the middle of the right half
Then the value becomes 75 and the divider follows
Scenario: No dimension, no comparison
Given Width and Height are both empty
Then the compare box shows only the original placeholder, with no variant layer, no divider, no handle and no variant chip
And the overlay reads "Set a width or height to see a preview." 12 px from the left, right and bottom
And the variant label reads "name" over "No output yet" [the board's placeholder name]
Scenario Outline: Output size, never enlarged
Given the sample is 4032 × 3024
When the form has Width <w>, Height <h> and Fit <fit>
Then the output label dimensions read "<out>"
Examples:
| w | h | fit | out |
| 1200 | 630 | cover | 1200×630 |
| 800 | | cover | 800×600 |
| | 2400 | inside | 3200×2400 |
| 600 | 600 | contain | 600×600 |
| 1000 | 1000 | inside | 1000×750 |
| 8000 | 8000 | cover | 4032×3024 |
Scenario Outline: Size estimate
Given the sample north-wall.jpg
When the form has name "<name>", <w>×<h> <fit>, Format <format>, Quality <quality>
Then the label reads "<out> · about <estimate>"
Examples:
| name | w | h | fit | format | quality | out | estimate |
| hero | 1200 | 630 | cover | WebP | 82 | 1200×630 | 96 KB |
| hero | 1200 | 630 | cover | WebP | 70 | 1200×630 | 71 KB |
| hero | 1200 | 630 | cover | PNG | n/a | 1200×630 | 310 KB |
| thumb | 400 | 300 | cover | WebP | 80 | 400×300 | 18 KB |
Scenario: The estimate follows quality and format
Then a lower quality gives a smaller or equal estimate for the same size and format
And the estimate never exceeds the original size 3.9 MB
Scenario: The sample name and slider labels follow the name
Given the name is empty and a width is set
Then the chip and label use the placeholder "name" and the slider's accessible name reads "Compare original and name"
Rule: URL and copy
Scenario: The URL row
Given the name is "hero"
Then the label "URL" is followed by a read-only mono 12 px box reading "/static/uploadedImages/north-wall.jpg?v=hero" on --ui-bg-elevated, and a button "Copy" with a copy icon
Scenario: Without a name
Given the name is empty
Then the box reads "/static/uploadedImages/north-wall.jpg?v=<name>" in muted text and "Copy" is disabled
Scenario: Copy writes the URL
Given the name is "hero"
When the user activates "Copy"
Then the clipboard receives the displayed URL
And the button reads "Copied" with a check icon
And it returns to "Copy" with the copy icon after a short delay (tested with fake timers)
Scenario: A clipboard failure does not claim success
Given navigator.clipboard.writeText rejects
Then the button keeps "Copy" and the page raises no error alert
Scenario: In edit mode the URL uses the locked name
Given the edit form of "hero"
Then the URL box reads "/static/uploadedImages/north-wall.jpg?v=hero" and "Copy" is enabled
Rule: Accessibility and hygiene
Scenario: Status is never colour alone
Then every status shows an icon and a word, and dialogs trap focus and return it to the opener
Scenario: Touch targets on mobile
Given the viewport is below 640 px
Then buttons, menu items, the switch rows, form controls and the slider handle are at least 44 px high
Scenario: Vocabulary
Then the screens say "variant", "generated" and "purge", never "thumbnail preset", "version" or "rendition"
Scenario: Hygiene
Then no comment is added to any file, and the new components use only Nuxt UI parts and the existing tokens (the only new token allowed is --rc-on-sand, already exported)
Rule: Estimate module, French and row order
Scenario: The estimator is one replaceable module
Then the size estimate coefficients live in a single named module under the variants area
And every place that shows an estimate labels it "about N KB"
And the Preview components import the estimate only through that module
Scenario: French strings
Given the locale is French
Then every new string of these screens has a French entry in web/src/i18n/messages.ts and no English string appears in the UI
Scenario: Row order
Then tests use the API order (by name) and only the visual-check mock uses the board order
+156
View File
@@ -0,0 +1,156 @@
# QA: Image variants settings screens
## Implementation note for coder (informational, not a QA assertion)
The API is built and covered by features/image-variants.qa.md: this slice has no Go change and no HTTP-level
section. The auth requirement "admin" does not exist in router meta (AuthRequirement is
public | session | verified | any); the role check lives in VariantsPage via the session role from
GET /v1/auth/me. Plain-text API errors are read through ApiError.body; never display the raw body.
Sizes: the new formatter is not formatSize. Every new string has an English and a
French entry in web/src/i18n/messages.ts.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock useApi's `request` per case (GET /v1/image-variants, POST /v1/image-variants, PUT, PATCH,
DELETE, POST .../purge); no backend. Mount VariantsPage inside DashboardShell with a resolved verified
session (id 1, username "marta", role "owner") and a test router at /settings/media/variants.
Default fixture = the Background's 6 variants in API order. Mock `matchMedia` for the 640 px
breakpoint; run layout cases at desktop and below-640. Use fake timers for the 150 ms skeleton delay.
Mock navigator.clipboard. No test freezes the system date (this screen shows no date).
1. **Access, Examples-driven** — session without role; role "owner"; role "admin"; list answering
ApiError(403). Assert the Forbidden copy and "Back to Home" (navigates to /home) and that no list
request is sent when the role is missing; assert the page header for owner and admin.
2. **Header and tabs** — title, description, "Add variant", tabs "Variants" (active) and "Cache"
(aria-disabled, click does nothing), breadcrumb "Settings", "Media", "Variants" and
mobile title "Variants".
3. **Loaded, desktop** — column labels, the 6 rows in order with every cell of the table, "—" for
print, "Disabled" for print, muted text on the disabled row, footer "6 variants · 1,284 generated
files · 212 MB", the reference card.
4. **Cell formats, Examples-driven** — the four dimension/quality/format cases.
5. **Loaded, mobile** — 6 cards, the spec lines (`1200×630 · cover · WebP · q82`, print without a
quality part), "244 generated · 84 MB", the footer after the cards, the reference card.
6. **Footer** — Default sums; a one-variant list "1 variant · 1 generated file · 18 KB"; an
after-delete recompute "5 variants · 1,040 generated files · 128 MB".
7. **Loading** — unresolved GET: nothing at 149 ms; at 150 ms the region with aria-busy and the label
"Loading variants", 5 skeleton rows on desktop / 4 cards below 640, no text; a response at 100 ms never
draws skeletons.
8. **Empty** — copy, the two template cards, "Add variant", no reference card; each template opens
the form with its values (thumb 400×300 cover WebP 80; hero 1200×630 cover WebP 82).
9. **Error, Examples-driven** — ApiError(500) and NetworkError: alert copy, "Try again", no close
button, no table; Try again re-sends GET and renders the Default fixture.
10. **Status switch** — accessible name "Enable variant hero"; toggling hero sends `PATCH
/v1/image-variants/hero {"enabled": false}`, switch disabled while in flight, row shows Disabled,
no toast; print on sends `{"enabled": true}`; failures 403/404/500 revert the switch and raise
the toast copy of the table.
11. **Row menu** — items Edit/Purge cache/Delete in order, ArrowDown/Up and Enter, Escape closes and
returns focus to the ellipsis button; Edit opens the edit form, the others open their dialog.
12. **Purge** — dialog copy for hero ("Purge 244 files", "84 MB"); Cancel/Escape send nothing; confirm
sends `POST /v1/image-variants/hero/purge`, closes the dialog, row dimmed with "Purging…" and
disabled controls while others stay active; resolve `{purged_files: 244, purged_bytes: 88080384}`
-> row 0 files, status Enabled, toast "Purged 244 files from “hero”"; failures 404/500 -> row restored + toast.
13. **Delete** — dialog copy; confirm sends `DELETE /v1/image-variants/hero`; spinner, Cancel
disabled, Escape and backdrop blocked; 204 -> row removed, toast `Variant “hero” deleted`, footer
recompute; deleting the last row shows Empty; failures 404 (reloads the list) / 500 -> toast.
14. **Reference card** — the 4 rows with badge words and icons, the exact JSON block text, the note;
absent in Loading/Empty/Error/Forbidden; one column below 640.
15. **Slide-over open/close** — desktop slide-over vs mobile full page (back arrow, no x), titles
"Add variant" / "Edit variant: hero", focus on Name, pristine close without dialog, dirty close
via Cancel/x/back/Escape opens "Discard changes?" with the right text, Keep editing / Discard
changes behaviours, focus return to the opener.
16. **Create: pristine** — all labels, placeholders, helps, defaults (Fit Cover, Format Original,
Quality 80, Generate off, Enabled on), "Add variant" disabled, overlay "Set a width or height to
see a preview.".
17. **Fit help, Examples-driven** — three helps.
18. **Field errors, Examples-driven** — each name/width/height/quality row of the feature; blur and
submit; revalidation on input; boundary values accepted; several errors at once: no request.
19. **No dimension** — copy under Width, error border on Height, default help hidden, no request.
20. **PNG** — Quality hidden, body without "quality", quality restored after switching back.
21. **Quality slider/number sync**.
22. **Create request** — exact POST body for the hero values; the `201` flow inserts the row in
name order without a second GET, footer recomputed, toast `Variant “hero” added`.
23. **Saving** — fields disabled, "Saving…", Escape/x/backdrop blocked.
24. **409** — inline message with the name, values kept, no alert and no toast, cleared on edit.
25. **Save error, Examples-driven** — ApiError(500) and NetworkError: alert copy; Try again repeats the
request; values kept.
26. **Other statuses, Examples-driven** — 400, 403, 404 toasts.
27. **422, Examples-driven** — the four server sentences and their inline/alert mapping.
28. **Edit: pristine** — locked Name with help, stored values, warning alert text with 244, Save
disabled.
29. **Edit: dirty and save** — quality 82 -> 70: estimate "about 71 KB", Save enabled, PUT body
without "name", 200 with purged_files 244 -> row updated in place, toast "Variant “hero” saved. 244 files
purged."; purged_files 0 -> "Variant “hero” saved.".
30. **Preview layout** — heading, sample text, labels, hint, chips "Original" and the name, handle size
(44 px class on mobile).
31. **ImageCompare keyboard, Examples-driven** — role/type range, min 0 max 100 step 1, value 50,
aria-label "Compare original and hero", aria-valuetext "50% hero"; every key row of the feature,
including clamping; valuetext and clip-path/divider position follow the value; pointer input sets it.
32. **ImageCompare empty** — no dimensions: no variant layer, divider or handle; overlay text.
33. **Output size and estimate, Examples-driven** — the six size rows and the four estimate rows;
monotonic quality; never above 3.9 MB.
34. **URL** — URL text with and without a name, Copy disabled without a name, click writes the
displayed URL, label "Copied" with fake timers and back to "Copy", a rejected clipboard keeps "Copy".
35. **French** — `setLocale('fr')`; every key used on the page exists in messages.ts (a catalogue
test) and the page renders without missing keys.
36. **Vocabulary** — no rendered text contains "thumbnail preset", "version" or "rendition".
## Visual (task visual:check -- image-variants, desktop and mobile, light and dark)
Reference: docs/design/screens/image-variants.md, board "Image variants" in
`docs/design/source/RenewCMS Screens Image variants.dc.html`, exact values in
`docs/design/source/VariantsFrame.dc.html` and `AppFrame.dc.html`. Add each state to
`web/visual/states.ts` with the board's data. The gate is hard: every non-skipped capture within 10 %,
both viewports, both themes. All states below use the board's sample data; none is clock-sensitive,
so none is skipped.
Sample data (from the board): the 6 variants of the Background (thumb 412 files 21 MB, card 318/58 MB,
hero 244/84 MB, square 140/19 MB, og 96/9 MB, print 74/21 MB), board row order as listed in
VariantsFrame (thumb, card, hero, square, og, print); (the board orders by creation, the API by name;
the mock must return the board order for the visual capture only). Session name "Marta Alves".
Preview sample: north-wall.jpg, 4032 × 3024, 3.9 MB.
- [ ] 1a Loaded (desktop table, mobile cards, reference card on desktop)
- [ ] 1b Loading
- [ ] 1c Empty with the two templates
- [ ] 1d Error loading
- [ ] 1e Forbidden (session without role, no nav item current)
- [ ] 1f Row menu (hero)
- [ ] 1g Row action in progress (hero "Purging…")
- [ ] 1h Purge confirm
- [ ] 1i Delete confirm
- [ ] 1j Delete in progress
- [ ] 1k Request reference (mobile scrolled 900 px; desktop shows it under the table)
- [ ] 2a Create: pristine
- [ ] 2b Create: dirty (hero, slider focused, mobile scrolled to the preview, estimate 96 KB)
- [ ] 2c Field errors (name "Hero banner", width 9000, height 630, quality 140)
- [ ] 2d No dimension (name "banner")
- [ ] 2e Name already taken (name "thumb", 400×300, estimate 18 KB)
- [ ] 2f Saving
- [ ] 2g Save error
- [ ] 2h Edit: pristine
- [ ] 2i Edit: dirty (quality 70, estimate 71 KB)
- [ ] 2j Unsaved changes (discard dialog over 2i)
- [ ] 2k PNG: no quality (estimate 310 KB)
- [ ] Layout, taken from the board frame (VariantsFrame.dc.html), not components.md: table grid
`minmax(0,1.1fr) 104px 76px 72px 68px 92px 92px 132px 44px`, 36 px header, 48 px rows; cards 12 px
padding with the name+switch line, the spec line, then count+menu; slide-over 960 px with a
440 px form column; compare box 3:2, chips 8 px from the top corners, handle 32 px (44 px on
mobile) centred on a 2 px divider; footer 64 px (72 px on mobile).
- [ ] Not drawn, no visual state: toggle failures, the purge/delete failure toasts, the 409-free
status toasts, the 422 mapping, the clipboard states "Copied" (add only if the board draws it: it
does not, so Vitest only), singular footer.
- [ ] Not covered by screenshots: keyboard and focus order (Tab reaches Add variant, tabs, each row's
switch and ellipsis; the slide-over focus starts on Name and is trapped; Escape rules), slider
keys by hand, hover/active, motion (slide-over, dialogs, skeleton pulse stopped under reduced
motion).
## Manual verification
- **Round trips against `task dev`**: create, edit (PUT with a purge), toggle, purge and delete a real
variant; confirm rows, counts and sizes after a reload match the API (needs generated files from a
real `/static/uploadedImages/<storedName>?v=<name>` request).
- **Role**: sign in as an owner and as an admin; confirm both reach the page. A session without a role
is not reproducible live (only owner/admin exist); covered by Vitest and the visual state.
- **Real clipboard** in a secure context (localhost): Copy then paste.
- **French**: switch the language and read every state, the dialogs and the toasts.
- **Real public URL**: paste the copied URL on a stored image and confirm it serves the variant.
+45 -5
View File
@@ -25,6 +25,7 @@ const props = withDefaults(
compactPadding?: boolean
bleedOfflineBanner?: boolean
renderWhileChecking?: boolean
noCurrentNav?: boolean
offlineMessage?: string
offlineIcon?: string
}>(),
@@ -39,6 +40,7 @@ const props = withDefaults(
compactPadding: false,
bleedOfflineBanner: false,
renderWhileChecking: false,
noCurrentNav: false,
},
)
@@ -62,6 +64,26 @@ const session = useSessionStore()
const update = useUpdateStore()
const api = useApi()
const canManageMedia = computed(
() => session.session?.role === 'owner' || session.session?.role === 'admin',
)
const settingsChildren = computed(() => [
{ label: t('Media'), type: 'label' as const },
{
label: t('Variants'),
to: '/settings/media/variants',
icon: 'i-lucide-layers',
active: !props.noCurrentNav && route.path === '/settings/media/variants',
},
{
label: t('Cache'),
icon: 'i-lucide-database',
disabled: true,
active: !props.noCurrentNav && route.path === '/settings/media/cache',
},
])
const navItems = computed(() =>
[
{
@@ -114,10 +136,23 @@ const navItems = computed(() =>
icon: 'i-lucide-settings',
slot: 'settings',
},
].map((item) => ({
...item,
active: route.path === item.to || route.path.startsWith(`${item.to}/`),
})),
].map((item) => {
const isSettings = item.to === '/settings'
const parentActive = isSettings
? route.path === item.to
: route.path === item.to || route.path.startsWith(`${item.to}/`)
return {
...item,
active: !props.noCurrentNav && parentActive,
...(isSettings && canManageMedia.value
? {
children: settingsChildren.value,
defaultOpen: route.path.startsWith(item.to),
trailingIcon: 'i-lucide-chevron-right',
}
: {}),
}
}),
)
const routeTitle = computed(() => translate((route.meta.title as string) ?? ''))
@@ -135,8 +170,13 @@ const navUi = computed(() => ({
list: 'flex flex-col gap-0.5',
link: sidebarCollapsed.value
? 'h-8 justify-center gap-0 px-0 text-sm'
: 'h-8 gap-2.5 px-2.5 py-0 text-sm',
: 'h-8 gap-2.5 px-2.5 py-0 text-sm [li[data-state=open]>&]:font-medium [li[data-state=open]>&]:text-highlighted',
linkLeadingIcon: 'size-4',
linkTrailingIcon: 'size-3.5 group-data-[state=open]:rotate-90',
label: 'px-2.5 pt-1.5 pb-0.5 pl-3 text-[11px] leading-4 font-semibold tracking-[.04em] text-muted uppercase',
childList: 'ms-[19px] gap-0.5 border-s border-default ps-0',
childLink: 'h-8 gap-2.5 py-0 pr-2.5 pl-3 text-sm',
childLinkIcon: 'size-3.5',
}))
const loggingOut = ref(false)
const sidebarOpen = ref(false)
@@ -20,6 +20,7 @@ interface ShellOptions {
username?: string
displayName?: string
email?: string
role?: string
width?: number
}
@@ -30,13 +31,14 @@ async function mountShell(path: string, options: ShellOptions = {}) {
username = 'marta',
displayName = '',
email = 'marta@renew.example',
role = undefined,
width = DESKTOP_WIDTH,
} = options
localStorage.clear()
setViewportWidth(width)
setActivePinia(createPinia())
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username, email, displayName }),
getMe: vi.fn().mockResolvedValue({ verified: true, username, email, displayName, role }),
request: vi.fn().mockResolvedValue(undefined),
})
await router.push(path)
@@ -382,3 +384,41 @@ describe('Account menu: opened from the drawer on mobile', () => {
expect(router.currentRoute.value.path).toBe('/login')
})
})
describe('DashboardShell Settings expands to the Media subpages', () => {
it.each(['owner', 'admin'])(
'shows Media: Variants and Cache under Settings for %s on /settings',
async (role) => {
const wrapper = await mountShell('/settings', { role })
const nav = wrapper.find('nav[aria-label="Main"]')
expect(nav.text()).toContain('Media')
const variants = nav.find('a[href="/settings/media/variants"]')
expect(variants.text()).toContain('Variants')
expect(variants.attributes('aria-current')).toBeUndefined()
const cache = nav
.findAll('[aria-disabled="true"], [disabled]')
.find((el) => el.text().includes('Cache'))
expect(cache).toBeDefined()
expect(nav.find('a[href="/settings"]').attributes('aria-current')).toBe('page')
},
)
it('marks Variants current and Settings not current on the variants page', async () => {
const wrapper = await mountShell('/settings/media/variants', { role: 'admin' })
const nav = wrapper.find('nav[aria-label="Main"]')
expect(nav.find('a[href="/settings/media/variants"]').attributes('aria-current')).toBe(
'page',
)
expect(nav.find('a[href="/settings"]').attributes('aria-current')).toBeUndefined()
})
it('shows no Media subpages without an owner or admin role', async () => {
const wrapper = await mountShell('/settings', { role: 'user' })
const nav = wrapper.find('nav[aria-label="Main"]')
expect(nav.find('a[href="/settings/media/variants"]').exists()).toBe(false)
expect(nav.text()).not.toContain('Cache')
})
})
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const MIN = 0
const MAX = 100
const STEP = 1
const LARGE_STEP = 10
const PATTERN =
'bg-[repeating-linear-gradient(135deg,color-mix(in_srgb,var(--ui-text-muted)_15%,transparent)_0_1px,transparent_1px_12px)]'
const VARIANT_PATTERN =
'bg-[repeating-linear-gradient(45deg,color-mix(in_srgb,var(--ui-text-muted)_25%,transparent)_0_2px,transparent_2px_6px)]'
const CHIP =
'absolute top-2 flex h-5 items-center border border-accented bg-default px-1.5 text-xs font-medium text-highlighted'
const props = defineProps<{
name: string
comparable: boolean
}>()
const position = defineModel<number>({ default: 50 })
const shownName = computed(() => props.name || t('name'))
function clamp(value: number) {
return Math.min(MAX, Math.max(MIN, value))
}
function onInput(event: Event) {
position.value = clamp(Number((event.target as HTMLInputElement).value))
}
function stepFor(event: KeyboardEvent) {
return event.shiftKey ? LARGE_STEP : STEP
}
const KEY_ACTIONS: Record<string, (event: KeyboardEvent) => number> = {
ArrowRight: (event) => position.value + stepFor(event),
ArrowUp: (event) => position.value + stepFor(event),
ArrowLeft: (event) => position.value - stepFor(event),
ArrowDown: (event) => position.value - stepFor(event),
Home: () => MIN,
End: () => MAX,
}
function onKeydown(event: KeyboardEvent) {
const action = KEY_ACTIONS[event.key]
if (!action) {
return
}
event.preventDefault()
position.value = clamp(action(event))
}
</script>
<template>
<div
data-testid="image-compare"
class="relative aspect-3/2 shrink-0 overflow-hidden border border-default"
>
<div
data-testid="compare-original"
class="absolute inset-0 flex items-center justify-center bg-(--ui-color-secondary-200) text-muted dark:bg-(--ui-color-secondary-300)"
:class="PATTERN"
>
<UIcon name="i-lucide-image" class="size-8 opacity-55" />
</div>
<template v-if="comparable">
<div
data-testid="compare-variant"
class="absolute inset-0 bg-accented"
:class="VARIANT_PATTERN"
:style="{ clipPath: `inset(0 0 0 ${position}%)` }"
/>
<span :class="[CHIP, 'left-2']">{{ t('Original') }}</span>
<span :class="[CHIP, 'right-2 font-mono']">{{ shownName }}</span>
<input
type="range"
:min="MIN"
:max="MAX"
:step="STEP"
:value="position"
:aria-label="t('Compare original and {0}', [shownName])"
:aria-valuenow="position"
:aria-valuetext="t('{0}% {1}', [position, shownName])"
class="peer absolute inset-0 z-10 size-full cursor-ew-resize opacity-0"
@input="onInput"
@keydown="onKeydown"
/>
<div
data-testid="compare-divider"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 -ml-px w-0.5 bg-default outline outline-accented"
:style="{ left: `${position}%` }"
/>
<span
data-testid="compare-handle"
aria-hidden="true"
class="pointer-events-none absolute top-1/2 flex size-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center border border-accented bg-default text-highlighted peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-primary max-sm:size-11"
:style="{ left: `${position}%` }"
>
<UIcon name="i-lucide-chevrons-left-right" class="size-4" />
</span>
</template>
<template v-else>
<span :class="[CHIP, 'left-2']">{{ t('Original') }}</span>
<p
class="absolute right-3 bottom-3 left-3 border border-default bg-default px-3 py-2 text-toned"
>
{{ t('Set a width or height to see a preview.') }}
</p>
</template>
</div>
</template>
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const SAMPLE_PATH = '/static/uploadedImages/photo.png?v=thumb'
const ERROR_BODY = `{
"status": 400,
"error": "unknown_variant",
"message": "Unknown variant",
"detail": "banner"
}`
const rows = computed(() => [
{ request: SAMPLE_PATH, status: 200, result: t('Image'), mono: true },
{
request: t('Unknown or disabled variant'),
status: 400,
result: t('Unknown variant'),
mono: false,
},
{
request: t('width, height, format or quality'),
status: 400,
result: t('Unsupported parameter'),
mono: false,
},
{ request: t('Missing source image'), status: 404, result: t('Not found'), mono: false },
])
const columns = [
{ id: 'request', header: '' },
{ id: 'status', header: '', meta: { class: { td: 'w-15.5' } } },
{ id: 'result', header: '', meta: { class: { td: 'w-30 sm:w-32.5' } } },
]
function isSuccess(status: number) {
return status === 200
}
</script>
<template>
<UCard
data-testid="request-reference"
:ui="{
root: 'rounded-none border border-default bg-default ring-0',
body: 'flex flex-col gap-3 p-4 sm:p-4',
}"
>
<h2 class="font-semibold text-highlighted">{{ t('How requests are handled') }}</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)]">
<div class="border border-default">
<UTable
:data="rows"
:columns="columns"
:ui="{
base: 'w-full table-fixed',
thead: 'hidden',
separator: 'hidden',
td: 'h-11 whitespace-normal px-0 py-1.5 first:pl-3 sm:h-9',
tbody: 'divide-y-0',
tr: 'border-t border-muted first:border-t-0',
}"
>
<template #request-cell="{ row }">
<span
class="block pr-2.5 text-highlighted"
:class="
row.original.mono ? 'font-mono text-xs break-all' : 'break-words'
"
>{{ row.original.request }}</span
>
</template>
<template #status-cell="{ row }">
<UBadge
color="neutral"
variant="soft"
:icon="
isSuccess(row.original.status)
? 'i-lucide-circle-check'
: 'i-lucide-circle-x'
"
:class="
isSuccess(row.original.status)
? 'bg-(--rc-success-soft) text-success'
: 'bg-(--rc-error-soft) text-error'
"
:ui="{ base: 'h-5 gap-1 px-1.5 py-0 text-xs', leadingIcon: 'size-3' }"
>{{ row.original.status }}</UBadge
>
</template>
<template #result-cell="{ row }">
<span class="text-toned">{{ row.original.result }}</span>
</template>
</UTable>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-xs/5 text-muted">{{ t('Body of a 400 response') }}</span>
<pre
class="m-0 overflow-hidden bg-elevated p-3 font-mono text-xs/4.5 text-highlighted"
>{{ ERROR_BODY }}</pre
>
</div>
</div>
<p class="text-xs/5 text-muted">
{{
t(
'unsupported_parameter replaces unknown_variant when a raw parameter is sent. There is no arbitrary-size mode.',
)
}}
</p>
</UCard>
</template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed } from 'vue'
import VariantRowMenu from '@/components/variants/VariantRowMenu.vue'
import VariantStatusSwitch from '@/components/variants/VariantStatusSwitch.vue'
import type { ImageVariant } from '@/composables/useImageVariants'
import { generatedLine, specLine, variantsSummary } from '@/lib/variants/variantFormat'
const props = defineProps<{
items: ImageVariant[]
isEnabled: (item: ImageVariant) => boolean
isLocked: (name: string) => boolean
isPurging: (name: string) => boolean
}>()
const emit = defineEmits<{
toggle: [name: string, value: boolean]
edit: [name: string]
purge: [name: string]
delete: [name: string]
}>()
const summary = computed(() => variantsSummary(props.items))
function nameTone(item: ImageVariant) {
return props.isEnabled(item) ? 'text-highlighted' : 'text-muted'
}
</script>
<template>
<div class="flex flex-col gap-2">
<UCard
v-for="item in items"
:key="item.name"
data-testid="variant-card"
:ui="{
root: 'rounded-none border border-default ring-0',
body: 'flex flex-col gap-1.5 p-3 sm:p-3',
}"
:class="props.isPurging(item.name) ? 'bg-elevated' : 'bg-default'"
>
<div class="flex items-center gap-2">
<span
class="min-w-0 flex-1 truncate font-mono text-base/5 font-medium"
:class="nameTone(item)"
>{{ item.name }}</span
>
<VariantStatusSwitch
:name="item.name"
:enabled="isEnabled(item)"
:purging="isPurging(item.name)"
:locked="isLocked(item.name)"
@toggle="emit('toggle', item.name, $event)"
/>
</div>
<span class="font-mono text-xs/5 text-toned">{{ specLine(item) }}</span>
<div class="flex items-center">
<span class="flex-1 text-muted">{{ generatedLine(item) }}</span>
<VariantRowMenu
:name="item.name"
:disabled="isPurging(item.name)"
@edit="emit('edit', item.name)"
@purge="emit('purge', item.name)"
@delete="emit('delete', item.name)"
/>
</div>
</UCard>
<p data-testid="variants-summary" class="py-1 text-muted">{{ summary }}</p>
</div>
</template>
@@ -0,0 +1,394 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { FormMode } from '@/composables/useVariantForm'
import type { VariantFit, VariantFormat } from '@/composables/useImageVariants'
import {
NAME_MAX_LENGTH,
QUALITY_MAX,
QUALITY_MIN,
sliderValue,
usesQuality,
type FieldErrors,
type FieldName,
type VariantFormValues,
} from '@/lib/variants/variantForm'
const FIELD_BASE =
'h-11 px-3 text-lg ring-inset focus:ring-2 focus:ring-primary aria-invalid:focus:ring-error disabled:bg-elevated disabled:text-highlighted disabled:opacity-60 sm:h-10 sm:text-sm'
const FIELD_UI = { help: 'text-xs leading-5', error: 'text-xs leading-5' }
const MONO_FIELD = `${FIELD_BASE} font-mono`
const ERROR_ROW = 'flex items-start gap-1.5'
const values = defineModel<VariantFormValues>('values', { required: true })
const props = defineProps<{
errors: FieldErrors
mode: FormMode
disabled: boolean
dimensionHelpHidden: boolean
generatedFiles: number
saveFailed: boolean
}>()
const emit = defineEmits<{ blur: [field: FieldName]; retry: [] }>()
const isEdit = computed(() => props.mode === 'edit')
const showCounter = computed(() => !isEdit.value && values.value.name !== '')
const fitOptions = computed(() => [
{ value: 'cover', label: t('Cover') },
{ value: 'contain', label: t('Contain') },
{ value: 'inside', label: t('Inside') },
])
const fitHelp = computed(() => {
const helps: Record<VariantFit, string> = {
cover: t('Fills the box and crops what overflows.'),
contain: t('Whole image inside the box, padded to the box size.'),
inside: t('Whole image inside the box, no padding.'),
}
return helps[values.value.fit]
})
const formatOptions = computed(() => [
{ value: 'original', label: t('Original') },
{ value: 'jpeg', label: 'JPEG' },
{ value: 'png', label: 'PNG' },
{ value: 'webp', label: 'WebP' },
])
const showQuality = computed(() => usesQuality(values.value.format))
const qualityModel = computed({
get: () => sliderValue(values.value.quality),
set: (value: number) => {
values.value.quality = String(value)
},
})
const editWarning = computed(() =>
props.generatedFiles === 1
? t(
'Changing dimensions, fit, format or quality purges 1 generated file; it will be regenerated on next request.',
)
: t(
'Changing dimensions, fit, format or quality purges {0} generated files; they will be regenerated on next request.',
[props.generatedFiles],
),
)
function setFit(value: unknown) {
values.value.fit = value as VariantFit
}
function setFormat(value: unknown) {
values.value.format = value as VariantFormat
}
</script>
<template>
<div class="flex min-w-0 flex-col gap-4">
<UAlert
v-if="saveFailed"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t save the variant')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 sm:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('retry')"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<UAlert
v-if="isEdit"
data-testid="edit-warning"
color="warning"
variant="soft"
:close="false"
icon="i-lucide-triangle-alert"
:description="editWarning"
/>
<div class="flex min-w-0 flex-col gap-4">
<UFormField
:ui="FIELD_UI"
data-field="name"
name="name"
:label="t('Name')"
:error="errors.name"
:help="
isEdit
? t('The name can\'t be changed.')
: t('Lowercase letters, numbers and dashes. Can\'t be changed later.')
"
>
<template v-if="showCounter" #hint>
<span data-testid="name-counter" class="font-mono text-xs">{{
`${values.name.length}/${NAME_MAX_LENGTH}`
}}</span>
</template>
<UInput
v-model="values.name"
:placeholder="t('e.g. hero')"
:readonly="isEdit"
:disabled="disabled"
:trailing-icon="isEdit ? 'i-lucide-lock' : undefined"
autocomplete="off"
class="w-full"
:class="{ 'opacity-100': isEdit }"
:ui="{
base: `${MONO_FIELD} ${isEdit ? 'bg-elevated' : ''}`,
trailingIcon: 'size-3.5 text-muted',
}"
@blur="emit('blur', 'name')"
/>
<template v-if="errors.name" #error="{ error }">
<span :class="ERROR_ROW"
><UIcon name="i-lucide-circle-alert" class="mt-0.75 size-3.5 shrink-0" />{{
error
}}</span
>
</template>
</UFormField>
<div class="flex flex-col gap-2">
<div class="grid grid-cols-2 gap-3">
<UFormField
:ui="FIELD_UI"
data-field="width"
name="width"
:label="t('Width')"
:error="errors.width"
>
<UInput
v-model="values.width"
inputmode="numeric"
:placeholder="t('auto')"
:disabled="disabled"
:color="errors.width ? 'error' : undefined"
:highlight="Boolean(errors.width)"
autocomplete="off"
class="w-full"
:ui="{ base: `${FIELD_BASE} pe-10`, trailing: 'pe-3' }"
@blur="emit('blur', 'width')"
>
<template #trailing>
<span class="font-mono text-sm text-muted">px</span>
</template>
</UInput>
<template v-if="errors.width" #error="{ error }">
<span :class="ERROR_ROW"
><UIcon
name="i-lucide-circle-alert"
class="mt-0.75 size-3.5 shrink-0"
/>{{ error }}</span
>
</template>
</UFormField>
<UFormField
:ui="FIELD_UI"
data-field="height"
name="height"
:label="t('Height')"
:error="errors.height"
>
<UInput
v-model="values.height"
inputmode="numeric"
:placeholder="t('auto')"
:disabled="disabled"
:color="errors.height || errors.dimension ? 'error' : undefined"
:highlight="Boolean(errors.height || errors.dimension)"
autocomplete="off"
class="w-full"
:ui="{ base: `${FIELD_BASE} pe-10`, trailing: 'pe-3' }"
@blur="emit('blur', 'height')"
>
<template #trailing>
<span class="font-mono text-sm text-muted">px</span>
</template>
</UInput>
<template v-if="errors.height" #error="{ error }">
<span :class="ERROR_ROW"
><UIcon
name="i-lucide-circle-alert"
class="mt-0.75 size-3.5 shrink-0"
/>{{ error }}</span
>
</template>
</UFormField>
</div>
<p v-if="!dimensionHelpHidden" class="text-xs/5 text-muted">
{{ t('Set a width, a height, or both. 1–8000 px. Images are never enlarged.') }}
</p>
</div>
<UFormField :ui="FIELD_UI" :label="t('Fit')" :help="fitHelp">
<URadioGroup
:model-value="values.fit"
:items="fitOptions"
:disabled="disabled"
variant="card"
orientation="horizontal"
:class="{ 'opacity-60': disabled }"
:ui="{
fieldset: 'grid grid-cols-3 gap-2',
item: 'min-h-11 items-center justify-center rounded-none border-accented p-0 text-highlighted has-data-[state=checked]:border-primary has-data-[state=checked]:bg-elevated sm:min-h-9',
container: 'sr-only',
wrapper: 'ms-0 w-auto',
label: 'flex items-center justify-center gap-1.5 font-normal text-highlighted',
}"
@update:model-value="setFit"
>
<template #label="{ item }">
<span
class="flex items-center gap-1.5"
:class="values.fit === item.value ? 'font-semibold' : ''"
>
<UIcon
v-if="values.fit === item.value"
name="i-lucide-check"
class="size-3.5"
/>
{{ item.label }}
</span>
</template>
</URadioGroup>
</UFormField>
<UFormField
:ui="FIELD_UI"
:label="t('Format')"
:help="
values.format === 'original'
? t('Keeps the format of each source image.')
: undefined
"
>
<USelect
:model-value="values.format"
:items="formatOptions"
:disabled="disabled"
class="w-full"
:ui="{
base: 'h-11 px-3 text-lg ring-inset disabled:bg-elevated disabled:text-highlighted disabled:opacity-60 sm:h-10 sm:text-sm',
}"
@update:model-value="setFormat"
/>
</UFormField>
<UFormField
v-if="showQuality"
:ui="FIELD_UI"
data-field="quality"
name="quality"
:label="t('Quality')"
:error="errors.quality"
:help="t('1–100. Higher is sharper and larger.')"
>
<div class="flex items-center gap-4" :class="{ 'opacity-60': disabled }">
<USlider
v-model="qualityModel"
:min="QUALITY_MIN"
:max="QUALITY_MAX"
:step="1"
:disabled="disabled"
:aria-label="t('Quality')"
class="flex-1"
:ui="{
root: 'h-11 sm:h-6',
track: 'h-1 rounded-none bg-accented',
range: 'rounded-none bg-primary',
thumb: 'size-4 rounded-none border-2 border-default bg-primary ring-0 outline outline-1 outline-primary',
}"
/>
<div class="pt-1.5">
<UInput
v-model="values.quality"
inputmode="numeric"
:aria-label="t('Quality')"
:disabled="disabled"
:color="errors.quality ? 'error' : undefined"
:highlight="Boolean(errors.quality)"
autocomplete="off"
class="w-21"
:ui="{ base: `${MONO_FIELD} disabled:opacity-100` }"
@blur="emit('blur', 'quality')"
/>
</div>
</div>
<template v-if="errors.quality" #error="{ error }">
<span :class="ERROR_ROW"
><UIcon name="i-lucide-circle-alert" class="mt-0.75 size-3.5 shrink-0" />{{
error
}}</span
>
</template>
</UFormField>
<div class="flex flex-col gap-3 pt-1">
<div class="flex min-h-8 items-start gap-3 max-sm:min-h-11">
<div class="flex min-w-0 flex-1 flex-col">
<span id="generate-on-upload-label" class="font-medium text-highlighted">{{
t('Generate on upload')
}}</span>
<span class="text-xs/5 text-muted">{{
t('Creates this variant when an image is uploaded.')
}}</span>
</div>
<div class="pt-0.5">
<USwitch
v-model="values.generateOnUpload"
:disabled="disabled"
aria-labelledby="generate-on-upload-label"
:ui="{
container: 'h-4.5',
base: 'relative h-4.5 w-8 before:absolute before:-inset-3.5 disabled:opacity-40 sm:before:hidden',
thumb: 'size-3.5 data-[state=checked]:translate-x-3.5',
}"
/>
</div>
</div>
<div class="flex min-h-8 items-start gap-3 max-sm:min-h-11">
<div class="flex min-w-0 flex-1 flex-col">
<span id="variant-enabled-label" class="font-medium text-highlighted">{{
t('Enabled')
}}</span>
<span class="text-xs/5 text-muted">{{
t('Disabled variants return 400 on the public site.')
}}</span>
</div>
<div class="pt-0.5">
<USwitch
v-model="values.enabled"
:disabled="disabled"
aria-labelledby="variant-enabled-label"
:ui="{
container: 'h-4.5',
base: 'relative h-4.5 w-8 before:absolute before:-inset-3.5 disabled:opacity-40 sm:before:hidden',
thumb: 'size-3.5 data-[state=checked]:translate-x-3.5',
}"
/>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,306 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedOverlay } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import VariantFormFields from '@/components/variants/VariantFormFields.vue'
import VariantPreview from '@/components/variants/VariantPreview.vue'
import { ApiError } from '@/composables/useApi'
import type { ImageVariant } from '@/composables/useImageVariants'
import { useVariantForm, type FormMode } from '@/composables/useVariantForm'
import { useVariantToasts } from '@/composables/useVariantToasts'
import { isStatus } from '@/lib/variants/variantErrors'
import {
toDefinition,
toNewVariant,
type FieldName,
type VariantFormValues,
} from '@/lib/variants/variantForm'
import { useImageVariantsStore } from '@/stores/imageVariants'
const DESKTOP_QUERY = '(min-width: 40rem)'
const CONFLICT = 409
const UNPROCESSABLE = 422
const TOAST_STATUSES = [400, 403, 404]
const props = defineProps<{
mode: FormMode
initial: VariantFormValues
variant?: ImageVariant
}>()
const emit = defineEmits<{
close: []
saved: [result: { mode: FormMode; name: string; purgedFiles: number }]
}>()
const store = useImageVariantsStore()
const overlay = useLocalizedOverlay()
const { announceFailure } = useVariantToasts()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const form = useVariantForm(props.mode, props.initial)
const saving = ref(false)
const saveFailed = ref(false)
const discardOpen = ref(false)
const title = computed(() =>
props.mode === 'edit' ? t('Edit variant: {0}', [props.initial.name]) : t('Add variant'),
)
const primaryLabel = computed(() => {
if (saving.value) {
return t('Saving…')
}
return props.mode === 'edit' ? t('Save changes') : t('Add variant')
})
const discardDescription = computed(() =>
form.values.name
? t("You have unsaved changes to “{0}”. If you close now, they're lost.", [
form.values.name,
])
: t("You have unsaved changes. If you close now, they're lost."),
)
function focusField(field: FieldName) {
const target = document.querySelector<HTMLElement>(
`[data-field="${field}"] input:not([type="hidden"])`,
)
target?.focus()
}
function focusOnOpen(event: Event) {
event.preventDefault()
focusField(props.mode === 'edit' ? 'width' : 'name')
}
async function confirmDiscard() {
if (discardOpen.value) {
return
}
discardOpen.value = true
const discard = await overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Discard changes?')
},
get description() {
return discardDescription.value
},
get confirmLabel() {
return t('Discard changes')
},
get cancelLabel() {
return t('Keep editing')
},
get pendingLabel() {
return t('Discard changes')
},
tone: 'danger',
action: async () => undefined,
},
})
.open()
discardOpen.value = false
if (discard) {
emit('close')
}
}
function requestClose() {
if (saving.value) {
return
}
if (!form.dirty.value) {
emit('close')
return
}
confirmDiscard()
}
function onOpenChange(isOpen: boolean) {
if (!isOpen) {
requestClose()
}
}
function showInvalidFromServer(body: string | undefined): FieldName | null {
const text = body ?? ''
if (text.includes('lowercase letters')) {
form.showRemote('name', t('Use lowercase letters, numbers and dashes, 2 to 32 characters.'))
return 'name'
}
if (text.includes('Width and height')) {
form.showRemote('width', t('Set a width, a height, or both.'))
return 'width'
}
if (text.includes('Quality')) {
form.showRemote('quality', t('Enter a value from 1 to 100.'))
return 'quality'
}
saveFailed.value = true
return null
}
function handleFailure(error: unknown): FieldName | null {
if (!(error instanceof ApiError)) {
saveFailed.value = true
return null
}
if (isStatus(error, CONFLICT)) {
form.showRemote(
'name',
t('A variant named “{0}” already exists. Choose another name.', [form.values.name]),
)
return 'name'
}
if (isStatus(error, UNPROCESSABLE)) {
return showInvalidFromServer(error.body)
}
if (TOAST_STATUSES.includes(error.status)) {
announceFailure(error)
return null
}
if (error.status !== 401) {
saveFailed.value = true
}
return null
}
async function persist() {
if (props.mode === 'edit' && props.variant) {
const saved = await store.save(props.variant.name, toDefinition(form.values))
emit('saved', { mode: 'edit', name: saved.name, purgedFiles: saved.purged_files ?? 0 })
return
}
const created = await store.create(toNewVariant(form.values))
emit('saved', { mode: 'create', name: created.name, purgedFiles: 0 })
}
async function submit() {
if (saving.value) {
return
}
saveFailed.value = false
const invalid = form.validateAll()
if (invalid) {
focusField(invalid)
return
}
saving.value = true
let toFocus: FieldName | null = null
try {
await persist()
} catch (error) {
toFocus = handleFailure(error)
} finally {
saving.value = false
}
if (toFocus) {
await nextTick()
focusField(toFocus)
}
}
</script>
<template>
<USlideover
:open="true"
side="right"
:title="title"
:description="t('Define a named size and format for generated images.')"
:close="
isDesktop
? {
icon: 'i-lucide-x',
color: 'neutral',
variant: 'ghost',
'aria-label': t('Close'),
class: 'size-7 justify-center text-muted',
}
: false
"
:content="{ onOpenAutoFocus: focusOnOpen }"
:ui="{
content: 'w-full max-w-none sm:max-w-240',
header: 'h-14 min-h-14 gap-3 px-5 py-0 max-sm:gap-1 max-sm:px-1.5',
wrapper: 'min-w-0 flex-1',
title: 'truncate text-lg font-semibold',
description: 'sr-only',
body: 'min-h-0 p-0 sm:overflow-hidden sm:p-0',
footer: 'h-18 gap-2 px-4 py-0 sm:h-16 sm:gap-3 sm:px-5',
}"
@update:open="onOpenChange"
>
<template #actions>
<UButton
v-if="!isDesktop"
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
:aria-label="t('Back')"
class="order-first size-11 shrink-0 justify-center"
:ui="{ leadingIcon: 'size-5' }"
@click="requestClose"
/>
</template>
<template #body>
<UForm
:state="form.values"
:validate-on="[]"
class="flex flex-col gap-6 p-4 max-sm:pb-25 sm:grid sm:h-full sm:grid-cols-[440px_minmax(0,1fr)] sm:gap-0 sm:p-0"
@submit="submit"
>
<div class="min-w-0 sm:overflow-y-auto sm:p-5">
<VariantFormFields
v-model:values="form.values"
:errors="form.errors.value"
:mode="mode"
:disabled="saving"
:dimension-help-hidden="form.dimensionHelpHidden.value"
:generated-files="variant?.generated_files ?? 0"
:save-failed="saveFailed"
@blur="form.onBlur"
@retry="submit"
/>
</div>
<div
class="min-w-0 border-t border-default pt-4 sm:overflow-y-auto sm:border-t-0 sm:border-l sm:bg-muted sm:p-5"
>
<VariantPreview :values="form.values" :disabled="saving" />
</div>
</UForm>
</template>
<template #footer>
<div class="flex w-full items-center gap-2 sm:gap-3">
<div v-if="isDesktop" class="flex flex-1 items-center gap-1.5 text-toned">
<template v-if="form.dirty.value">
<UIcon name="i-lucide-circle-dot" class="size-3.5" />
{{ t('Unsaved changes') }}
</template>
</div>
<UButton
color="neutral"
variant="outline"
:disabled="saving"
class="h-11 flex-1 justify-center border border-accented bg-default px-3 text-sm text-highlighted ring-0 sm:h-8 sm:flex-none"
@click="requestClose"
>
{{ t('Cancel') }}
</UButton>
<UButton
:loading="saving"
:disabled="!form.dirty.value || saving"
class="h-11 flex-1 justify-center px-4 text-sm sm:h-8 sm:flex-none"
@click="submit"
>
{{ primaryLabel }}
</UButton>
</div>
</template>
</USlideover>
</template>
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import ImageCompare from '@/components/variants/ImageCompare.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { SAMPLE_SOURCE, estimateLabel, estimateVariant } from '@/lib/variants/variantEstimate'
import { parseDimension, sliderValue, type VariantFormValues } from '@/lib/variants/variantForm'
import { formatVariantSize } from '@/lib/variants/variantFormat'
const URL_PREFIX = '/static/uploadedImages/'
const NAME_PLACEHOLDER = '<name>'
const props = defineProps<{
values: VariantFormValues
disabled: boolean
}>()
const position = ref(50)
const { copied, copy } = useCopyToClipboard()
const dimensionsAreValid = computed(
() =>
(props.values.width.trim() === '' || parseDimension(props.values.width) !== null) &&
(props.values.height.trim() === '' || parseDimension(props.values.height) !== null),
)
const estimate = computed(() =>
dimensionsAreValid.value
? estimateVariant({
width: parseDimension(props.values.width),
height: parseDimension(props.values.height),
fit: props.values.fit,
format: props.values.format,
quality: sliderValue(props.values.quality),
})
: null,
)
const shownName = computed(() => props.values.name || t('name'))
const outputLine = computed(() =>
estimate.value ? estimateLabel(estimate.value) : t('No output yet'),
)
const originalLine = computed(
() =>
`${SAMPLE_SOURCE.width} × ${SAMPLE_SOURCE.height} · ${formatVariantSize(SAMPLE_SOURCE.bytes)}`,
)
const hasName = computed(() => props.values.name !== '')
const url = computed(
() => `${URL_PREFIX}${SAMPLE_SOURCE.filename}?v=${props.values.name || NAME_PLACEHOLDER}`,
)
async function onCopy() {
try {
await copy(url.value)
} catch {
return
}
}
</script>
<template>
<div class="flex min-w-0 flex-col gap-3">
<div class="flex items-baseline gap-2">
<h3 class="flex-1 font-semibold text-highlighted">{{ t('Preview') }}</h3>
<span class="text-xs/5 text-muted">{{
t('Sample: {0}', [SAMPLE_SOURCE.filename])
}}</span>
</div>
<ImageCompare v-model="position" :name="values.name" :comparable="estimate !== null" />
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col">
<span class="font-medium text-highlighted">{{ t('Original') }}</span>
<span class="text-xs/5 text-muted">{{ originalLine }}</span>
</div>
<div class="flex flex-col">
<span class="font-mono font-medium text-highlighted">{{ shownName }}</span>
<span class="text-xs/5 text-muted">{{ outputLine }}</span>
</div>
</div>
<p class="text-xs/5 text-muted">
{{
t(
'Drag the divider, or focus it and use the arrow keys. The estimate comes from a sample; output is never larger than the source.',
)
}}
</p>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('URL') }}</span>
<div class="flex gap-2">
<div
data-testid="variant-url"
class="flex min-h-11 min-w-0 flex-1 items-center truncate border border-default bg-elevated px-3 font-mono text-xs sm:min-h-8"
:class="hasName ? 'text-highlighted' : 'text-muted'"
>
{{ url }}
</div>
<UButton
color="neutral"
variant="outline"
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
:disabled="!hasName || disabled"
class="h-11 shrink-0 gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 sm:h-8"
@click="onCopy"
>
{{ copied ? t('Copied') : t('Copy') }}
</UButton>
</div>
</div>
</div>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const props = defineProps<{
name: string
disabled: boolean
}>()
const emit = defineEmits<{ edit: []; purge: []; delete: [] }>()
const label = computed(() => t('Actions for {0}', [props.name]))
const items = computed(() => [
[
{
get label() {
return t('Edit')
},
icon: 'i-lucide-pencil',
onSelect: () => emit('edit'),
},
{
get label() {
return t('Purge cache')
},
icon: 'i-lucide-eraser',
onSelect: () => emit('purge'),
},
{
get label() {
return t('Delete')
},
icon: 'i-lucide-trash-2',
color: 'error' as const,
onSelect: () => emit('delete'),
},
],
])
</script>
<template>
<UTooltip :text="label">
<UDropdownMenu
:items="items"
:content="{ align: 'end' }"
:ui="{
content: 'w-44',
item: 'h-8 gap-2 px-2 text-sm max-sm:h-11',
itemLeadingIcon: 'size-3.5',
}"
>
<UButton
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
:aria-label="label"
:disabled="disabled"
class="size-7 shrink-0 justify-center text-highlighted data-[state=open]:outline-2 data-[state=open]:outline-offset-2 data-[state=open]:outline-primary max-sm:size-11"
/>
</UDropdownMenu>
</UTooltip>
</template>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const props = defineProps<{
name: string
enabled: boolean
purging: boolean
locked: boolean
}>()
const emit = defineEmits<{ toggle: [value: boolean] }>()
const word = computed(() => {
if (props.purging) {
return t('Purging…')
}
return props.enabled ? t('Enabled') : t('Disabled')
})
const icon = computed(() => {
if (props.purging) {
return 'i-lucide-loader-circle'
}
return props.enabled ? 'i-lucide-circle-check' : 'i-lucide-circle-off'
})
const tone = computed(() => {
if (props.purging) {
return 'text-info'
}
return props.enabled ? 'text-highlighted' : 'text-muted'
})
</script>
<template>
<div class="flex items-center gap-2 whitespace-nowrap" :class="tone">
<USwitch
:model-value="enabled"
:disabled="locked"
:aria-label="t('Enable variant {0}', [name])"
:ui="{
container: 'h-4.5',
base: 'relative h-4.5 w-8 before:absolute before:-inset-3.5 data-[disabled]:opacity-40 sm:before:hidden',
thumb: 'size-3.5 data-[state=checked]:translate-x-3.5',
}"
@update:model-value="emit('toggle', $event)"
/>
<span class="flex items-center gap-1">
<UIcon :name="icon" class="size-3.5 shrink-0" :class="{ 'animate-spin': purging }" />
{{ word }}
</span>
</div>
</template>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { VARIANT_TEMPLATES } from '@/lib/variants/variantForm'
import { fitLabel, formatDimensions, formatLabel } from '@/lib/variants/variantFormat'
const emit = defineEmits<{ add: []; template: [name: string] }>()
function description(template: (typeof VARIANT_TEMPLATES)[number]) {
return [
formatDimensions(template.width, template.height),
fitLabel('cover'),
formatLabel('webp'),
].join(' · ')
}
</script>
<template>
<UEmpty
variant="naked"
icon="i-lucide-layers"
:title="t('No variants yet')"
:description="
t(
'A variant is a named size that an image can be requested in, like ?v=thumb. Start with one of these.',
)
"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-10',
header: 'max-w-none gap-3',
body: 'w-full max-w-none items-center gap-3',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md font-semibold',
description: 'max-w-95 text-pretty text-muted',
actions: 'w-full flex-col items-center justify-center gap-3',
}"
>
<template #actions>
<div class="flex w-full max-w-130 flex-col gap-3 sm:flex-row">
<UButton
v-for="template in VARIANT_TEMPLATES"
:key="template.name"
color="neutral"
variant="outline"
class="min-h-16 flex-1 flex-col items-start justify-start gap-0.5 border border-accented bg-default p-3 text-left ring-0 sm:min-h-0"
@click="emit('template', template.name)"
>
<span class="font-mono font-medium text-highlighted">{{ template.name }}</span>
<span class="text-xs/5 text-muted">{{ description(template) }}</span>
<span class="mt-2 flex items-center gap-1 font-medium text-highlighted">
<UIcon name="i-lucide-plus" class="size-3.5" />
{{ t('Use template') }}
</span>
</UButton>
</div>
<UButton icon="i-lucide-plus" class="h-8 px-4" @click="emit('add')">
{{ t('Add variant') }}
</UButton>
</template>
</UEmpty>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { t } from '@/i18n'
const emit = defineEmits<{ home: [] }>()
</script>
<template>
<UEmpty
variant="naked"
icon="i-lucide-lock"
:title="t('You don\'t have access')"
:description="
t('Only admins can manage image variants. Ask an admin if you need one changed.')
"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-18',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md font-semibold',
description: 'max-w-95 text-pretty text-muted',
}"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-arrow-left"
class="h-11 gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 sm:h-8"
@click="emit('home')"
>
{{ t('Back to Home') }}
</UButton>
</template>
</UEmpty>
</template>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { t } from '@/i18n'
defineProps<{ desktop: boolean }>()
const DESKTOP_ROWS = 5
const MOBILE_CARDS = 4
const ROW_BARS = ['w-30', 'w-22.5', 'w-15', 'w-15', 'w-20', 'w-40']
const BAR = 'rounded-none bg-accented'
</script>
<template>
<section :aria-label="t('Loading variants')" aria-busy="true">
<div v-if="desktop" class="border border-default bg-default">
<div class="h-9 bg-elevated" />
<div
v-for="row in DESKTOP_ROWS"
:key="row"
class="flex h-12 items-center gap-8 border-t border-muted px-4"
>
<USkeleton
v-for="(width, index) in ROW_BARS"
:key="index"
class="h-3.25"
:class="[BAR, width]"
/>
</div>
</div>
<div v-else class="flex flex-col gap-2">
<div
v-for="card in MOBILE_CARDS"
:key="card"
class="flex flex-col gap-2.5 border border-default bg-default p-3"
>
<USkeleton class="h-3.25 w-[40%]" :class="BAR" />
<USkeleton class="h-2.75 w-[70%]" :class="BAR" />
<USkeleton class="h-2.75 w-[55%]" :class="BAR" />
</div>
</div>
</section>
</template>
@@ -0,0 +1,175 @@
<script setup lang="ts">
import { formatNumber, t } from '@/i18n'
import { computed } from 'vue'
import VariantRowMenu from '@/components/variants/VariantRowMenu.vue'
import VariantStatusSwitch from '@/components/variants/VariantStatusSwitch.vue'
import type { ImageVariant } from '@/composables/useImageVariants'
import {
fitLabel,
formatDimensions,
formatLabel,
formatVariantSize,
qualityCell,
variantsSummary,
} from '@/lib/variants/variantFormat'
const props = defineProps<{
items: ImageVariant[]
isEnabled: (item: ImageVariant) => boolean
isLocked: (name: string) => boolean
isPurging: (name: string) => boolean
}>()
const emit = defineEmits<{
toggle: [name: string, value: boolean]
edit: [name: string]
purge: [name: string]
delete: [name: string]
}>()
function fixed(width: string) {
return { meta: { class: { th: width, td: width } } }
}
const columns = computed(() => [
{
id: 'name',
get header() {
return t('Name')
},
},
{
id: 'dimensions',
get header() {
return t('Dimensions')
},
...fixed('w-26'),
},
{
id: 'fit',
get header() {
return t('Fit')
},
...fixed('w-19'),
},
{
id: 'format',
get header() {
return t('Format')
},
...fixed('w-18'),
},
{
id: 'quality',
get header() {
return t('Quality')
},
...fixed('w-17'),
},
{
id: 'generated',
get header() {
return t('Generated')
},
...fixed('w-23'),
},
{
id: 'cache_size',
get header() {
return t('Cache size')
},
...fixed('w-23'),
},
{
id: 'status',
get header() {
return t('Status')
},
...fixed('w-33'),
},
{ id: 'actions', header: '', ...fixed('w-11') },
])
const meta = {
class: {
tr: (row: { original: ImageVariant }) => {
const item = row.original
const tone = props.isEnabled(item) ? 'text-default' : 'text-muted'
return props.isPurging(item.name) ? `bg-elevated ${tone}` : `bg-default ${tone}`
},
},
}
const summary = computed(() => variantsSummary(props.items))
function variant(row: { original: ImageVariant }) {
return row.original
}
function nameTone(row: { original: ImageVariant }) {
return props.isEnabled(variant(row)) ? 'text-highlighted' : 'text-muted'
}
</script>
<template>
<div class="border border-default bg-default">
<UTable
:data="items"
:columns="columns"
:meta="meta"
:ui="{
base: 'w-full table-fixed',
thead: 'bg-elevated [&>tr]:border-t-0',
separator: 'hidden',
th: 'h-9 px-0 py-0 first:pl-4 last:pr-2',
td: 'h-12 px-0 py-0 first:pl-4 last:pr-2',
tbody: 'divide-y-0',
tr: 'border-t border-muted',
}"
>
<template #name-cell="{ row }">
<span class="block truncate font-mono text-sm font-medium" :class="nameTone(row)">{{
variant(row).name
}}</span>
</template>
<template #dimensions-cell="{ row }">
<span class="font-mono text-xs">{{
formatDimensions(variant(row).width, variant(row).height)
}}</span>
</template>
<template #fit-cell="{ row }">{{ fitLabel(variant(row).fit) }}</template>
<template #format-cell="{ row }">{{ formatLabel(variant(row).format) }}</template>
<template #quality-cell="{ row }">{{ qualityCell(variant(row)) }}</template>
<template #generated-cell="{ row }">{{
formatNumber(variant(row).generated_files)
}}</template>
<template #cache_size-cell="{ row }">{{
formatVariantSize(variant(row).cache_size)
}}</template>
<template #status-cell="{ row }">
<VariantStatusSwitch
:name="variant(row).name"
:enabled="isEnabled(variant(row))"
:purging="isPurging(variant(row).name)"
:locked="isLocked(variant(row).name)"
@toggle="emit('toggle', variant(row).name, $event)"
/>
</template>
<template #actions-cell="{ row }">
<span class="flex justify-end">
<VariantRowMenu
:name="variant(row).name"
:disabled="isPurging(variant(row).name)"
@edit="emit('edit', variant(row).name)"
@purge="emit('purge', variant(row).name)"
@delete="emit('delete', variant(row).name)"
/>
</span>
</template>
</UTable>
<div data-testid="variants-summary" class="border-t border-default px-4 py-2.5 text-toned">
{{ summary }}
</div>
</div>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { t } from '@/i18n'
const TAB = 'h-9 gap-1.5 rounded-none border-b-2 px-0 py-0 text-sm hover:bg-transparent max-sm:h-11'
const ACTIVE = 'border-primary font-medium text-highlighted'
const INACTIVE = 'border-transparent font-normal text-toned aria-disabled:opacity-100'
</script>
<template>
<div
role="tablist"
:aria-label="t('Image variants')"
class="flex gap-5 border-b border-default"
>
<UButton
role="tab"
aria-selected="true"
icon="i-lucide-layers"
color="neutral"
variant="ghost"
:class="[TAB, ACTIVE, '-mb-px']"
:ui="{ leadingIcon: 'size-3.5' }"
>
{{ t('Variants') }}
</UButton>
<UButton
role="tab"
aria-selected="false"
aria-disabled="true"
icon="i-lucide-database"
color="neutral"
variant="ghost"
:class="[TAB, INACTIVE, '-mb-px']"
:ui="{ leadingIcon: 'size-3.5' }"
>
{{ t('Cache') }}
</UButton>
</div>
</template>
+99
View File
@@ -0,0 +1,99 @@
import { useApi } from '@/composables/useApi'
export type VariantFit = 'cover' | 'contain' | 'inside'
export type VariantFormat = 'original' | 'jpeg' | 'png' | 'webp'
export interface ImageVariant {
name: string
width: number | null
height: number | null
fit: VariantFit
format: VariantFormat
quality: number | null
generate_on_upload: boolean
enabled: boolean
generated_files: number
cache_size: number
created_at?: string
updated_at?: string
}
export interface VariantDefinition {
width: number | null
height: number | null
fit: VariantFit
format: VariantFormat
quality?: number
generate_on_upload: boolean
enabled: boolean
}
export interface NewVariantRequest extends VariantDefinition {
name: string
}
export interface SavedVariant extends ImageVariant {
purged_files?: number
}
export interface PurgeResult {
purged_files: number
purged_bytes: number
}
const JSON_HEADERS = { 'Content-Type': 'application/json' }
const BASE_PATH = '/v1/image-variants'
function variantPath(name: string) {
return `${BASE_PATH}/${encodeURIComponent(name)}`
}
export function useImageVariants() {
const { request } = useApi()
async function listVariants(): Promise<ImageVariant[]> {
const body = await request<{ items: ImageVariant[] }>(BASE_PATH)
return body.items
}
function createVariant(input: NewVariantRequest): Promise<ImageVariant> {
return request<ImageVariant>(BASE_PATH, {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify(input),
})
}
function replaceVariant(name: string, input: VariantDefinition): Promise<SavedVariant> {
return request<SavedVariant>(variantPath(name), {
method: 'PUT',
headers: JSON_HEADERS,
body: JSON.stringify(input),
})
}
function setVariantEnabled(name: string, enabled: boolean): Promise<ImageVariant> {
return request<ImageVariant>(variantPath(name), {
method: 'PATCH',
headers: JSON_HEADERS,
body: JSON.stringify({ enabled }),
})
}
function deleteVariant(name: string): Promise<void> {
return request<void>(variantPath(name), { method: 'DELETE' })
}
function purgeVariant(name: string): Promise<PurgeResult> {
return request<PurgeResult>(`${variantPath(name)}/purge`, { method: 'POST' })
}
return {
listVariants,
createVariant,
replaceVariant,
setVariantEnabled,
deleteVariant,
purgeVariant,
}
}
+93
View File
@@ -0,0 +1,93 @@
import { t } from '@/i18n'
import { computed, reactive, ref, watch } from 'vue'
import {
FIELD_ORDER,
blankValues,
hasChanged,
lacksDimension,
validateField,
type FieldErrors,
type FieldName,
type VariantFormValues,
} from '@/lib/variants/variantForm'
export type FormMode = 'create' | 'edit'
type RemoteField = 'name' | 'width' | 'quality'
export function useVariantForm(mode: FormMode, initial: VariantFormValues) {
const values = reactive<VariantFormValues>({ ...initial })
const baseline: VariantFormValues = mode === 'edit' ? { ...initial } : blankValues()
const shown = reactive<Record<FieldName, boolean>>({
name: false,
width: false,
height: false,
quality: false,
})
const dimensionShown = ref(false)
const remote = reactive<Partial<Record<RemoteField, string>>>({})
const dirty = computed(() => hasChanged(values, baseline))
const dimensionError = computed(() =>
dimensionShown.value && lacksDimension(values)
? t('Set a width, a height, or both.')
: undefined,
)
const errors = computed<FieldErrors>(() => ({
name: remote.name ?? (shown.name ? validateField('name', values) : undefined),
width:
remote.width ??
(shown.width ? validateField('width', values) : undefined) ??
dimensionError.value,
height: shown.height ? validateField('height', values) : undefined,
quality: remote.quality ?? (shown.quality ? validateField('quality', values) : undefined),
dimension: dimensionError.value,
}))
const dimensionHelpHidden = computed(
() =>
Boolean(errors.value.width) ||
Boolean(errors.value.height) ||
Boolean(dimensionError.value),
)
function onBlur(field: FieldName) {
if (values[field] !== baseline[field]) {
shown[field] = true
}
}
function validateAll(): FieldName | null {
for (const field of FIELD_ORDER) {
shown[field] = true
}
dimensionShown.value = true
const invalid = FIELD_ORDER.find((field) => validateField(field, values) !== undefined)
if (invalid) {
return invalid
}
return lacksDimension(values) ? 'width' : null
}
function showRemote(field: RemoteField, message: string) {
remote[field] = message
}
watch(
() => values.name,
() => delete remote.name,
)
watch(
() => [values.width, values.height],
() => delete remote.width,
)
watch(
() => values.quality,
() => delete remote.quality,
)
return { values, dirty, errors, dimensionHelpHidden, onBlur, validateAll, showRemote }
}
+31
View File
@@ -0,0 +1,31 @@
import { useLocalizedToast } from '@/i18n/presentation'
import { toastCopyFor } from '@/lib/variants/variantErrors'
export function useVariantToasts() {
const toast = useLocalizedToast()
function announceFailure(error: unknown) {
toast.add(() => ({
color: 'error' as const,
icon: 'i-lucide-circle-x',
get title() {
return toastCopyFor(error).title
},
get description() {
return toastCopyFor(error).description
},
}))
}
function announceSuccess(message: () => string) {
toast.add(() => ({
color: 'success' as const,
icon: 'i-lucide-circle-check',
get title() {
return message()
},
}))
}
return { announceFailure, announceSuccess }
}
+124
View File
@@ -1237,6 +1237,130 @@ export const messages = {
'Apply {0} GB': 'Appliquer {0} Go',
"Couldn't save the retention period": 'Impossible d’enregistrer la durée de conservation',
'Your previous setting is still in place.': 'Votre réglage précédent est conservé.',
'Image variants': 'Variantes d’image',
Variants: 'Variantes',
Cache: 'Cache',
'Add variant': 'Ajouter une variante',
'Named sizes and formats generated from your original images. Originals are never changed.':
'Tailles et formats nommés générés à partir de vos images originales. Les originaux ne sont jamais modifiés.',
'Loading variants': 'Chargement des variantes',
Generated: 'Générés',
'Cache size': 'Taille du cache',
Fit: 'Ajustement',
Format: 'Format',
Quality: 'Qualité',
Height: 'Hauteur',
Enabled: 'Activée',
Disabled: 'Désactivée',
cover: 'remplir',
contain: 'contenir',
inside: 'à l’intérieur',
Cover: 'Remplir',
Contain: 'Contenir',
Inside: 'À l’intérieur',
auto: 'auto',
name: 'nom',
'q{0}': 'q{0}',
'1 variant': '1 variante',
'{0} variants': '{0} variantes',
'1 generated file': '1 fichier généré',
'{0} generated files': '{0} fichiers générés',
'{0} generated · {1}': '{0} générés · {1}',
'Enable variant {0}': 'Activer la variante {0}',
'Purge cache': 'Purger le cache',
'Use template': 'Utiliser le modèle',
'No variants yet': 'Aucune variante pour l’instant',
'A variant is a named size that an image can be requested in, like ?v=thumb. Start with one of these.':
'Une variante est une taille nommée dans laquelle une image peut être demandée, comme ?v=thumb. Commencez par l’une de celles-ci.',
"You don't have access": 'Vous n’avez pas accès',
'Only admins can manage image variants. Ask an admin if you need one changed.':
'Seuls les administrateurs peuvent gérer les variantes d’image. Demandez à un administrateur si vous en avez besoin d’une modifiée.',
'Only admins can manage image variants.':
'Seuls les administrateurs peuvent gérer les variantes d’image.',
'Back to Home': 'Retour à l’accueil',
"Couldn't load variants": 'Impossible de charger les variantes',
"Couldn't save the variant": 'Impossible d’enregistrer la variante',
'Check the request': 'Vérifiez la requête',
"A value in the request isn't valid. Reload the page and try again.":
'Une valeur de la requête n’est pas valide. Rechargez la page et réessayez.',
'This variant or image no longer exists. Refresh the page.':
'Cette variante ou cette image n’existe plus. Actualisez la page.',
"The server couldn't finish. Try again. If it keeps happening, check the server log.":
'Le serveur n’a pas pu terminer. Réessayez. Si le problème persiste, consultez le journal du serveur.',
'Purge cache for “{0}”?': 'Purger le cache de « {0} » ?',
'This removes {0} ({1}). Files are generated again on the next request.':
'Cela supprime {0} ({1}). Les fichiers sont générés à nouveau à la prochaine requête.',
'Purge 1 file': 'Purger 1 fichier',
'Purge {0} files': 'Purger {0} fichiers',
'Purged 1 file from “{0}”': '1 fichier purgé de « {0} »',
'Purged {0} files from “{1}”': '{0} fichiers purgés de « {1} »',
'Delete variant “{0}”?': 'Supprimer la variante « {0} » ?',
"This removes the variant and its {0} ({1}). Articles that use ?v={2} will start returning 400. This can't be undone.":
'Cela supprime la variante et ses fichiers générés ({0}, {1}). Les articles qui utilisent ?v={2} renverront une erreur 400. Cette action est irréversible.',
'Delete variant': 'Supprimer la variante',
'Variant “{0}” deleted': 'Variante « {0} » supprimée',
'Variant “{0}” added': 'Variante « {0} » ajoutée',
'Variant “{0}” saved.': 'Variante « {0} » enregistrée.',
'Variant “{0}” saved. 1 file purged.': 'Variante « {0} » enregistrée. 1 fichier purgé.',
'Variant “{0}” saved. {1} files purged.': 'Variante « {0} » enregistrée. {1} fichiers purgés.',
'Unknown or disabled variant': 'Variante inconnue ou désactivée',
'Unknown variant': 'Variante inconnue',
'width, height, format or quality': 'width, height, format ou quality',
'Unsupported parameter': 'Paramètre non pris en charge',
'Missing source image': 'Image source introuvable',
'How requests are handled': 'Traitement des requêtes',
'Body of a 400 response': 'Corps d’une réponse 400',
'unsupported_parameter replaces unknown_variant when a raw parameter is sent. There is no arbitrary-size mode.':
'unsupported_parameter remplace unknown_variant lorsqu’un paramètre brut est envoyé. Il n’existe pas de mode à taille arbitraire.',
'Edit variant: {0}': 'Modifier la variante : {0}',
'Save changes': 'Enregistrer les modifications',
Back: 'Retour',
'Define a named size and format for generated images.':
'Définit une taille et un format nommés pour les images générées.',
"You have unsaved changes to “{0}”. If you close now, they're lost.":
'Vous avez des modifications non enregistrées sur « {0} ». Si vous fermez maintenant, elles seront perdues.',
"You have unsaved changes. If you close now, they're lost.":
'Vous avez des modifications non enregistrées. Si vous fermez maintenant, elles seront perdues.',
'Discard changes?': 'Abandonner les modifications ?',
"The name can't be changed.": 'Le nom ne peut pas être modifié.',
"Lowercase letters, numbers and dashes. Can't be changed later.":
'Lettres minuscules, chiffres et tirets. Ne pourra pas être modifié plus tard.',
'e.g. hero': 'p. ex. hero',
'Set a width, a height, or both. 1–8000 px. Images are never enlarged.':
'Indiquez une largeur, une hauteur, ou les deux. De 1 à 8 000 px. Les images ne sont jamais agrandies.',
'Set a width, a height, or both.': 'Indiquez une largeur, une hauteur, ou les deux.',
'Enter a value from 1 to 8000.': 'Saisissez une valeur de 1 à 8 000.',
'Enter a value from 1 to 100.': 'Saisissez une valeur de 1 à 100.',
'Use lowercase letters, numbers and dashes, 2 to 32 characters.':
'Utilisez des lettres minuscules, des chiffres et des tirets, de 2 à 32 caractères.',
'A variant named “{0}” already exists. Choose another name.':
'Une variante nommée « {0} » existe déjà. Choisissez un autre nom.',
'Fills the box and crops what overflows.': 'Remplit le cadre et rogne ce qui dépasse.',
'Whole image inside the box, padded to the box size.':
'Image entière dans le cadre, complétée jusqu’à la taille du cadre.',
'Whole image inside the box, no padding.': 'Image entière dans le cadre, sans marge.',
'Keeps the format of each source image.': 'Conserve le format de chaque image source.',
'1–100. Higher is sharper and larger.':
'De 1 à 100. Plus la valeur est haute, plus l’image est nette et lourde.',
'Generate on upload': 'Générer à l’import',
'Creates this variant when an image is uploaded.':
'Crée cette variante lorsqu’une image est importée.',
'Disabled variants return 400 on the public site.':
'Les variantes désactivées renvoient 400 sur le site public.',
'Changing dimensions, fit, format or quality purges 1 generated file; it will be regenerated on next request.':
'Modifier les dimensions, l’ajustement, le format ou la qualité purge 1 fichier généré ; il sera régénéré à la prochaine requête.',
'Changing dimensions, fit, format or quality purges {0} generated files; they will be regenerated on next request.':
'Modifier les dimensions, l’ajustement, le format ou la qualité purge {0} fichiers générés ; ils seront régénérés à la prochaine requête.',
'Set a width or height to see a preview.':
'Indiquez une largeur ou une hauteur pour voir un aperçu.',
'Compare original and {0}': 'Comparer l’original et {0}',
'{0}% {1}': '{0} % {1}',
'No output yet': 'Pas encore de résultat',
'Sample: {0}': 'Exemple : {0}',
'{0} · about {1}': '{0} · environ {1}',
'Drag the divider, or focus it and use the arrow keys. The estimate comes from a sample; output is never larger than the source.':
'Faites glisser le séparateur, ou donnez-lui le focus et utilisez les flèches. L’estimation vient d’un échantillon ; le résultat n’est jamais plus lourd que la source.',
URL: 'URL',
} as const
export type MessageKey = keyof typeof messages
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest'
import {
SAMPLE_SOURCE,
estimateLabel,
estimateVariant,
formatPixelSize,
type EstimateRequest,
} from '../variantEstimate'
import { outputSize } from '../variantOutput'
const SAMPLE = { width: 4032, height: 3024 }
function request(overrides: Partial<EstimateRequest>): EstimateRequest {
return {
width: 1200,
height: 630,
fit: 'cover',
format: 'webp',
quality: 82,
...overrides,
}
}
function labelOf(overrides: Partial<EstimateRequest>) {
return estimateLabel(estimateVariant(request(overrides))!)
}
describe('Output size, never enlarged', () => {
it.each([
[1200, 630, 'cover', '1200×630'],
[800, null, 'cover', '800×600'],
[null, 2400, 'inside', '3200×2400'],
[600, 600, 'contain', '600×600'],
[1000, 1000, 'inside', '1000×750'],
[8000, 8000, 'cover', '4032×3024'],
] as const)('width %s height %s fit %s gives %s', (width, height, fit, expected) => {
const size = outputSize(SAMPLE, { width, height, fit })
expect(formatPixelSize(size!)).toBe(expected)
})
it('has no output without a dimension', () => {
expect(outputSize(SAMPLE, { width: null, height: null, fit: 'cover' })).toBeNull()
})
it('crops to the box ratio when the box is smaller than the source in one dimension', () => {
const size = outputSize(
{ width: 300, height: 200 },
{ width: 400, height: 150, fit: 'cover' },
)
expect(size).toEqual({ width: 300, height: 113 })
})
it('crops a square box out of the sample', () => {
const size = outputSize(SAMPLE, { width: 1000, height: 1000, fit: 'cover' })
expect(size).toEqual({ width: 1000, height: 1000 })
})
})
describe('Size estimate', () => {
it.each([
['hero', 1200, 630, 'cover', 'webp', 82, '1200×630 · about 96 KB'],
['hero', 1200, 630, 'cover', 'webp', 70, '1200×630 · about 71 KB'],
['hero', 1200, 630, 'cover', 'png', 82, '1200×630 · about 310 KB'],
['thumb', 400, 300, 'cover', 'webp', 80, '400×300 · about 18 KB'],
] as const)(
'%s %s×%s %s %s q%s reads %s',
(_name, width, height, fit, format, quality, expected) => {
expect(labelOf({ width, height, fit, format, quality })).toBe(expected)
},
)
it('gives a smaller or equal estimate for a lower quality', () => {
const bytes = (quality: number) => estimateVariant(request({ quality }))!.bytes
const sizes = [1, 20, 40, 60, 80, 100].map(bytes)
expect(sizes).toEqual([...sizes].sort((left, right) => left - right))
})
it('never exceeds the original size', () => {
const estimate = estimateVariant(
request({ width: 8000, height: 8000, format: 'png', quality: 100 }),
)
expect(estimate!.bytes).toBeLessThanOrEqual(SAMPLE_SOURCE.bytes)
})
it('is empty without a dimension', () => {
expect(estimateVariant(request({ width: null, height: null }))).toBeNull()
})
})
@@ -0,0 +1,156 @@
import { describe, expect, it } from 'vitest'
import {
VARIANT_TEMPLATES,
blankValues,
hasChanged,
lacksDimension,
sliderValue,
toDefinition,
toNewVariant,
validateField,
valuesFromTemplate,
valuesFromVariant,
type FieldName,
type VariantFormValues,
} from '../variantForm'
const NAME_ERROR = 'Use lowercase letters, numbers and dashes, 2 to 32 characters.'
const RANGE_ERROR = 'Enter a value from 1 to 8000.'
const QUALITY_ERROR = 'Enter a value from 1 to 100.'
function values(overrides: Partial<VariantFormValues>): VariantFormValues {
return { ...blankValues(), name: 'hero', width: '1200', height: '630', ...overrides }
}
describe('Field errors', () => {
it.each([
['name', { name: 'Hero banner' }, NAME_ERROR],
['name', { name: 'a' }, NAME_ERROR],
['name', { name: 'a'.repeat(33) }, NAME_ERROR],
['width', { width: '9000' }, RANGE_ERROR],
['width', { width: '0' }, RANGE_ERROR],
['height', { height: '8001' }, RANGE_ERROR],
['quality', { quality: '140' }, QUALITY_ERROR],
['quality', { quality: '0' }, QUALITY_ERROR],
] as [FieldName, Partial<VariantFormValues>, string][])(
'%s %j reads the message',
(field, overrides, message) => {
expect(validateField(field, values(overrides))).toBe(message)
},
)
it.each([
['name', { name: 'ab' }],
['name', { name: 'a-b-c' }],
['name', { name: 'a'.repeat(32) }],
['width', { width: '1' }],
['height', { height: '8000' }],
['quality', { quality: '1' }],
['quality', { quality: '100' }],
] as [FieldName, Partial<VariantFormValues>][])('%s %j shows no error', (field, overrides) => {
expect(validateField(field, values(overrides))).toBeUndefined()
})
it('does not validate the quality of a PNG', () => {
expect(validateField('quality', values({ format: 'png', quality: '999' }))).toBeUndefined()
})
it('treats an empty dimension as absent', () => {
expect(validateField('width', values({ width: '' }))).toBeUndefined()
expect(lacksDimension(values({ width: '', height: '' }))).toBe(true)
expect(lacksDimension(values({ width: '', height: '9' }))).toBe(false)
})
})
describe('Request body', () => {
it('builds the create body from the form', () => {
const body = toNewVariant(
values({ format: 'webp', quality: '82', generateOnUpload: true, enabled: true }),
)
expect(body).toEqual({
name: 'hero',
width: 1200,
height: 630,
fit: 'cover',
format: 'webp',
quality: 82,
generate_on_upload: true,
enabled: true,
})
})
it('sends an empty dimension as null', () => {
const body = toNewVariant(values({ name: 'card', width: '800', height: '' }))
expect(body.width).toBe(800)
expect(body.height).toBeNull()
})
it('omits the quality of a PNG', () => {
const definition = toDefinition(values({ format: 'png', quality: '70' }))
expect('quality' in definition).toBe(false)
})
})
describe('Form values', () => {
it('starts blank with the documented defaults', () => {
expect(blankValues()).toEqual({
name: '',
width: '',
height: '',
fit: 'cover',
format: 'original',
quality: '80',
generateOnUpload: false,
enabled: true,
})
})
it.each([
['thumb', '400', '300', '80'],
['hero', '1200', '630', '82'],
])('template %s pre-fills %s×%s q%s as cover WebP', (name, width, height, quality) => {
const template = VARIANT_TEMPLATES.find((entry) => entry.name === name)!
expect(valuesFromTemplate(template)).toMatchObject({
name,
width,
height,
quality,
fit: 'cover',
format: 'webp',
generateOnUpload: false,
enabled: true,
})
})
it('reads a stored PNG variant with a default hidden quality', () => {
const form = valuesFromVariant({
name: 'print',
width: null,
height: 2400,
fit: 'inside',
format: 'png',
quality: null,
generate_on_upload: false,
enabled: false,
generated_files: 74,
cache_size: 0,
})
expect([form.width, form.height, form.quality]).toEqual(['', '2400', '80'])
})
it('detects a change against the baseline', () => {
expect(hasChanged(blankValues(), blankValues())).toBe(false)
expect(hasChanged({ ...blankValues(), name: 'x' }, blankValues())).toBe(true)
})
it('clamps the slider position', () => {
expect([sliderValue('60'), sliderValue('140'), sliderValue('0'), sliderValue('')]).toEqual([
60, 100, 1, 1,
])
})
})
@@ -0,0 +1,170 @@
import { afterEach, describe, expect, it } from 'vitest'
import { initializeLocale, setLocale } from '@/i18n'
import type { ImageVariant } from '@/composables/useImageVariants'
import {
formatDimensions,
formatLabel,
formatVariantSize,
generatedLine,
qualityCell,
specLine,
variantsSummary,
} from '../variantFormat'
const MB = 1048576
function variant(overrides: Partial<ImageVariant>): ImageVariant {
return {
name: 'hero',
width: 1200,
height: 630,
fit: 'cover',
format: 'webp',
quality: 82,
generate_on_upload: false,
enabled: true,
generated_files: 244,
cache_size: 84 * MB,
...overrides,
}
}
function defaultFixture(): ImageVariant[] {
return [
variant({
name: 'card',
width: 800,
height: null,
fit: 'inside',
quality: 80,
generated_files: 318,
cache_size: 58 * MB,
}),
variant({ name: 'hero' }),
variant({
name: 'og',
format: 'jpeg',
quality: 80,
generated_files: 96,
cache_size: 9 * MB,
}),
variant({
name: 'print',
width: null,
height: 2400,
fit: 'inside',
format: 'png',
quality: null,
enabled: false,
generated_files: 74,
cache_size: 21 * MB,
}),
variant({
name: 'square',
width: 600,
height: 600,
format: 'jpeg',
quality: 85,
generated_files: 140,
cache_size: 19 * MB,
}),
variant({
name: 'thumb',
width: 400,
height: 300,
quality: 80,
generated_files: 412,
cache_size: 21 * MB,
}),
]
}
afterEach(() => {
localStorage.clear()
initializeLocale()
})
describe('Dimensions, quality and format cells', () => {
it.each([
[400, 300, 'webp', 80, '400×300', 'WebP', '80'],
[800, null, 'jpeg', 80, '800 × auto', 'JPEG', '80'],
[null, 2400, 'png', null, 'auto × 2400', 'PNG', '—'],
[600, 600, 'original', 82, '600×600', 'Original', '82'],
] as const)(
'width %s height %s format %s quality %s reads %s, %s, %s',
(width, height, format, quality, dimensions, label, cell) => {
const entry = variant({ width, height, format, quality })
expect(formatDimensions(entry.width, entry.height)).toBe(dimensions)
expect(formatLabel(entry.format)).toBe(label)
expect(qualityCell(entry)).toBe(cell)
},
)
})
describe('Spec line of a mobile card', () => {
it('joins dimensions, fit, format and quality', () => {
expect(specLine(variant({}))).toBe('1200×630 · cover · WebP · q82')
})
it('has no quality part for print', () => {
const print = defaultFixture()[3]!
expect(specLine(print)).toBe('auto × 2400 · inside · PNG')
})
it('reads the generated count and size', () => {
expect(generatedLine(variant({}))).toBe('244 generated · 84 MB')
})
})
describe('Footer sums are computed from the list', () => {
it('sums the Default fixture', () => {
expect(variantsSummary(defaultFixture())).toBe(
'6 variants · 1,284 generated files · 212 MB',
)
})
it('uses the singular for one variant with one file', () => {
const thumb = variant({ name: 'thumb', generated_files: 1, cache_size: 18 * 1024 })
expect(variantsSummary([thumb])).toBe('1 variant · 1 generated file · 18 KB')
})
it('recomputes after a deletion', () => {
const rest = defaultFixture().filter((entry) => entry.name !== 'hero')
expect(variantsSummary(rest)).toBe('5 variants · 1,040 generated files · 128 MB')
})
it('reads an empty cache as 0 KB', () => {
expect(variantsSummary([variant({ generated_files: 0, cache_size: 0 })])).toBe(
'1 variant · 0 generated files · 0 KB',
)
})
})
describe('Variant size formatter', () => {
it.each([
[0, '0 KB'],
[1, '1 KB'],
[18 * 1024, '18 KB'],
[1023 * 1024, '1023 KB'],
[MB, '1 MB'],
[Math.round(3.9 * MB), '3.9 MB'],
[84 * MB, '84 MB'],
[88080384, '84 MB'],
[2048 * MB, '2 GB'],
])('formats %s bytes as %s', (bytes, expected) => {
expect(formatVariantSize(bytes)).toBe(expected)
})
it('uses French units and decimal comma', () => {
setLocale('fr')
expect([formatVariantSize(18 * 1024), formatVariantSize(Math.round(3.9 * MB))]).toEqual([
'18 Ko',
'3,9 Mo',
])
})
})
+47
View File
@@ -0,0 +1,47 @@
import { t } from '@/i18n'
import { ApiError } from '@/composables/useApi'
export interface ToastCopy {
title: string
description: string
}
const BAD_REQUEST = 400
const FORBIDDEN = 403
const NOT_FOUND = 404
export function toastCopyFor(error: unknown): ToastCopy {
const status = error instanceof ApiError ? error.status : 0
if (status === BAD_REQUEST) {
return {
title: t('Check the request'),
description: t("A value in the request isn't valid. Reload the page and try again."),
}
}
if (status === FORBIDDEN) {
return {
title: t("You don't have access"),
description: t('Only admins can manage image variants.'),
}
}
if (status === NOT_FOUND) {
return {
title: t('Not found'),
description: t('This variant or image no longer exists. Refresh the page.'),
}
}
return {
title: t('Something went wrong'),
description: t(
"The server couldn't finish. Try again. If it keeps happening, check the server log.",
),
}
}
export function isStatus(error: unknown, status: number) {
return error instanceof ApiError && error.status === status
}
export function isHandledByApiClient(error: unknown) {
return error instanceof ApiError && error.status === 401
}
+64
View File
@@ -0,0 +1,64 @@
import { t } from '@/i18n'
import type { VariantFormat } from '@/composables/useImageVariants'
import { formatVariantSize } from '@/lib/variants/variantFormat'
import { outputSize, type OutputRequest, type PixelSize } from '@/lib/variants/variantOutput'
export type { PixelSize } from '@/lib/variants/variantOutput'
const BYTES_PER_MB = 1024 * 1024
const FULL_QUALITY = 100
const PNG_BYTES_PER_PIXEL = 0.42
const SIZE_EXPONENT = 0.885
const QUALITY_EXPONENT = 1.9
const LOSSY_BASE: Record<Exclude<VariantFormat, 'png'>, number> = {
webp: 0.9,
jpeg: 1.4,
original: 1.4,
}
export const SAMPLE_SOURCE = {
filename: 'north-wall.jpg',
width: 4032,
height: 3024,
bytes: Math.round(3.9 * BYTES_PER_MB),
}
export interface EstimateRequest extends OutputRequest {
format: VariantFormat
quality: number
}
export interface VariantEstimate {
size: PixelSize
bytes: number
}
function rawBytes(pixels: number, request: EstimateRequest) {
if (request.format === 'png') {
return pixels * PNG_BYTES_PER_PIXEL
}
const quality = Math.min(FULL_QUALITY, Math.max(1, request.quality)) / FULL_QUALITY
return (
LOSSY_BASE[request.format] *
Math.pow(pixels, SIZE_EXPONENT) *
Math.pow(quality, QUALITY_EXPONENT)
)
}
export function estimateVariant(request: EstimateRequest): VariantEstimate | null {
const size = outputSize(SAMPLE_SOURCE, request)
if (size === null) {
return null
}
const bytes = Math.min(SAMPLE_SOURCE.bytes, rawBytes(size.width * size.height, request))
return { size, bytes }
}
export function formatPixelSize(size: PixelSize) {
return `${size.width}×${size.height}`
}
export function estimateLabel(estimate: VariantEstimate) {
return t('{0} · about {1}', [formatPixelSize(estimate.size), formatVariantSize(estimate.bytes)])
}
+154
View File
@@ -0,0 +1,154 @@
import { t } from '@/i18n'
import type {
ImageVariant,
NewVariantRequest,
VariantDefinition,
VariantFit,
VariantFormat,
} from '@/composables/useImageVariants'
export const NAME_MAX_LENGTH = 32
const DIMENSION_MIN = 1
const DIMENSION_MAX = 8000
export const QUALITY_MIN = 1
export const QUALITY_MAX = 100
const DEFAULT_QUALITY = 80
const NAME_PATTERN = /^[a-z0-9-]{2,32}$/
const WHOLE_NUMBER = /^\d+$/
export type FieldName = 'name' | 'width' | 'height' | 'quality'
export interface VariantFormValues {
name: string
width: string
height: string
fit: VariantFit
format: VariantFormat
quality: string
generateOnUpload: boolean
enabled: boolean
}
export type FieldErrors = Partial<Record<FieldName, string>> & { dimension?: string }
export const FIELD_ORDER: FieldName[] = ['name', 'width', 'height', 'quality']
export const VARIANT_TEMPLATES = [
{ name: 'thumb', width: 400, height: 300, quality: 80 },
{ name: 'hero', width: 1200, height: 630, quality: 82 },
] as const
export function blankValues(): VariantFormValues {
return {
name: '',
width: '',
height: '',
fit: 'cover',
format: 'original',
quality: String(DEFAULT_QUALITY),
generateOnUpload: false,
enabled: true,
}
}
export function valuesFromTemplate(
template: (typeof VARIANT_TEMPLATES)[number],
): VariantFormValues {
return {
...blankValues(),
name: template.name,
width: String(template.width),
height: String(template.height),
format: 'webp',
quality: String(template.quality),
}
}
export function valuesFromVariant(variant: ImageVariant): VariantFormValues {
return {
name: variant.name,
width: variant.width === null ? '' : String(variant.width),
height: variant.height === null ? '' : String(variant.height),
fit: variant.fit,
format: variant.format,
quality: String(variant.quality ?? DEFAULT_QUALITY),
generateOnUpload: variant.generate_on_upload,
enabled: variant.enabled,
}
}
export function hasChanged(current: VariantFormValues, initial: VariantFormValues) {
return (Object.keys(initial) as (keyof VariantFormValues)[]).some(
(key) => current[key] !== initial[key],
)
}
export function usesQuality(format: VariantFormat) {
return format !== 'png'
}
function wholeNumberInRange(text: string, min: number, max: number) {
const trimmed = text.trim()
if (!WHOLE_NUMBER.test(trimmed)) {
return null
}
const value = Number(trimmed)
return value >= min && value <= max ? value : null
}
export function parseDimension(text: string) {
return wholeNumberInRange(text, DIMENSION_MIN, DIMENSION_MAX)
}
function parseQuality(text: string) {
return wholeNumberInRange(text, QUALITY_MIN, QUALITY_MAX)
}
export function validateField(field: FieldName, values: VariantFormValues): string | undefined {
if (field === 'name') {
return NAME_PATTERN.test(values.name)
? undefined
: t('Use lowercase letters, numbers and dashes, 2 to 32 characters.')
}
if (field === 'quality') {
return !usesQuality(values.format) || parseQuality(values.quality) !== null
? undefined
: t('Enter a value from 1 to 100.')
}
const text = values[field]
return text.trim() === '' || parseDimension(text) !== null
? undefined
: t('Enter a value from 1 to 8000.')
}
export function lacksDimension(values: VariantFormValues) {
return values.width.trim() === '' && values.height.trim() === ''
}
export function sliderValue(text: string) {
const parsed = Number.parseInt(text, 10)
if (Number.isNaN(parsed)) {
return QUALITY_MIN
}
return Math.min(QUALITY_MAX, Math.max(QUALITY_MIN, parsed))
}
export function toDefinition(values: VariantFormValues): VariantDefinition {
const definition: VariantDefinition = {
width: parseDimension(values.width),
height: parseDimension(values.height),
fit: values.fit,
format: values.format,
generate_on_upload: values.generateOnUpload,
enabled: values.enabled,
}
if (usesQuality(values.format)) {
definition.quality = parseQuality(values.quality) ?? DEFAULT_QUALITY
}
return definition
}
export function toNewVariant(values: VariantFormValues): NewVariantRequest {
return { name: values.name, ...toDefinition(values) }
}
+93
View File
@@ -0,0 +1,93 @@
import { formatNumber, t } from '@/i18n'
import type { ImageVariant, VariantFit, VariantFormat } from '@/composables/useImageVariants'
const BYTES_PER_KB = 1024
const BYTES_PER_MB = BYTES_PER_KB * 1024
const KB_PER_MB = 1024
const MB_PER_GB = 1024
const NO_QUALITY = '—'
const SEPARATOR = ' · '
function oneDecimal(value: number) {
return Math.round(value * 10) / 10
}
export function formatVariantSize(bytes: number) {
const kilobytes = Math.round(bytes / BYTES_PER_KB)
if (bytes === 0) {
return t('{0} KB', [0])
}
if (kilobytes < KB_PER_MB) {
return t('{0} KB', [Math.max(1, kilobytes)])
}
const megabytes = bytes / BYTES_PER_MB
if (megabytes < MB_PER_GB) {
return t('{0} MB', [oneDecimal(megabytes)])
}
return t('{0} GB', [oneDecimal(megabytes / MB_PER_GB)])
}
export function formatDimensions(width: number | null, height: number | null) {
if (width !== null && height !== null) {
return `${width}×${height}`
}
if (width !== null) {
return `${width} × ${t('auto')}`
}
return `${t('auto')} × ${height ?? ''}`
}
export function formatLabel(format: VariantFormat) {
const labels: Record<VariantFormat, string> = {
webp: 'WebP',
jpeg: 'JPEG',
png: 'PNG',
original: t('Original'),
}
return labels[format]
}
export function fitLabel(fit: VariantFit) {
const labels: Record<VariantFit, string> = {
cover: t('cover'),
contain: t('contain'),
inside: t('inside'),
}
return labels[fit]
}
function hasQuality(variant: Pick<ImageVariant, 'format' | 'quality'>) {
return variant.format !== 'png' && variant.quality !== null
}
export function qualityCell(variant: Pick<ImageVariant, 'format' | 'quality'>) {
return hasQuality(variant) ? String(variant.quality) : NO_QUALITY
}
export function specLine(variant: ImageVariant) {
const parts = [
formatDimensions(variant.width, variant.height),
fitLabel(variant.fit),
formatLabel(variant.format),
]
if (hasQuality(variant)) {
parts.push(t('q{0}', [variant.quality ?? 0]))
}
return parts.join(SEPARATOR)
}
export function generatedLine(variant: ImageVariant) {
return t('{0} generated · {1}', [
formatNumber(variant.generated_files),
formatVariantSize(variant.cache_size),
])
}
export function variantsSummary(items: readonly ImageVariant[]) {
const files = items.reduce((total, item) => total + item.generated_files, 0)
const bytes = items.reduce((total, item) => total + item.cache_size, 0)
const variants = items.length === 1 ? t('1 variant') : t('{0} variants', [items.length])
const generated =
files === 1 ? t('1 generated file') : t('{0} generated files', [formatNumber(files)])
return [variants, generated, formatVariantSize(bytes)].join(SEPARATOR)
}
+54
View File
@@ -0,0 +1,54 @@
import type { VariantFit } from '@/composables/useImageVariants'
export interface PixelSize {
width: number
height: number
}
export interface OutputRequest {
width: number | null
height: number | null
fit: VariantFit
}
function scaled(size: PixelSize, factor: number): PixelSize {
return { width: Math.round(size.width * factor), height: Math.round(size.height * factor) }
}
function singleDimension(source: PixelSize, width: number | null, height: number | null) {
const factor = width !== null ? width / source.width : (height ?? 0) / source.height
return scaled(source, Math.min(1, factor))
}
function cropToBox(source: PixelSize, box: PixelSize): PixelSize {
const boxRatio = box.width / box.height
if (source.width / source.height > boxRatio) {
return { width: source.height * boxRatio, height: source.height }
}
return { width: source.width, height: source.width / boxRatio }
}
function coverBox(source: PixelSize, box: PixelSize) {
if (box.width >= source.width && box.height >= source.height) {
return source
}
const crop = cropToBox(source, box)
return scaled(crop, Math.min(1, box.width / crop.width))
}
export function outputSize(source: PixelSize, request: OutputRequest): PixelSize | null {
const { width, height, fit } = request
if (width === null && height === null) {
return null
}
if (width === null || height === null) {
return singleDimension(source, width, height)
}
if (fit === 'contain') {
return { width, height }
}
if (fit === 'inside') {
return scaled(source, Math.min(1, width / source.width, height / source.height))
}
return coverBox(source, { width, height })
}
+5
View File
@@ -106,6 +106,11 @@ const router = createRouter({
component: () => import('@/views/SettingsView.vue'),
meta: { auth: 'verified', title: 'Settings' },
},
{
path: '/settings/media/variants',
component: () => import('@/views/VariantsPage.vue'),
meta: { auth: 'verified', title: 'Settings' },
},
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/NotFoundView.vue'),
+144
View File
@@ -0,0 +1,144 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { ApiError } from '@/composables/useApi'
import {
useImageVariants,
type ImageVariant,
type NewVariantRequest,
type PurgeResult,
type SavedVariant,
type VariantDefinition,
} from '@/composables/useImageVariants'
export type VariantsLoadState = 'loading' | 'ready' | 'error' | 'forbidden'
const FORBIDDEN_STATUS = 403
const UNAUTHORIZED_STATUS = 401
function byName(left: ImageVariant, right: ImageVariant) {
if (left.name === right.name) {
return 0
}
return left.name < right.name ? -1 : 1
}
export const useImageVariantsStore = defineStore('imageVariants', () => {
const api = useImageVariants()
const items = ref<ImageVariant[]>([])
const loadState = ref<VariantsLoadState>('loading')
const toggling = ref<Record<string, boolean>>({})
const purging = ref<string[]>([])
const isEmpty = computed(() => loadState.value === 'ready' && items.value.length === 0)
function find(name: string) {
return items.value.find((item) => item.name === name)
}
function enabledOf(item: ImageVariant) {
return toggling.value[item.name] ?? item.enabled
}
function isToggling(name: string) {
return name in toggling.value
}
function isPurging(name: string) {
return purging.value.includes(name)
}
function isBusy(name: string) {
return isToggling(name) || isPurging(name)
}
function replace(variant: ImageVariant) {
items.value = items.value.map((item) => (item.name === variant.name ? variant : item))
}
async function load() {
loadState.value = 'loading'
try {
items.value = await api.listVariants()
loadState.value = 'ready'
} catch (error) {
if (error instanceof ApiError && error.status === FORBIDDEN_STATUS) {
loadState.value = 'forbidden'
return
}
if (error instanceof ApiError && error.status === UNAUTHORIZED_STATUS) {
return
}
loadState.value = 'error'
}
}
async function reloadQuietly() {
try {
items.value = await api.listVariants()
} catch {
return
}
}
async function toggle(name: string, enabled: boolean) {
toggling.value = { ...toggling.value, [name]: enabled }
try {
replace(await api.setVariantEnabled(name, enabled))
} finally {
const next = { ...toggling.value }
delete next[name]
toggling.value = next
}
}
async function purge(name: string): Promise<PurgeResult> {
purging.value = [...purging.value, name]
try {
const result = await api.purgeVariant(name)
const current = find(name)
if (current) {
replace({ ...current, generated_files: 0, cache_size: 0 })
}
return result
} finally {
purging.value = purging.value.filter((entry) => entry !== name)
}
}
async function remove(name: string) {
await api.deleteVariant(name)
items.value = items.value.filter((item) => item.name !== name)
}
async function create(input: NewVariantRequest) {
const created = await api.createVariant(input)
items.value = [...items.value, created].sort(byName)
return created
}
async function save(name: string, definition: VariantDefinition): Promise<SavedVariant> {
const saved = await api.replaceVariant(name, definition)
const variant: SavedVariant = { ...saved }
delete variant.purged_files
replace(variant)
return saved
}
return {
items,
loadState,
isEmpty,
find,
enabledOf,
isToggling,
isPurging,
isBusy,
load,
reloadQuietly,
toggle,
purge,
remove,
create,
save,
}
})
+420
View File
@@ -0,0 +1,420 @@
<script setup lang="ts">
import { formatNumber, t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useRouter } from 'vue-router'
import { useLocalizedOverlay } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import RequestReferenceCard from '@/components/variants/RequestReferenceCard.vue'
import VariantCardList from '@/components/variants/VariantCardList.vue'
import VariantFormSlideover from '@/components/variants/VariantFormSlideover.vue'
import VariantsEmpty from '@/components/variants/VariantsEmpty.vue'
import VariantsForbidden from '@/components/variants/VariantsForbidden.vue'
import VariantsSkeleton from '@/components/variants/VariantsSkeleton.vue'
import VariantsTable from '@/components/variants/VariantsTable.vue'
import VariantsTabs from '@/components/variants/VariantsTabs.vue'
import type { ImageVariant } from '@/composables/useImageVariants'
import type { FormMode } from '@/composables/useVariantForm'
import { useVariantToasts } from '@/composables/useVariantToasts'
import { isHandledByApiClient, isStatus } from '@/lib/variants/variantErrors'
import {
VARIANT_TEMPLATES,
blankValues,
valuesFromTemplate,
valuesFromVariant,
type VariantFormValues,
} from '@/lib/variants/variantForm'
import { formatVariantSize } from '@/lib/variants/variantFormat'
import { useImageVariantsStore } from '@/stores/imageVariants'
import { useSessionStore } from '@/stores/session'
const DESKTOP_QUERY = '(min-width: 40rem)'
const SKELETON_DELAY_MS = 150
const NOT_FOUND = 404
interface FormTarget {
mode: FormMode
initial: VariantFormValues
variant?: ImageVariant
}
const router = useRouter()
const session = useSessionStore()
const store = useImageVariantsStore()
const overlay = useLocalizedOverlay()
const { announceFailure, announceSuccess } = useVariantToasts()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const showSkeleton = ref(false)
const formTarget = ref<FormTarget | null>(null)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let formOpener: HTMLElement | null = null
let requested = false
const sessionReady = computed(() => session.status === 'ready')
const canManage = computed(
() => session.session?.role === 'owner' || session.session?.role === 'admin',
)
const forbidden = computed(
() => (sessionReady.value && !canManage.value) || store.loadState === 'forbidden',
)
const ready = computed(() => store.loadState === 'ready')
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
async function loadVariants() {
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
const loading = store.load()
try {
await loading
} finally {
clearSkeletonTimer()
showSkeleton.value = false
}
}
watch(
[sessionReady, canManage],
([isReady, isAdmin]) => {
if (isReady && isAdmin && !requested) {
requested = true
loadVariants()
}
},
{ immediate: true },
)
onBeforeUnmount(clearSkeletonTimer)
function rowMenuButton(name: string) {
const label = t('Actions for {0}', [name])
return (
Array.from(document.querySelectorAll<HTMLElement>('button[aria-label]')).find(
(button) => button.getAttribute('aria-label') === label,
) ?? null
)
}
function rememberOpener(element?: HTMLElement | null) {
formOpener =
element ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null)
}
async function restoreFocus(element: HTMLElement | null) {
await nextTick()
if (element?.isConnected) {
element.focus()
}
}
function openCreate(initial: VariantFormValues = blankValues()) {
rememberOpener()
formTarget.value = { mode: 'create', initial }
}
function openTemplate(name: string) {
const template = VARIANT_TEMPLATES.find((entry) => entry.name === name)
if (template) {
openCreate(valuesFromTemplate(template))
}
}
function openEdit(name: string) {
const variant = store.find(name)
if (!variant) {
return
}
rememberOpener(rowMenuButton(name))
formTarget.value = { mode: 'edit', initial: valuesFromVariant(variant), variant }
}
function closeForm() {
formTarget.value = null
restoreFocus(formOpener)
}
function onSaved(result: { mode: FormMode; name: string; purgedFiles: number }) {
closeForm()
announceSuccess(() => {
if (result.mode === 'create') {
return t('Variant “{0}” added', [result.name])
}
if (result.purgedFiles === 0) {
return t('Variant “{0}” saved.', [result.name])
}
return result.purgedFiles === 1
? t('Variant “{0}” saved. 1 file purged.', [result.name])
: t('Variant “{0}” saved. {1} files purged.', [
result.name,
formatNumber(result.purgedFiles),
])
})
}
async function onToggle(name: string, value: boolean) {
try {
await store.toggle(name, value)
} catch (error) {
if (!isHandledByApiClient(error)) {
announceFailure(error)
}
}
}
function generatedFilesText(count: number) {
return count === 1 ? t('1 generated file') : t('{0} generated files', [formatNumber(count)])
}
async function onPurge(name: string) {
const variant = store.find(name)
if (!variant) {
return
}
const trigger = rowMenuButton(name)
const confirmed = await overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Purge cache for “{0}”?', [name])
},
get description() {
return t(
'This removes {0} ({1}). Files are generated again on the next request.',
[
generatedFilesText(variant.generated_files),
formatVariantSize(variant.cache_size),
],
)
},
get confirmLabel() {
return variant.generated_files === 1
? t('Purge 1 file')
: t('Purge {0} files', [formatNumber(variant.generated_files)])
},
get cancelLabel() {
return t('Cancel')
},
get pendingLabel() {
return t('Purging…')
},
tone: 'neutral',
action: async () => undefined,
},
})
.open()
restoreFocus(trigger)
if (!confirmed) {
return
}
try {
const result = await store.purge(name)
announceSuccess(() =>
result.purged_files === 1
? t('Purged 1 file from “{0}”', [name])
: t('Purged {0} files from “{1}”', [formatNumber(result.purged_files), name]),
)
} catch (error) {
if (!isHandledByApiClient(error)) {
announceFailure(error)
}
}
}
async function onDelete(name: string) {
const variant = store.find(name)
if (!variant) {
return
}
const trigger = rowMenuButton(name)
let failure: unknown = null
let removed = false
await overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Delete variant “{0}”?', [name])
},
get description() {
return t(
"This removes the variant and its {0} ({1}). Articles that use ?v={2} will start returning 400. This can't be undone.",
[
generatedFilesText(variant.generated_files),
formatVariantSize(variant.cache_size),
name,
],
)
},
get confirmLabel() {
return t('Delete variant')
},
get cancelLabel() {
return t('Cancel')
},
get pendingLabel() {
return t('Delete variant')
},
tone: 'danger',
action: async () => {
try {
await store.remove(name)
removed = true
} catch (error) {
failure = error
}
},
},
})
.open()
if (removed) {
restoreFocus(document.querySelector<HTMLElement>('[data-testid="add-variant"]'))
announceSuccess(() => t('Variant “{0}” deleted', [name]))
return
}
restoreFocus(trigger)
if (failure === null || isHandledByApiClient(failure)) {
return
}
if (isStatus(failure, NOT_FOUND)) {
await store.reloadQuietly()
}
announceFailure(failure)
}
</script>
<template>
<DashboardShell compact-padding :mobile-title="t('Variants')" :no-current-nav="forbidden">
<template #breadcrumb>
<UBreadcrumb
:items="[
{
get label() {
return t('Settings')
},
to: '/settings',
},
{
get label() {
return t('Media')
},
},
{
get label() {
return t('Variants')
},
},
]"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<VariantsForbidden v-if="forbidden" @home="router.push('/home')" />
<div v-else-if="sessionReady" class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div class="flex min-w-0 flex-1 flex-col gap-1">
<h1 class="text-xl/8 font-semibold text-highlighted sm:text-stat">
{{ t('Image variants') }}
</h1>
<p class="max-w-155 text-pretty text-muted">
{{
t(
'Named sizes and formats generated from your original images. Originals are never changed.',
)
}}
</p>
</div>
<UButton
data-testid="add-variant"
icon="i-lucide-plus"
class="h-11 shrink-0 justify-center px-3 text-sm sm:h-8"
@click="openCreate()"
>
{{ t('Add variant') }}
</UButton>
</div>
<VariantsTabs />
<VariantsSkeleton
v-if="store.loadState === 'loading' && showSkeleton"
:desktop="isDesktop"
/>
<UAlert
v-else-if="store.loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load variants')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 sm:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@click="loadVariants"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<VariantsEmpty v-else-if="store.isEmpty" @add="openCreate()" @template="openTemplate" />
<template v-else-if="ready">
<VariantsTable
v-if="isDesktop"
:items="store.items"
:is-enabled="store.enabledOf"
:is-locked="store.isBusy"
:is-purging="store.isPurging"
@toggle="onToggle"
@edit="openEdit"
@purge="onPurge"
@delete="onDelete"
/>
<VariantCardList
v-else
:items="store.items"
:is-enabled="store.enabledOf"
:is-locked="store.isBusy"
:is-purging="store.isPurging"
@toggle="onToggle"
@edit="openEdit"
@purge="onPurge"
@delete="onDelete"
/>
<RequestReferenceCard />
</template>
</div>
<VariantFormSlideover
v-if="formTarget"
:mode="formTarget.mode"
:initial="formTarget.initial"
:variant="formTarget.variant"
@close="closeForm"
@saved="onSaved"
/>
</DashboardShell>
</template>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,933 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import UButton from '@nuxt/ui/components/Button.vue'
import UIcon from '@nuxt/ui/components/Icon.vue'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { initializeLocale } from '@/i18n'
import {
LIST,
MB,
alertDialog,
buttonByLabel,
buttonIn,
calls,
click,
defaultFixture,
deferred,
findButton,
menuItem,
mountPage,
pageTitle,
openMenu,
pressEscape,
rowOf,
rows,
stubBrowser,
textOf,
toastCalls,
unmountPage,
variant,
variantsTable,
wrapper,
} from './variantsTestKit'
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() }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => toasts }
})
const FORBIDDEN_TEXT =
'Only admins can manage image variants. Ask an admin if you need one changed.'
const FOOTER = '6 variants · 1,284 generated files · 212 MB'
const SERVER_TEXT =
"The server couldn't finish. Try again. If it keeps happening, check the server log."
const NOT_FOUND_TEXT = 'This variant or image no longer exists. Refresh the page.'
const PATCH_HERO = 'PATCH /v1/image-variants/hero'
const PATCH_PRINT = 'PATCH /v1/image-variants/print'
const PURGE_HERO = 'POST /v1/image-variants/hero/purge'
const DELETE_HERO = 'DELETE /v1/image-variants/hero'
function summary() {
return document.body.querySelector('[data-testid="variants-summary"]')?.textContent?.trim()
}
function iconNames(root: ParentNode) {
return wrapper!
.findAllComponents(UIcon)
.filter((icon) => root.contains(icon.element))
.map((icon) => icon.props('name'))
}
function iconOf(button: HTMLElement) {
return wrapper!
.findAllComponents(UButton)
.find((component) => component.element === button)
?.props('icon')
}
function switchOf(name: string) {
return document.body.querySelector<HTMLButtonElement>(
`button[role="switch"][aria-label="Enable variant ${name}"]`,
)!
}
function statusOf(name: string) {
return textOf(rowOf(name)).match(/(Enabled|Disabled|Purging…)/)?.[1]
}
function expectForbidden() {
expect(document.body.textContent).toContain("You don't have access")
expect(document.body.textContent).toContain(FORBIDDEN_TEXT)
expect(findButton('Back to Home')!.disabled).toBe(false)
expect(pageTitle()).toBeNull()
expect(document.body.querySelector('[role="tablist"]')).toBeNull()
expect(variantsTable()).toBeUndefined()
expect(document.body.querySelector('[data-testid="variant-card"]')).toBeNull()
expect(document.body.querySelector('[data-testid="request-reference"]')).toBeNull()
expect(findButton('Add variant')).toBeUndefined()
expect(document.body.querySelector('nav[aria-label="Main"] [aria-current="page"]')).toBeNull()
}
beforeEach(() => {
toasts.add.mockClear()
localStorage.clear()
initializeLocale()
stubBrowser()
vi.mocked(useApi).mockReset()
})
afterEach(async () => {
vi.unstubAllGlobals()
await unmountPage()
localStorage.clear()
initializeLocale()
})
describe('Rule: Only admins and owners see the page', () => {
it('A session whose role is not owner or admin gets the Forbidden state: no role', async () => {
await mountPage({ role: null })
expectForbidden()
expect(calls(LIST)).toHaveLength(0)
expect(iconNames(document.body)).toContain('i-lucide-lock')
const back = findButton('Back to Home')!
expect(iconOf(back)).toBe('i-lucide-arrow-left')
})
it('A session whose role is not owner or admin gets the Forbidden state: owner answered 403', async () => {
await mountPage({ routes: { [LIST]: () => Promise.reject(new ApiError(403)) } })
expectForbidden()
expect(calls(LIST)).toHaveLength(1)
})
it('Back to Home leaves the page', async () => {
await mountPage({ role: null })
await click(findButton('Back to Home'))
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
})
it.each(['owner', 'admin'])('An owner or an admin sees the list: %s', async (role) => {
await mountPage({ role })
expect(calls(LIST)).toHaveLength(1)
expect(pageTitle()?.textContent).toBe('Image variants')
expect(
document.body.querySelector('nav[aria-label="Main"] [aria-current="page"]')
?.textContent,
).toContain('Variants')
})
})
describe('Rule: Header and tabs', () => {
it('The page header and the tabs', async () => {
await mountPage()
expect(pageTitle()?.textContent).toBe('Image variants')
expect(document.body.textContent).toContain(
'Named sizes and formats generated from your original images. Originals are never changed.',
)
const add = findButton('Add variant')!
expect(iconOf(add)).toBe('i-lucide-plus')
const tabs = Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]'))
expect(tabs.map((tab) => tab.textContent?.trim())).toEqual(['Variants', 'Cache'])
expect(tabs[0]!.getAttribute('aria-selected')).toBe('true')
expect(tabs[0]!.className).toContain('border-primary')
expect(tabs[0]!.className).toContain('font-medium')
expect(iconOf(tabs[0]!)).toBe('i-lucide-layers')
expect(iconOf(tabs[1]!)).toBe('i-lucide-database')
expect(tabs[1]!.getAttribute('aria-disabled')).toBe('true')
expect(tabs[1]!.className).toContain('text-toned')
})
it('Activating Cache changes nothing', async () => {
await mountPage()
const before = router.currentRoute.value.fullPath
const cache = document.body.querySelectorAll<HTMLElement>('[role="tab"]')[1]!
await click(cache)
expect(router.currentRoute.value.fullPath).toBe(before)
expect(cache.getAttribute('aria-selected')).toBe('false')
expect(calls(LIST)).toHaveLength(1)
})
it('The desktop breadcrumb reads Settings, Media, Variants', async () => {
await mountPage()
const items = Array.from(
document.body.querySelectorAll('nav[aria-label="breadcrumb"] li'),
).map((item) => item.textContent?.trim())
expect(items.filter(Boolean)).toEqual(['Settings', 'Media', 'Variants'])
})
it('The mobile top bar title is Variants', async () => {
await mountPage({ desktop: false })
expect(
document.body.querySelector('header h1, [data-slot="title"]')?.textContent,
).toContain('Variants')
})
it('The Add variant button sits below the description on mobile', async () => {
await mountPage({ desktop: false })
const add = findButton('Add variant')!
expect(add.className).toContain('h-11')
expect(add.parentElement!.className).toContain('flex-col')
})
})
describe('Rule: Loaded, table on desktop and cards on mobile', () => {
it('Desktop table', async () => {
await mountPage()
const headers = Array.from(variantsTable()!.querySelectorAll('thead th')).map((cell) =>
cell.textContent?.trim(),
)
expect(headers).toEqual([
'Name',
'Dimensions',
'Fit',
'Format',
'Quality',
'Generated',
'Cache size',
'Status',
'',
])
expect(rows().map(textOf)).toEqual([
'card 800 × auto inside WebP 80 318 58 MB Enabled',
'hero 1200×630 cover WebP 82 244 84 MB Enabled',
'og 1200×630 cover JPEG 80 96 9 MB Enabled',
'print auto × 2400 inside PNG — 74 21 MB Disabled',
'square 600×600 cover JPEG 85 140 19 MB Enabled',
'thumb 400×300 cover WebP 80 412 21 MB Enabled',
])
expect(summary()).toBe(FOOTER)
expect(document.body.querySelector('[data-testid="request-reference"]')).not.toBeNull()
})
it('The name and dimensions cells are IBM Plex Mono and the disabled row is muted', async () => {
await mountPage()
const cells = rowOf('hero').querySelectorAll('td')
expect(cells[0]!.querySelector('span')!.className).toContain('font-mono')
expect(cells[0]!.querySelector('span')!.className).toContain('font-medium')
expect(cells[1]!.querySelector('span')!.className).toContain('font-mono')
expect(rowOf('print').className).toContain('text-muted')
expect(rowOf('hero').className).toContain('text-default')
})
it('Mobile cards', async () => {
await mountPage({ desktop: false })
const cards = rows()
expect(cards).toHaveLength(6)
expect(textOf(rowOf('hero'))).toBe(
'hero Enabled 1200×630 · cover · WebP · q82 244 generated · 84 MB',
)
expect(textOf(rowOf('print'))).toContain('auto × 2400 · inside · PNG ')
expect(textOf(rowOf('print'))).not.toContain('q')
expect(summary()).toBe(FOOTER)
const footer = document.body.querySelector('[data-testid="variants-summary"]')!
const reference = document.body.querySelector('[data-testid="request-reference"]')!
expect(cards[5]!.compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
)
expect(footer.compareDocumentPosition(reference) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
)
})
it('Singular footer', async () => {
await mountPage({
routes: {
[LIST]: () =>
Promise.resolve({
items: [
variant({ name: 'thumb', generated_files: 1, cache_size: 18 * 1024 }),
],
}),
},
})
expect(summary()).toBe('1 variant · 1 generated file · 18 KB')
})
it('Footer sums are computed from the list', async () => {
await mountPage()
expect(summary()).toBe(FOOTER)
})
})
describe('Rule: Loading', () => {
it('Skeletons appear only after 150 ms', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountPage({ routes: { [LIST]: () => new Promise(() => undefined) } })
vi.advanceTimersByTime(149)
await flushPromises()
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(pageTitle()?.textContent).toBe('Image variants')
expect(document.body.querySelector('[role="tablist"]')).not.toBeNull()
vi.advanceTimersByTime(1)
await flushPromises()
const region = document.body.querySelector('section[aria-busy="true"]')!
expect(region.getAttribute('aria-label')).toBe('Loading variants')
expect(region.querySelectorAll('.h-12')).toHaveLength(5)
expect(region.querySelectorAll('.h-9')).toHaveLength(1)
expect(region.querySelectorAll('.h-12 [class*="animate-pulse"]')).toHaveLength(30)
expect(textOf(region)).toBe('')
})
it('Skeleton cards on mobile hold three bars', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountPage({ desktop: false, routes: { [LIST]: () => new Promise(() => undefined) } })
vi.advanceTimersByTime(150)
await flushPromises()
const cards = document.body.querySelectorAll('section[aria-busy="true"] > div > div')
expect(cards).toHaveLength(4)
expect(
Array.from(cards[0]!.children).map(
(bar) => (bar as HTMLElement).className.match(/w-\[\d+%\]/)?.[0],
),
).toEqual(['w-[40%]', 'w-[70%]', 'w-[55%]'])
})
it('A fast answer never flashes skeletons', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const pending = deferred<unknown>()
await mountPage({ routes: { [LIST]: () => pending.promise } })
vi.advanceTimersByTime(100)
pending.resolve({ items: defaultFixture() })
await flushPromises()
vi.advanceTimersByTime(500)
await flushPromises()
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(rows()).toHaveLength(6)
})
})
describe('Rule: Empty', () => {
const emptyRoutes = { [LIST]: () => Promise.resolve({ items: [] }) }
it('No variants', async () => {
await mountPage({ routes: emptyRoutes })
expect(pageTitle()?.textContent).toBe('Image variants')
expect(document.body.querySelector('[role="tablist"]')).not.toBeNull()
expect(document.body.textContent).toContain('No variants yet')
expect(document.body.textContent).toContain(
'A variant is a named size that an image can be requested in, like ?v=thumb. Start with one of these.',
)
expect(iconNames(document.body)).toContain('i-lucide-layers')
const templates = Array.from(document.body.querySelectorAll('button')).filter((button) =>
button.textContent?.includes('Use template'),
)
expect(templates.map(textOf)).toEqual([
'thumb 400×300 · cover · WebP Use template',
'hero 1200×630 · cover · WebP Use template',
])
expect(templates.map((button) => button.className.includes('min-h-16'))).toEqual([
true,
true,
])
const adds = Array.from(document.body.querySelectorAll('button')).filter(
(button) => button.textContent?.trim() === 'Add variant',
)
expect(adds).toHaveLength(2)
expect(variantsTable()).toBeUndefined()
expect(document.body.querySelector('[data-testid="variants-summary"]')).toBeNull()
expect(document.body.querySelector('[data-testid="request-reference"]')).toBeNull()
})
it.each([
['thumb', '400', '300', '80'],
['hero', '1200', '630', '82'],
])('A template opens the form pre-filled: %s', async (name, width, height, quality) => {
await mountPage({ routes: emptyRoutes })
const template = Array.from(document.body.querySelectorAll('button')).find((button) =>
button.textContent?.startsWith(name),
)!
await click(template)
expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain('Add variant')
const form = document.body.querySelector('[role="dialog"]')!
expect((form.querySelector('[data-field="name"] input') as HTMLInputElement).value).toBe(
name,
)
expect((form.querySelector('[data-field="width"] input') as HTMLInputElement).value).toBe(
width,
)
expect((form.querySelector('[data-field="height"] input') as HTMLInputElement).value).toBe(
height,
)
expect((form.querySelector('[data-field="quality"] input') as HTMLInputElement).value).toBe(
quality,
)
expect(buttonIn(form, 'Add variant')!.disabled).toBe(false)
})
})
describe('Rule: Error loading', () => {
it.each([
['status 500', () => Promise.reject(new ApiError(500, undefined, 'boom'))],
['a network failure', () => Promise.reject(new NetworkError())],
])('A failed list request: %s', async (_name, failure) => {
await mountPage({ routes: { [LIST]: failure } })
expect(pageTitle()?.textContent).toBe('Image variants')
expect(document.body.querySelector('[role="tablist"]')).not.toBeNull()
const alert = document.body.querySelector<HTMLElement>('[role="alert"]')!
expect(textOf(alert)).toContain("Couldn't load variants")
expect(textOf(alert)).toContain('Check your connection and try again.')
expect(textOf(alert)).not.toContain('boom')
const retry = buttonIn(alert, 'Try again')!
expect(iconOf(retry)).toBe('i-lucide-rotate-cw')
expect(alert.querySelector('[data-slot="close"]')).toBeNull()
expect(variantsTable()).toBeUndefined()
expect(document.body.querySelector('[data-testid="variant-card"]')).toBeNull()
expect(document.body.querySelector('[data-testid="variants-summary"]')).toBeNull()
expect(document.body.querySelector('[data-testid="request-reference"]')).toBeNull()
})
it('Try again reloads', async () => {
let attempt = 0
await mountPage({
routes: {
[LIST]: () =>
++attempt === 1
? Promise.reject(new ApiError(500))
: Promise.resolve({ items: defaultFixture() }),
},
})
await click(findButton('Try again'))
expect(calls(LIST)).toHaveLength(2)
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(rows()).toHaveLength(6)
})
})
describe('Rule: Status switch', () => {
it('Each row has a labelled switch paired with an icon and a word', async () => {
await mountPage()
expect(document.body.querySelectorAll('button[role="switch"]')).toHaveLength(6)
expect(switchOf('hero').getAttribute('aria-checked')).toBe('true')
expect(switchOf('print').getAttribute('aria-checked')).toBe('false')
expect(iconNames(rowOf('hero'))).toContain('i-lucide-circle-check')
expect(iconNames(rowOf('print'))).toContain('i-lucide-circle-off')
expect(switchOf('hero').className).toContain('w-8')
expect(switchOf('hero').className).toContain('h-4.5')
})
it.each([
['hero', 'enabled', PATCH_HERO, false, 'Disabled'],
['print', 'disabled', PATCH_PRINT, true, 'Enabled'],
])(
'Toggling sends PATCH and updates the row: %s is %s',
async (name, _before, route, after, label) => {
const pending = deferred<unknown>()
const original = defaultFixture().find((item) => item.name === name)!
await mountPage({ routes: { [route]: () => pending.promise } })
await click(switchOf(name))
expect(JSON.parse(String((calls(route)[0]![1] as RequestInit).body))).toEqual({
enabled: after,
})
expect(switchOf(name).disabled).toBe(true)
pending.resolve({ ...original, enabled: after })
await flushPromises()
expect(statusOf(name)).toBe(label)
expect(switchOf(name).disabled).toBe(false)
expect(textOf(rowOf(name))).toContain(`${original.generated_files}`)
expect(calls(`POST /v1/image-variants/${name}/purge`)).toHaveLength(0)
expect(toasts.add).not.toHaveBeenCalled()
},
)
it('A toggle shows the response generated_files and cache_size', async () => {
await mountPage({
routes: {
[PATCH_HERO]: () =>
Promise.resolve(
variant({ enabled: false, generated_files: 5, cache_size: 2 * MB }),
),
},
})
await click(switchOf('hero'))
expect(textOf(rowOf('hero'))).toBe('hero 1200×630 cover WebP 82 5 2 MB Disabled')
})
it.each([
[403, "You don't have access", 'Only admins can manage image variants.'],
[404, 'Not found', NOT_FOUND_TEXT],
[500, 'Something went wrong', SERVER_TEXT],
])('A failed toggle reverts and reports: %s', async (status, title, text) => {
await mountPage({ routes: { [PATCH_HERO]: () => Promise.reject(new ApiError(status)) } })
await click(switchOf('hero'))
expect(statusOf('hero')).toBe('Enabled')
expect(switchOf('hero').getAttribute('aria-checked')).toBe('true')
expect(switchOf('hero').disabled).toBe(false)
expect(toastCalls(toasts)).toEqual([{ title, description: text, color: 'error' }])
})
})
describe('Rule: Row menu', () => {
it('The ellipsis opens a three-item menu', async () => {
await mountPage()
const button = buttonByLabel('Actions for hero')!
expect(button.getAttribute('aria-label')).toBe('Actions for hero')
await click(button)
const menu = document.body.querySelector<HTMLElement>('[role="menu"]')!
expect(menu.className).toContain('w-44')
const items = Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]'))
expect(items.map((item) => item.textContent?.trim())).toEqual([
'Edit',
'Purge cache',
'Delete',
])
expect(items.every((item) => item.className.includes('h-8'))).toBe(true)
expect(items.every((item) => item.className.includes('max-sm:h-11'))).toBe(true)
expect(items[2]!.className).toContain('text-error')
expect(button.className).toContain('data-[state=open]:outline-primary')
expect(button.getAttribute('data-state')).toBe('open')
})
it('Menu keyboard behaviour: Escape closes the menu with focus back on the ellipsis button', async () => {
await mountPage()
const button = buttonByLabel('Actions for hero')!
button.focus()
await click(button)
await pressEscape()
await vi.waitFor(() => expect(document.body.querySelector('[role="menu"]')).toBeNull())
await vi.waitFor(() => expect(document.activeElement).toBe(button))
})
it('Menu items: Edit opens the slide-over in edit mode', async () => {
await mountPage()
await openMenu('hero')
await click(menuItem('Edit'))
expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain(
'Edit variant: hero',
)
})
it('Menu items: Purge cache opens the purge dialog', async () => {
await mountPage()
await openMenu('hero')
await click(menuItem('Purge cache'))
expect(alertDialog()?.textContent).toContain('Purge cache for “hero”?')
})
it('Menu items: Delete opens the delete dialog', async () => {
await mountPage()
await openMenu('hero')
await click(menuItem('Delete'))
expect(alertDialog()?.textContent).toContain('Delete variant “hero”?')
})
})
describe('Rule: Purge cache', () => {
async function openPurge() {
await openMenu('hero')
await click(menuItem('Purge cache'))
}
it('The purge dialog', async () => {
await mountPage()
await openPurge()
const dialog = alertDialog()!
expect(textOf(dialog)).toContain('Purge cache for “hero”?')
expect(textOf(dialog)).toContain(
'This removes 244 generated files (84 MB). Files are generated again on the next request.',
)
const buttons = Array.from(dialog.querySelectorAll('button')).map((button) =>
button.textContent?.trim(),
)
expect(buttons).toEqual(['Cancel', 'Purge 244 files'])
expect(dialog.contains(document.activeElement)).toBe(true)
})
it('Cancel closes without a request', async () => {
await mountPage()
await openPurge()
await click(buttonIn(alertDialog()!, 'Cancel'))
expect(alertDialog()).toBeNull()
expect(calls(PURGE_HERO)).toHaveLength(0)
expect(textOf(rowOf('hero'))).toContain('244 84 MB')
expect(document.activeElement).toBe(buttonByLabel('Actions for hero'))
})
it('Escape closes the dialog without a request', async () => {
await mountPage()
await openPurge()
await pressEscape()
expect(alertDialog()).toBeNull()
expect(calls(PURGE_HERO)).toHaveLength(0)
})
it('Confirming purges while the row is dimmed', async () => {
const pending = deferred<unknown>()
await mountPage({ routes: { [PURGE_HERO]: () => pending.promise } })
await openPurge()
await click(buttonIn(alertDialog()!, 'Purge 244 files'))
expect(calls(PURGE_HERO)).toHaveLength(1)
expect(alertDialog()).toBeNull()
expect(rowOf('hero').className).toContain('bg-elevated')
expect(switchOf('hero').disabled).toBe(true)
expect(buttonByLabel('Actions for hero')!.disabled).toBe(true)
expect(statusOf('hero')).toBe('Purging…')
expect(iconNames(rowOf('hero'))).toContain('i-lucide-loader-circle')
expect(rowOf('hero').querySelector('.text-info')).not.toBeNull()
expect(switchOf('card').disabled).toBe(false)
expect(buttonByLabel('Actions for card')!.disabled).toBe(false)
pending.resolve({ purged_files: 244, purged_bytes: 88080384 })
await flushPromises()
})
it('Purge succeeds', async () => {
await mountPage({
routes: {
[PURGE_HERO]: () => Promise.resolve({ purged_files: 244, purged_bytes: 88080384 }),
},
})
await openPurge()
await click(buttonIn(alertDialog()!, 'Purge 244 files'))
expect(textOf(rowOf('hero'))).toBe('hero 1200×630 cover WebP 82 0 0 KB Enabled')
expect(switchOf('hero').disabled).toBe(false)
expect(buttonByLabel('Actions for hero')!.disabled).toBe(false)
expect(summary()).toBe('6 variants · 1,040 generated files · 128 MB')
expect(calls(LIST)).toHaveLength(1)
expect(toastCalls(toasts)).toEqual([
{ title: 'Purged 244 files from “hero”', description: undefined, color: 'success' },
])
})
it.each([
[404, 'Not found', NOT_FOUND_TEXT],
[500, 'Something went wrong', SERVER_TEXT],
])('Purge fails: %s', async (status, title, text) => {
await mountPage({ routes: { [PURGE_HERO]: () => Promise.reject(new ApiError(status)) } })
await openPurge()
await click(buttonIn(alertDialog()!, 'Purge 244 files'))
expect(textOf(rowOf('hero'))).toBe('hero 1200×630 cover WebP 82 244 84 MB Enabled')
expect(switchOf('hero').disabled).toBe(false)
expect(buttonByLabel('Actions for hero')!.disabled).toBe(false)
expect(toastCalls(toasts)).toEqual([{ title, description: text, color: 'error' }])
})
})
describe('Rule: Delete variant', () => {
async function openDelete(name = 'hero') {
await openMenu(name)
await click(menuItem('Delete'))
}
it('The delete dialog', async () => {
await mountPage()
await openDelete()
const dialog = alertDialog()!
expect(textOf(dialog)).toContain('Delete variant “hero”?')
expect(textOf(dialog)).toContain(
"This removes the variant and its 244 generated files (84 MB). Articles that use ?v=hero will start returning 400. This can't be undone.",
)
const buttons = Array.from(dialog.querySelectorAll('button'))
expect(buttons.map((button) => button.textContent?.trim())).toEqual([
'Cancel',
'Delete variant',
])
expect(buttons[1]!.className).toContain('bg-error')
})
it('Confirming shows a spinner and blocks dismissal', async () => {
const pending = deferred<unknown>()
await mountPage({ routes: { [DELETE_HERO]: () => pending.promise } })
await openDelete()
await click(buttonIn(alertDialog()!, 'Delete variant'))
expect(calls(DELETE_HERO)).toHaveLength(1)
const dialog = alertDialog()!
expect(buttonIn(dialog, 'Cancel')!.disabled).toBe(true)
const confirm = buttonIn(dialog, 'Delete variant')!
expect(iconNames(confirm)).toContain('i-lucide-loader-circle')
await pressEscape()
expect(alertDialog()).not.toBeNull()
pending.resolve(undefined)
await flushPromises()
})
it('Delete succeeds', async () => {
await mountPage({ routes: { [DELETE_HERO]: () => Promise.resolve(undefined) } })
await openDelete()
await click(buttonIn(alertDialog()!, 'Delete variant'))
expect(alertDialog()).toBeNull()
expect(rows().map((row) => textOf(row).split(' ')[0])).toEqual([
'card',
'og',
'print',
'square',
'thumb',
])
expect(summary()).toBe('5 variants · 1,040 generated files · 128 MB')
expect(toastCalls(toasts)).toEqual([
{ title: 'Variant “hero” deleted', description: undefined, color: 'success' },
])
})
it('Deleting the last variant shows the empty state', async () => {
await mountPage({
routes: {
[LIST]: () => Promise.resolve({ items: [variant({ name: 'thumb' })] }),
'DELETE /v1/image-variants/thumb': () => Promise.resolve(undefined),
},
})
await openDelete('thumb')
await click(buttonIn(alertDialog()!, 'Delete variant'))
expect(document.body.textContent).toContain('No variants yet')
expect(variantsTable()).toBeUndefined()
})
it('Delete fails: 404 reloads the list and reports', async () => {
let listCalls = 0
await mountPage({
routes: {
[LIST]: () => {
listCalls++
return Promise.resolve({
items:
listCalls === 1
? defaultFixture()
: defaultFixture().filter((v) => v.name !== 'hero'),
})
},
[DELETE_HERO]: () => Promise.reject(new ApiError(404)),
},
})
await openDelete()
await click(buttonIn(alertDialog()!, 'Delete variant'))
expect(alertDialog()).toBeNull()
expect(calls(LIST)).toHaveLength(2)
expect(toastCalls(toasts)).toEqual([
{ title: 'Not found', description: NOT_FOUND_TEXT, color: 'error' },
])
})
it('Delete fails: 500 keeps the row and reports', async () => {
await mountPage({ routes: { [DELETE_HERO]: () => Promise.reject(new ApiError(500)) } })
await openDelete()
await click(buttonIn(alertDialog()!, 'Delete variant'))
expect(alertDialog()).toBeNull()
expect(rows()).toHaveLength(6)
expect(calls(LIST)).toHaveLength(1)
expect(toastCalls(toasts)).toEqual([
{ title: 'Something went wrong', description: SERVER_TEXT, color: 'error' },
])
})
})
describe('Rule: Request reference card', () => {
it('The card under the table', async () => {
await mountPage()
const card = document.body.querySelector<HTMLElement>('[data-testid="request-reference"]')!
expect(card.querySelector('h2')?.textContent).toBe('How requests are handled')
const cardRows = Array.from(card.querySelectorAll('tbody tr')).map(textOf)
expect(cardRows).toEqual([
'/static/uploadedImages/photo.png?v=thumb 200 Image',
'Unknown or disabled variant 400 Unknown variant',
'width, height, format or quality 400 Unsupported parameter',
'Missing source image 404 Not found',
])
expect(card.querySelector('tbody tr td span')!.className).toContain('font-mono')
const badges = Array.from(card.querySelectorAll('tbody tr td:nth-child(2) > *'))
expect(badges.map((badge) => badge.className.includes('bg-(--rc-success-soft)'))).toEqual([
true,
false,
false,
false,
])
expect(
badges.slice(1).every((badge) => badge.className.includes('bg-(--rc-error-soft)')),
).toBe(true)
expect(iconNames(card)).toEqual(
expect.arrayContaining(['i-lucide-circle-check', 'i-lucide-circle-x']),
)
expect(textOf(card.querySelector('div.flex.flex-col.gap-1\\.5')!)).toContain(
'Body of a 400 response',
)
expect(card.querySelector('pre')!.textContent).toBe(
'{\n "status": 400,\n "error": "unknown_variant",\n "message": "Unknown variant",\n "detail": "banner"\n}',
)
expect(card.querySelector('p')!.textContent).toBe(
'unsupported_parameter replaces unknown_variant when a raw parameter is sent. There is no arbitrary-size mode.',
)
})
it('The card has one column on mobile', async () => {
await mountPage({ desktop: false })
const grid = document.body.querySelector<HTMLElement>(
'[data-testid="request-reference"] .grid',
)!
expect(grid.className).toContain('grid-cols-1')
expect(grid.className).toContain('sm:grid-cols-')
})
it.each([
['Loading', { [LIST]: () => new Promise(() => undefined) }],
['Empty', { [LIST]: () => Promise.resolve({ items: [] }) }],
['Error', { [LIST]: () => Promise.reject(new ApiError(500)) }],
])('The card is not drawn when the state is %s', async (_state, routes) => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountPage({ routes })
vi.advanceTimersByTime(200)
await flushPromises()
expect(document.body.querySelector('[data-testid="request-reference"]')).toBeNull()
})
})
describe('Rule: Accessibility and hygiene', () => {
it('Status is never colour alone', async () => {
await mountPage()
for (const item of defaultFixture()) {
const row = rowOf(item.name)
expect(statusOf(item.name)).toMatch(/^(Enabled|Disabled|Purging…)$/)
expect(iconNames(row).some((name) => /circle-(check|off)/.test(name))).toBe(true)
}
})
it('Touch targets on mobile', async () => {
await mountPage({ desktop: false })
expect(buttonByLabel('Actions for hero')!.className).toContain('max-sm:size-11')
expect(findButton('Add variant')!.className).toContain('h-11')
expect(switchOf('hero').className).toContain('before:-inset-3.5')
expect(document.body.querySelectorAll('[role="tab"]')[0]!.className).toContain(
'max-sm:h-11',
)
})
it('Vocabulary', async () => {
await mountPage()
await openMenu('hero')
const text = document.body.textContent!.toLowerCase()
expect(text).not.toContain('thumbnail preset')
expect(text).not.toContain('rendition')
expect(text).not.toMatch(/\bversion\b/)
})
})
describe('Rule: French strings', () => {
it('renders the list in French with no English left over', async () => {
const { setLocale } = await import('@/i18n')
setLocale('fr')
await mountPage()
expect(pageTitle()?.textContent).toBe('Variantes d’image')
expect(rows().map((row) => textOf(row).split(' ')[0])).toEqual([
'card',
'hero',
'og',
'print',
'square',
'thumb',
])
expect(summary()).toBe('6 variantes · 1 284 fichiers générés · 212 Mo')
expect(textOf(rowOf('print'))).toContain('Désactivée')
expect(document.body.textContent).toContain('Traitement des requêtes')
const text = document.body.textContent!
for (const english of [
'Image variants',
'generated files',
'How requests',
'Enabled',
'Disabled',
'Add variant',
'Cache size',
]) {
expect(text).not.toContain(english)
}
})
})
+350
View File
@@ -0,0 +1,350 @@
import { vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, h } from 'vue'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UApp from '@nuxt/ui/components/App.vue'
import router from '@/router'
import { useApi } from '@/composables/useApi'
import type { ImageVariant } from '@/composables/useImageVariants'
import { useSessionStore } from '@/stores/session'
import VariantsPage from '../VariantsPage.vue'
export const MB = 1048576
export const LIST = 'GET /v1/image-variants'
export const CREATE = 'POST /v1/image-variants'
export type Handler = (body?: Record<string, unknown>) => Promise<unknown>
export interface MountOptions {
desktop?: boolean
role?: string | null
routes?: Record<string, Handler>
}
export function variant(overrides: Partial<ImageVariant>): ImageVariant {
return {
name: 'hero',
width: 1200,
height: 630,
fit: 'cover',
format: 'webp',
quality: 82,
generate_on_upload: false,
enabled: true,
generated_files: 244,
cache_size: 84 * MB,
created_at: '2026-03-12T09:00:00Z',
updated_at: '2026-03-12T09:00:00Z',
...overrides,
}
}
export function defaultFixture(): ImageVariant[] {
return [
variant({
name: 'card',
width: 800,
height: null,
fit: 'inside',
quality: 80,
generated_files: 318,
cache_size: 58 * MB,
}),
variant({}),
variant({
name: 'og',
format: 'jpeg',
quality: 80,
generated_files: 96,
cache_size: 9 * MB,
}),
variant({
name: 'print',
width: null,
height: 2400,
fit: 'inside',
format: 'png',
quality: null,
enabled: false,
generated_files: 74,
cache_size: 21 * MB,
}),
variant({
name: 'square',
width: 600,
height: 600,
format: 'jpeg',
quality: 85,
generated_files: 140,
cache_size: 19 * MB,
}),
variant({
name: 'thumb',
width: 400,
height: 300,
quality: 80,
generated_files: 412,
cache_size: 21 * MB,
}),
]
}
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 function setViewport(desktop: boolean) {
window.matchMedia = ((query: string) => ({
matches: desktop,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia
}
export let requestMock: ReturnType<typeof vi.fn>
export let wrapper: VueWrapper | null = null
export async function mountPage(options: MountOptions = {}) {
const { desktop = true, role = 'owner', routes = {} } = options
setViewport(desktop)
setActivePinia(createPinia())
const session = useSessionStore()
const sessionRole = role ?? undefined
session.session = {
verified: true,
username: 'marta',
id: 1,
displayName: 'Marta Alves',
role: sessionRole as 'owner' | 'admin' | undefined,
}
session.status = 'ready'
requestMock = vi.fn((path: string, init?: RequestInit) => {
const key = `${init?.method ?? 'GET'} ${path}`
const handler =
routes[key] ??
(key === LIST ? () => Promise.resolve({ items: defaultFixture() }) : null)
if (!handler) {
throw new Error(`Unexpected request: ${key}`)
}
return handler(
init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : undefined,
)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi
.fn()
.mockResolvedValue({ verified: true, username: 'marta', id: 1, role: sessionRole }),
request: requestMock,
})
await router.push('/settings/media/variants')
const Host = defineComponent({ render: () => h(UApp, null, () => h(VariantsPage)) })
wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return wrapper
}
export async function unmountPage() {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
document.body.innerHTML = ''
await router.push('/login')
}
export function textOf(element: Element) {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
const parts: string[] = []
while (walker.nextNode()) {
const text = walker.currentNode.textContent?.trim()
if (text) {
parts.push(text)
}
}
return parts.join(' ')
}
export function rows() {
return Array.from(
document.body.querySelectorAll<HTMLElement>('tbody tr, [data-testid="variant-card"]'),
).filter((row) => !row.closest('[data-testid="request-reference"]'))
}
export function pageTitle() {
return document.body.querySelector('h1:not([data-slot="title"])')
}
export function variantsTable() {
return Array.from(document.body.querySelectorAll('table')).find(
(table) => !table.closest('[data-testid="request-reference"]'),
)
}
export function rowOf(name: string) {
return rows().find((row) => row.textContent?.includes(name))!
}
export function buttonIn(root: ParentNode, text: string) {
return Array.from(root.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === text,
)
}
export function findButton(text: string) {
return buttonIn(document.body, text)
}
export function buttonByLabel(label: string) {
return document.body.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
}
export async function click(element: HTMLElement | null | undefined) {
element?.click()
await flushPromises()
}
export function calls(key: string) {
return requestMock.mock.calls.filter(
([path, init]) => `${(init as RequestInit | undefined)?.method ?? 'GET'} ${path}` === key,
)
}
export function bodyOf(key: string, index = 0) {
return JSON.parse(String((calls(key)[index]![1] as RequestInit).body)) as Record<
string,
unknown
>
}
export function toastCalls(toasts: { add: ReturnType<typeof vi.fn> }) {
return toasts.add.mock.calls.map(([source]) => ({
title: (source as { title?: string }).title,
description: (source as { description?: string }).description,
color: (source as { color?: string }).color,
}))
}
export function dispatch(element: Element, type: string) {
element.dispatchEvent(new Event(type, { bubbles: true }))
}
export async function typeInto(input: HTMLInputElement | null | undefined, value: string) {
input!.value = value
dispatch(input!, 'input')
await flushPromises()
}
export async function blur(input: HTMLInputElement | null | undefined) {
dispatch(input!, 'blur')
await flushPromises()
}
export async function pressKey(element: Element, key: string, init: KeyboardEventInit = {}) {
element.dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }),
)
await flushPromises()
}
export async function pressEscape() {
const target = document.activeElement ?? document.body
target.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
)
await flushPromises()
}
export function dialogs() {
return Array.from(
document.body.querySelectorAll<HTMLElement>('[role="dialog"], [role="alertdialog"]'),
)
}
export function alertDialog() {
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')
}
export function slideover() {
return document.body.querySelector<HTMLElement>('[role="dialog"]')
}
export function field(name: string) {
return document.body.querySelector<HTMLInputElement>(
`[data-field="${name}"] input:not([aria-hidden="true"])`,
)
}
export function fieldMessage(name: string) {
return document.body
.querySelector(`[data-field="${name}"] [data-slot="error"]`)
?.textContent?.trim()
}
export function fieldHelp(name: string) {
return document.body
.querySelector(`[data-field="${name}"] [data-slot="help"]`)
?.textContent?.trim()
}
export async function openMenu(name: string) {
await click(buttonByLabel(`Actions for ${name}`))
}
export function menuItem(label: string) {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(
(item) => item.textContent?.trim() === label,
)
}
export async function openAddForm() {
const opener = document.body.querySelector<HTMLElement>('[data-testid="add-variant"]')!
opener.focus()
opener.click()
await flushPromises()
}
export async function openEditForm(name: string) {
await openMenu(name)
await click(menuItem('Edit'))
}
export function stubBrowser() {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
}
export async function clickBackdrop() {
const overlay = document.body.querySelector<HTMLElement>('[data-slot="overlay"]')!
await new Promise((resolve) => setTimeout(resolve, 20))
const down = new MouseEvent('pointerdown', { bubbles: true, button: 0 })
Object.defineProperty(down, 'pointerType', { value: 'mouse' })
overlay.dispatchEvent(down)
overlay.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
}
export function stubClipboard() {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
return writeText
}
+65
View File
@@ -186,7 +186,72 @@ function tableStates(markdown: string) {
.filter((cell) => cell !== 'State' && !/^-+$/.test(cell))
}
const variantsBoard = 'RenewCMS Screens Image variants.dc.html'
function numberedStates(prefix: string, names: string[]) {
return Object.fromEntries(
names.map((state, index) => [
state,
`${prefix}${String.fromCharCode(97 + index)} · ${state}`,
]),
)
}
function variantsScreen(): Screen {
const listNames = [
'Loaded',
'Loading',
'Empty',
'Error loading',
'Forbidden',
'Row menu',
'Row action in progress',
'Purge confirm',
'Delete confirm',
'Delete in progress',
'Request reference',
'Entry: sidebar',
]
const formNames = [
'Create: pristine',
'Create: dirty',
'Field errors',
'No dimension',
'Name already taken',
'Saving',
'Save error',
'Edit: pristine',
'Edit: dirty',
'Unsaved changes',
'PNG: no quality',
]
const sources: Source[] = [
{
board: variantsBoard,
group: 'Variants list',
states: listNames,
alias: numberedStates('1', listNames),
},
{
board: variantsBoard,
group: 'Add edit variant',
states: formNames,
alias: numberedStates('2', formNames),
},
]
return {
name: 'image-variants',
board: variantsBoard,
group: 'Variants list',
states: [...listNames, ...formNames],
sources,
}
}
async function readScreen(name: string): Promise<Screen> {
if (name === 'image-variants') {
return variantsScreen()
}
const markdown = await readFile(join(designRoot, 'screens', `${name}.md`), 'utf8')
const reference = /\*\*Visual reference:\*\* `source\/([^`]+)`, group "([^"]+)"/.exec(markdown)
if (!reference) {
+334
View File
@@ -3095,6 +3095,339 @@ const activityStates: Record<string, VisualState> = {
Details: activityClockSensitive,
}
const variantsPath = '/settings/media/variants'
const variantsMegabyte = 1048576
const variantsSession: ApiMock = {
status: 200,
body: {
id: 1,
is_verified: true,
username: 'marta',
email: 'marta@renew.example',
display_name: 'Marta Alves',
role: 'owner',
},
}
function boardVariant(
name: string,
width: number | null,
height: number | null,
fit: string,
format: string,
quality: number | null,
files: number,
megabytes: number,
enabled = true,
) {
return {
name,
width,
height,
fit,
format,
quality,
generate_on_upload: false,
enabled,
generated_files: files,
cache_size: megabytes * variantsMegabyte,
created_at: '2026-03-12T09:00:00Z',
updated_at: '2026-03-12T09:00:00Z',
}
}
const variantsBoardList = [
boardVariant('thumb', 400, 300, 'cover', 'webp', 80, 412, 21),
boardVariant('card', 800, null, 'inside', 'webp', 80, 318, 58),
boardVariant('hero', 1200, 630, 'cover', 'webp', 82, 244, 84),
boardVariant('square', 600, 600, 'cover', 'jpeg', 85, 140, 19),
boardVariant('og', 1200, 630, 'cover', 'jpeg', 80, 96, 9),
boardVariant('print', null, 2400, 'inside', 'png', null, 74, 21, false),
]
function variantsApi(
items: unknown[] = variantsBoardList,
overrides: Record<string, ApiMock> = {},
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': variantsSession,
'GET /v1/image-variants': { status: 200, body: { items } },
...overrides,
}
}
function variantsListState(
overrides: Record<string, ApiMock> = {},
steps?: VisualState['steps'],
items: unknown[] = variantsBoardList,
): VisualState {
return { path: variantsPath, api: variantsApi(items, overrides), steps }
}
async function openVariantMenu(page: Page, name: string) {
await page.getByRole('button', { name: `Actions for ${name}`, exact: true }).click()
await page.getByRole('menu').waitFor()
}
async function chooseVariantMenuItem(page: Page, name: string, item: string) {
await openVariantMenu(page, name)
await page.getByRole('menuitem', { name: item, exact: true }).click()
}
async function scrollVariantsPage(page: Page, offset: number) {
await page.evaluate((value) => {
const body = document
.querySelector<HTMLElement>('#dashboard-content')
?.closest<HTMLElement>('[data-slot="body"]')
if (body) body.scrollTop = value
}, offset)
await page.waitForTimeout(150)
}
async function scrollVariantsForm(page: Page, offset: number) {
await page.evaluate((value) => {
const body = document.querySelector<HTMLElement>('[role="dialog"] [data-slot="body"]')
if (body) body.scrollTop = value
}, offset)
await page.waitForTimeout(150)
}
function isMobileViewport(page: Page) {
return (page.viewportSize()?.width ?? 0) < 640
}
async function openVariantForm(page: Page) {
await page.getByRole('button', { name: 'Add variant', exact: true }).first().click()
await page.getByRole('dialog').waitFor()
}
function variantField(page: Page, name: string) {
return page.locator(`[data-field="${name}"] input:not([aria-hidden="true"])`)
}
async function pickVariantFormat(page: Page, label: string) {
await page.getByRole('dialog').getByRole('combobox').click()
await page.getByRole('option', { name: label, exact: true }).click()
}
interface VariantFormFill {
name: string
width: string
height: string
format: string
quality?: string
generate?: boolean
}
async function fillVariantForm(page: Page, values: VariantFormFill) {
await variantField(page, 'name').fill(values.name)
await variantField(page, 'width').fill(values.width)
await variantField(page, 'height').fill(values.height)
await pickVariantFormat(page, values.format)
if (values.quality !== undefined) {
await variantField(page, 'quality').fill(values.quality)
}
if (values.generate) {
await page.getByRole('switch', { name: 'Generate on upload', exact: true }).click()
}
}
const heroForm: VariantFormFill = {
name: 'hero',
width: '1200',
height: '630',
format: 'WebP',
quality: '82',
generate: true,
}
async function blurFocus(page: Page) {
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
await page.waitForTimeout(100)
}
async function pressVariantFormButton(page: Page, label: string) {
await page.getByRole('dialog').getByRole('button', { name: label, exact: true }).click()
}
async function openEditHero(page: Page) {
await chooseVariantMenuItem(page, 'hero', 'Edit')
await page.getByRole('dialog').waitFor()
}
const variantsStates: Record<string, VisualState> = {
Loaded: variantsListState(),
Loading: {
path: variantsPath,
api: variantsApi(variantsBoardList, {
'GET /v1/image-variants': { status: 200, hang: true },
}),
},
Empty: variantsListState({}, undefined, []),
'Error loading': variantsListState({ 'GET /v1/image-variants': { status: 500 } }),
Forbidden: {
path: variantsPath,
api: {
'GET /v1/auth/me': {
status: 200,
body: { ...(variantsSession.body as object), role: 'user' },
},
},
},
'Row menu': variantsListState({}, (page) => openVariantMenu(page, 'hero')),
'Row action in progress': variantsListState(
{ 'POST /v1/image-variants/hero/purge': { status: 200, hang: true } },
async (page) => {
await chooseVariantMenuItem(page, 'hero', 'Purge cache')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Purge 244 files' })
.click()
await page.mouse.move(0, 0)
},
),
'Purge confirm': variantsListState({}, async (page) => {
await chooseVariantMenuItem(page, 'hero', 'Purge cache')
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}),
'Delete confirm': variantsListState({}, async (page) => {
await chooseVariantMenuItem(page, 'hero', 'Delete')
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}),
'Delete in progress': variantsListState(
{ 'DELETE /v1/image-variants/hero': { status: 204, hang: true } },
async (page) => {
await chooseVariantMenuItem(page, 'hero', 'Delete')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete variant' })
.click()
await page.mouse.move(0, 0)
},
),
'Entry: sidebar': {
path: '/media',
origin: mediaOrigin,
api: mediaApi({ 'GET /v1/auth/me': variantsSession }),
steps: async (page) => {
if (isMobileViewport(page)) {
await page.locator('[data-slot="toggle"]').first().click()
}
await page
.locator(
'nav[aria-label="Main"]:visible a[href="/settings"] [data-slot="linkTrailing"]',
)
.click()
await page.mouse.move(0, 0)
await page.waitForTimeout(250)
},
},
'Request reference': {
...variantsListState({}, async (page) => {
if (isMobileViewport(page)) {
await scrollVariantsPage(page, 900)
}
}),
exception: {
maxPercent: 21,
reason: 'board draws /media/photo.png?v=thumb (2 lines) and the mandated /static/uploadedImages/photo.png?v=thumb wraps to 3; board scroll 900 is unreachable (page max ~861)',
approvedBy: 'user',
},
},
'Create: pristine': variantsListState({}, openVariantForm),
'Create: dirty': variantsListState({}, async (page) => {
await openVariantForm(page)
await fillVariantForm(page, heroForm)
await page.locator('input[type="range"]').focus()
if (isMobileViewport(page)) {
await scrollVariantsForm(page, 560)
}
}),
'Field errors': variantsListState({}, async (page) => {
await openVariantForm(page)
await fillVariantForm(page, {
name: 'Hero banner',
width: '9000',
height: '630',
format: 'WebP',
quality: '140',
})
await variantField(page, 'name').blur()
await variantField(page, 'width').blur()
await variantField(page, 'quality').blur()
await blurFocus(page)
}),
'No dimension': variantsListState({}, async (page) => {
await openVariantForm(page)
await variantField(page, 'name').fill('banner')
await pickVariantFormat(page, 'WebP')
await pressVariantFormButton(page, 'Add variant')
await blurFocus(page)
}),
'Name already taken': variantsListState(
{
'POST /v1/image-variants': {
status: 409,
body: 'A variant named “thumb” already exists. Choose another name.',
},
},
async (page) => {
await openVariantForm(page)
await fillVariantForm(page, {
name: 'thumb',
width: '400',
height: '300',
format: 'WebP',
quality: '80',
generate: true,
})
await pressVariantFormButton(page, 'Add variant')
await page.getByText('already exists').waitFor()
await blurFocus(page)
},
),
Saving: variantsListState(
{ 'POST /v1/image-variants': { status: 201, hang: true } },
async (page) => {
await openVariantForm(page)
await fillVariantForm(page, heroForm)
await pressVariantFormButton(page, 'Add variant')
await page.mouse.move(0, 0)
},
),
'Save error': variantsListState(
{ 'POST /v1/image-variants': { status: 500 } },
async (page) => {
await openVariantForm(page)
await fillVariantForm(page, heroForm)
await pressVariantFormButton(page, 'Add variant')
await page.getByText("Couldn't save the variant").waitFor()
await page.mouse.move(0, 0)
},
),
'Edit: pristine': variantsListState({}, openEditHero),
'Edit: dirty': variantsListState({}, async (page) => {
await openEditHero(page)
await variantField(page, 'quality').fill('70')
await blurFocus(page)
}),
'Unsaved changes': variantsListState({}, async (page) => {
await openEditHero(page)
await variantField(page, 'quality').fill('70')
await pressVariantFormButton(page, 'Cancel')
await page.getByRole('alertdialog').waitFor()
}),
'PNG: no quality': variantsListState({}, async (page) => {
await openVariantForm(page)
await fillVariantForm(page, { ...heroForm, quality: undefined })
await pickVariantFormat(page, 'PNG')
await blurFocus(page)
}),
}
export const visualStates: Record<string, Record<string, VisualState>> = {
activity: activityStates,
profile: {
@@ -4233,6 +4566,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
steps: submitAcceptInvite('lea', 'limewash2026'),
},
},
'image-variants': variantsStates,
media: { ...mediaStates, ...mediaPurgeStates },
'media-picker': pickerStates,
}