feat: image variants settings screens
This commit is contained in:
1 parent
f1d56ef50e
commit
f567866161
42 files changed
+7192
-11
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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', [
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)])
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user