From f5678661612c3a728f43225f5e413877c2d4c790 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Thu, 8 Oct 2026 13:57:41 +0200 Subject: [PATCH] feat: image variants settings screens --- docs/design/components.md | 2 +- docs/design/routes.md | 2 + docs/design/screens/image-variants.md | 3 + .../RenewCMS Screens Image variants.dc.html | 1 + docs/design/source/VariantsFrame.dc.html | 14 +- features/image_variants_screens.feature | 690 +++++++++ features/image_variants_screens.qa.md | 156 +++ web/src/components/DashboardShell.vue | 50 +- .../__tests__/DashboardShell.spec.ts | 42 +- web/src/components/variants/ImageCompare.vue | 115 ++ .../variants/RequestReferenceCard.vue | 113 ++ .../components/variants/VariantCardList.vue | 69 + .../components/variants/VariantFormFields.vue | 394 ++++++ .../variants/VariantFormSlideover.vue | 306 ++++ .../components/variants/VariantPreview.vue | 111 ++ .../components/variants/VariantRowMenu.vue | 64 + .../variants/VariantStatusSwitch.vue | 55 + web/src/components/variants/VariantsEmpty.vue | 61 + .../components/variants/VariantsForbidden.vue | 34 + .../components/variants/VariantsSkeleton.vue | 41 + web/src/components/variants/VariantsTable.vue | 175 +++ web/src/components/variants/VariantsTabs.vue | 39 + web/src/composables/useImageVariants.ts | 99 ++ web/src/composables/useVariantForm.ts | 93 ++ web/src/composables/useVariantToasts.ts | 31 + web/src/i18n/messages.ts | 124 ++ .../__tests__/variantEstimate.spec.ts | 93 ++ .../variants/__tests__/variantForm.spec.ts | 156 +++ .../variants/__tests__/variantFormat.spec.ts | 170 +++ web/src/lib/variants/variantErrors.ts | 47 + web/src/lib/variants/variantEstimate.ts | 64 + web/src/lib/variants/variantForm.ts | 154 ++ web/src/lib/variants/variantFormat.ts | 93 ++ web/src/lib/variants/variantOutput.ts | 54 + web/src/router/index.ts | 5 + web/src/stores/imageVariants.ts | 144 ++ web/src/views/VariantsPage.vue | 420 ++++++ web/src/views/__tests__/VariantsForm.spec.ts | 1237 +++++++++++++++++ web/src/views/__tests__/VariantsPage.spec.ts | 933 +++++++++++++ web/src/views/__tests__/variantsTestKit.ts | 350 +++++ web/visual/check.ts | 65 + web/visual/states.ts | 334 +++++ 42 files changed, 7192 insertions(+), 11 deletions(-) create mode 100644 features/image_variants_screens.feature create mode 100644 features/image_variants_screens.qa.md create mode 100644 web/src/components/variants/ImageCompare.vue create mode 100644 web/src/components/variants/RequestReferenceCard.vue create mode 100644 web/src/components/variants/VariantCardList.vue create mode 100644 web/src/components/variants/VariantFormFields.vue create mode 100644 web/src/components/variants/VariantFormSlideover.vue create mode 100644 web/src/components/variants/VariantPreview.vue create mode 100644 web/src/components/variants/VariantRowMenu.vue create mode 100644 web/src/components/variants/VariantStatusSwitch.vue create mode 100644 web/src/components/variants/VariantsEmpty.vue create mode 100644 web/src/components/variants/VariantsForbidden.vue create mode 100644 web/src/components/variants/VariantsSkeleton.vue create mode 100644 web/src/components/variants/VariantsTable.vue create mode 100644 web/src/components/variants/VariantsTabs.vue create mode 100644 web/src/composables/useImageVariants.ts create mode 100644 web/src/composables/useVariantForm.ts create mode 100644 web/src/composables/useVariantToasts.ts create mode 100644 web/src/lib/variants/__tests__/variantEstimate.spec.ts create mode 100644 web/src/lib/variants/__tests__/variantForm.spec.ts create mode 100644 web/src/lib/variants/__tests__/variantFormat.spec.ts create mode 100644 web/src/lib/variants/variantErrors.ts create mode 100644 web/src/lib/variants/variantEstimate.ts create mode 100644 web/src/lib/variants/variantForm.ts create mode 100644 web/src/lib/variants/variantFormat.ts create mode 100644 web/src/lib/variants/variantOutput.ts create mode 100644 web/src/stores/imageVariants.ts create mode 100644 web/src/views/VariantsPage.vue create mode 100644 web/src/views/__tests__/VariantsForm.spec.ts create mode 100644 web/src/views/__tests__/VariantsPage.spec.ts create mode 100644 web/src/views/__tests__/variantsTestKit.ts diff --git a/docs/design/components.md b/docs/design/components.md index b9e1463..73924e9 100644 --- a/docs/design/components.md +++ b/docs/design/components.md @@ -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 diff --git a/docs/design/routes.md b/docs/design/routes.md index e24ee71..36a23ac 100644 --- a/docs/design/routes.md +++ b/docs/design/routes.md @@ -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. diff --git a/docs/design/screens/image-variants.md b/docs/design/screens/image-variants.md index 3097595..58c1447 100644 --- a/docs/design/screens/image-variants.md +++ b/docs/design/screens/image-variants.md @@ -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 diff --git a/docs/design/source/RenewCMS Screens Image variants.dc.html b/docs/design/source/RenewCMS Screens Image variants.dc.html index b9cb136..dfa386f 100644 --- a/docs/design/source/RenewCMS Screens Image variants.dc.html +++ b/docs/design/source/RenewCMS Screens Image variants.dc.html @@ -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', [ diff --git a/docs/design/source/VariantsFrame.dc.html b/docs/design/source/VariantsFrame.dc.html index c892351..5e9e921 100644 --- a/docs/design/source/VariantsFrame.dc.html +++ b/docs/design/source/VariantsFrame.dc.html @@ -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 } } diff --git a/features/image_variants_screens.feature b/features/image_variants_screens.feature new file mode 100644 index 0000000..bc56ce1 --- /dev/null +++ b/features/image_variants_screens.feature @@ -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= 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 + 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 + + 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 "" + 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 , height , format and quality + Then the Dimensions cell reads "", the Format cell reads "